structuredClone vs JSON.stringify for IndexedDB

A common IndexedDB habit is to JSON.stringify a record before put() and JSON.parse it on read, the way you must with localStorage. For IndexedDB this is not only unnecessary — it is lossy. IndexedDB serializes values with the structured clone algorithm natively, the same algorithm exposed by the global structuredClone() function. Stringifying first throws away type information the database would have preserved for free: a Date becomes a string, a Map becomes {}, a Blob is destroyed. This guide compares the two and shows when each is actually correct. For foundational context review Data Serialization & Deserialization.

Type fidelity through structured clone versus JSON.stringify A diagram showing a record with Date, Map, and Blob preserved by IndexedDB's structured clone but flattened or lost by JSON.stringify. Record Date · Map · Blob ArrayBuffer · undefined put() — structured clone Date, Map, Blob preserved read back with original types intact JSON.stringify first Date → string · Map → {} Blob, ArrayBuffer, undefined lost Store native values directly — stringify only for string-only stores

The specific problem

When you write store.put(record), IndexedDB does not store a JavaScript object reference — it stores a structured clone of the value, made with the same algorithm the platform uses for postMessage and the standalone structuredClone() global. That algorithm understands a rich set of types: Date, RegExp, Map, Set, ArrayBuffer, typed arrays, Blob, File, and ImageData. If you stringify the record yourself first, you replace this rich serializer with JSON, whose type vocabulary is tiny. The record round-trips, but several of its fields come back as the wrong type, silently.

Root-cause analysis: two different serializers

JSON.stringify was designed for an interchange format that has objects, arrays, strings, numbers, booleans, and null. Everything else is coerced or dropped: a Date is converted to an ISO string (and never converted back on parse), a Map or Set serializes to {} because it has no own enumerable string keys, undefined and functions are omitted from objects and become null in arrays, a Blob or ArrayBuffer has no JSON representation at all, and BigInt throws a TypeError.

The structured clone algorithm is the platform’s deep-copy primitive and preserves all of those structured types. Its limits are different: it cannot clone functions, DOM nodes, or Error-subclass-specific fields, and it strips the prototype — a class instance comes back as a plain object with the same own properties but none of its methods. Cloning an unsupported value throws a DataCloneError. Crucially, IndexedDB already applies this algorithm for you on every put(), so the right default is to pass native values straight in.

Value type JSON.stringify round-trip Structured clone (IndexedDB put)
Date Becomes ISO string; not revived Preserved as Date
Map / Set Becomes {} / [] (data lost) Preserved
ArrayBuffer / typed array Lost (no representation) Preserved
Blob / File Lost Preserved
RegExp Becomes {} Preserved
undefined (object prop) Property dropped Preserved
BigInt Throws TypeError Preserved
Function Dropped Throws DataCloneError
Class instance Plain object, methods lost Plain object, methods lost (prototype stripped)
DOM node Dropped / {} Throws DataCloneError

The fix: store native values directly

Pass the record to put() without stringifying. IndexedDB clones it and returns the structured types intact on read.

interface Session {
  id: string;
  startedAt: Date;          // a real Date, not a string
  tags: Set<string>;        // a Set, preserved
  counters: Map<string, number>; // a Map, preserved
  avatar: Blob;             // binary, preserved
  draft?: string;           // may be undefined — kept by structured clone
}

function openDb(): Promise<IDBDatabase> {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open('session-db', 1);
    req.onupgradeneeded = () => req.result.createObjectStore('sessions', { keyPath: 'id' });
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

// Store the record as-is — NO JSON.stringify.
async function saveSession(session: Session): Promise<void> {
  const db = await openDb();
  await new Promise<void>((resolve, reject) => {
    const tx = db.transaction('sessions', 'readwrite');
    tx.objectStore('sessions').put(session); // structured clone happens here
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });
}

// Read it back with every type intact.
async function loadSession(id: string): Promise<Session | undefined> {
  const db = await openDb();
  return new Promise((resolve, reject) => {
    const tx = db.transaction('sessions', 'readonly');
    const req = tx.objectStore('sessions').get(id);
    req.onsuccess = () => resolve(req.result as Session | undefined);
    req.onerror = () => reject(req.error);
  });
}

For an in-memory deep copy of the same rich data — without touching the database — use the standalone structuredClone() global, which runs the identical algorithm synchronously:

const original = {
  when: new Date(),
  seen: new Set(['a', 'b']),
  scores: new Map([['x', 1]]),
  bytes: new Uint8Array([1, 2, 3]),
};

const copy = structuredClone(original); // deep, type-preserving copy
copy.seen.add('c');
console.assert(original.seen.size === 2, 'Original Set untouched by the clone');
console.assert(copy.when instanceof Date, 'Date preserved through structuredClone');

When you still must stringify

JSON.stringify is correct when the destination is genuinely string-only. localStorage and sessionStorage store strings, so a structured object must be serialized — see Best Practices for Serializing Complex Objects in sessionStorage for the reviver patterns that restore Date and Map.

// localStorage is string-only: stringify is required (and lossy by nature).
const prefs = { theme: 'dark', updatedAt: new Date().toISOString() };
localStorage.setItem('prefs', JSON.stringify(prefs));

const restored = JSON.parse(localStorage.getItem('prefs') ?? '{}');
// restored.updatedAt is a STRING — revive it explicitly if you need a Date.
const updatedAt = new Date(restored.updatedAt);

The rule of thumb: if the store understands structured clone (IndexedDB, the Cache API for Response bodies, postMessage), do not stringify. If the store is string-only (localStorage, sessionStorage, a URL parameter), you must.

Verification

Assert the types survive the round-trip:

const session: Session = {
  id: 's1',
  startedAt: new Date('2026-06-19T10:00:00Z'),
  tags: new Set(['offline']),
  counters: new Map([['saves', 3]]),
  avatar: new Blob(['hi'], { type: 'text/plain' }),
};
await saveSession(session);
const back = await loadSession('s1');

console.assert(back?.startedAt instanceof Date, 'Date preserved');
console.assert(back?.tags instanceof Set, 'Set preserved');
console.assert(back?.counters instanceof Map, 'Map preserved');
console.assert(back?.avatar instanceof Blob, 'Blob preserved');

In the DevTools Application panel, IndexedDB shows the stored value with its real types (a Date renders as a date, a Blob as binary), confirming no stringification flattened it.

Edge cases and a fallback

DataCloneError on put(). If the record contains a function, a DOM node, a WeakMap, or another non-cloneable value, put() throws a DataCloneError. Strip or replace the offending field before storing — for example, persist a serializable descriptor and rebuild the live object on read. Wrap put() in try/catch and log which key path failed.

try {
  store.put(record);
} catch (err) {
  if (err instanceof DOMException && err.name === 'DataCloneError') {
    console.error('Record holds a non-cloneable value (function/DOM node?)', err);
    // Fallback: store a sanitized, plain-data version instead.
    store.put(toPlainData(record));
  } else {
    throw err;
  }
}

function toPlainData<T extends object>(value: T): Record<string, unknown> {
  // Keep only structured-cloneable own data; drop methods/handlers.
  return JSON.parse(JSON.stringify(value));
}

Class instances lose their methods. Structured clone copies own enumerable properties but strips the prototype, so a new User() stored in IndexedDB returns as a plain object — user.fullName() is gone. Re-instantiate on read (Object.assign(new User(), record)), or store plain data and keep behavior in your code, not in stored instances.

Cache API bodies. When you store a Response in the Cache API, its body is bytes, not a cloned object graph — that is a separate model covered in IndexedDB vs Cache API for Offline JSON Payloads.

Frequently Asked Questions

Do I need to JSON.stringify objects before putting them in IndexedDB?

No. IndexedDB serializes values with the structured clone algorithm on every put(), preserving Date, Map, Set, ArrayBuffer, Blob, and typed arrays. Stringifying first replaces that rich serializer with JSON and silently loses those types.

Why did my Date come back as a string from IndexedDB?

Because you stringified the record before storing it. JSON.stringify converts a Date to an ISO string and JSON.parse does not revive it. Store the native Date directly and IndexedDB preserves it; only stringify for string-only stores like localStorage vs sessionStorage.

What does a DataCloneError mean when I call put()?

The record contains a value the structured clone algorithm cannot copy — typically a function, DOM node, or WeakMap. Remove or replace that field, or store a sanitized plain-data version and rebuild the live object on read.

Related