Published on

Data Corruption from Bad Serialization — When Your Data Silently Changes

Authors

Introduction

Serialization bugs are insidious because they don't throw errors. The data goes in, comes out, looks approximately right — but is subtly wrong. A price stored as a float accumulates rounding errors. A BigInt truncated to a Number loses precision. A Date serialized without timezone info becomes the wrong time. These bugs corrupt production data, silently, for months before anyone notices.

Bug 1: Floating Point Money

// ❌ Never store money as float — floating point arithmetic is imprecise
const price = 19.99
const tax = price * 0.1
console.log(tax)  // → 1.9999999999999998 (not 2.00!)

const total = price + tax
console.log(total)  // → 21.988999999999997 (not 21.99!)

// After storing and retrieving: the stored value is wrong
await db.query('UPDATE products SET price = $1', [price * 1.1])
// price is now 21.988999999999997... stored in the database

// ✅ Use integer cents (multiply by 100)
const priceInCents = 1999  // $19.99
const taxInCents = Math.round(priceInCents * 0.1)   // 200 cents = $2.00
const totalInCents = priceInCents + taxInCents       // 2199 cents = $21.99

// Display: divide by 100 only for display
const display = (totalInCents / 100).toFixed(2)  // "21.99"

// Or use a decimal library
import Decimal from 'decimal.js'

const price = new Decimal('19.99')
const tax = price.mul('0.1')
console.log(tax.toString())   // "1.999" → Math.round to "2.00"
const total = price.add(tax.toDecimalPlaces(2, Decimal.ROUND_HALF_UP))
console.log(total.toString())  // "21.99" — exact

Bug 2: BigInt/Large Integer Truncation in JSON

JavaScript's JSON.stringify cannot serialize BigInt — it throws. But even worse, large integers that exceed Number.MAX_SAFE_INTEGER (9,007,199,254,740,991) lose precision silently:

// Database returns a BIGINT: 9007199254740993 (> MAX_SAFE_INTEGER)
const id = 9007199254740993n  // bigint in JS

// ❌ JSON.stringify BigInt throws:
JSON.stringify({ id })  // TypeError: Do not know how to serialize a BigInt

// ❌ Converting to Number loses precision:
Number(9007199254740993n)  // → 9007199254740992 (WRONG — last digit changed!)

// ✅ Serialize as string — no precision loss
JSON.stringify({ id: id.toString() })  // { "id": "9007199254740993" }

// In Express response:
res.json({ id: row.id.toString() })
// pg (node-postgres) returns BIGINT as string by default — use it!
const result = await db.query('SELECT id FROM users WHERE email = $1', [email])
const userId = result.rows[0].id  // "9007199254740993" — already a string ✅

// Never do this:
const userId = parseInt(result.rows[0].id)  // loses precision for large IDs!

Bug 3: Date Timezone Corruption

// ❌ Parsing date without timezone — depends on system locale
const date = new Date('2026-03-15')
// On UTC server: midnight UTC
// On EST server: midnight EST (= 5 AM UTC)
// If you store this in DB as UTC, it's wrong

// ❌ toLocaleDateString() — produces non-parseable string
const stored = new Date().toLocaleDateString()  // "3/15/2026" — no timezone info!
// When you try to parse this back:
new Date('3/15/2026')  // locale-dependent, might even fail

// ✅ Always use ISO 8601 UTC
const stored = new Date().toISOString()  // "2026-03-15T14:00:00.000Z"
// Parseable, timezone-aware, sortable as a string

// ✅ Parse with explicit timezone context
import { parseISO } from 'date-fns'
import { fromZonedTime } from 'date-fns-tz'

// User input "2026-03-15 14:00" in America/New_York:
const utc = fromZonedTime('2026-03-15T14:00:00', 'America/New_York')
const stored = utc.toISOString()  // Always stored as UTC

Bug 4: JSON with Prototype Pollution or Loss

// ❌ Storing a class instance as JSON loses methods and type info
class Price {
  constructor(public cents: number, public currency: string) {}

  format() { return `${this.currency} ${(this.cents / 100).toFixed(2)}` }
}

const price = new Price(1999, 'USD')
const stored = JSON.stringify(price)  // '{"cents":1999,"currency":"USD"}'

const retrieved = JSON.parse(stored)  // plain object, not a Price instance
retrieved.format()  // TypeError: retrieved.format is not a function

// ✅ Use plain data objects for serialization, reconstruct on retrieval
const serialized = { cents: price.cents, currency: price.currency }
const retrieved = new Price(serialized.cents, serialized.currency)
retrieved.format()  // works

Bug 5: NaN and Infinity in JSON

// ❌ JSON.stringify silently converts NaN and Infinity to null
const data = { score: NaN, ratio: Infinity, rate: -Infinity }
JSON.stringify(data)  // '{"score":null,"ratio":null,"rate":null}'

// ❌ You store null, retrieve null, think the value was missing
// But it was NaN/Infinity from a division error

// ✅ Validate before storing
function sanitizeNumber(value: number, fieldName: string): number {
  if (!isFinite(value)) {
    throw new Error(`Invalid numeric value for ${fieldName}: ${value}`)
  }
  return value
}

const score = calculateScore()  // might return NaN if input was bad
const safeScore = sanitizeNumber(score, 'score')  // throws instead of silently corrupting

Bug 6: Undefined Values Dropped from JSON

// ❌ JSON.stringify drops undefined properties
const user = {
  id: '123',
  name: 'Sanjeev',
  middleName: undefined,  // user has no middle name
}

JSON.stringify(user)
// '{"id":"123","name":"Sanjeev"}'
// middleName is completely gone — when you parse this, there's no way to distinguish
// "no middle name" from "middle name not loaded from DB"

// ✅ Use null explicitly for "no value"
const user = {
  id: '123',
  name: 'Sanjeev',
  middleName: null,  // explicit null
}

JSON.stringify(user)
// '{"id":"123","name":"Sanjeev","middleName":null}'
// Now you know the field exists and is empty

Serialization Safety Checklist

  • ✅ Never store money as float — use integer cents or decimal.js
  • ✅ Serialize large integers as strings — never convert BIGINT to Number
  • ✅ Always store dates as ISO 8601 UTC (toISOString())
  • ✅ Validate for NaN / Infinity before JSON serialization
  • ✅ Use null instead of undefined for "no value" in JSON
  • ✅ Reconstruct class instances after JSON.parse — don't call methods on plain objects
  • ✅ Use decimal.js or big.js for any arithmetic on decimal numbers

Conclusion

Serialization bugs are silent — they produce wrong data with no error message. The most damaging are floating-point money calculations (use cents), BigInt truncation (serialize as strings), and timezone-unaware date parsing (always use ISO 8601). These aren't obscure edge cases: they happen routinely in any app that handles money, large IDs, or dates. The fix for each is a simple rule applied consistently: money in cents, IDs as strings, dates in UTC ISO 8601, and null instead of undefined.