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.
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
- Data Serialization & Deserialization — the parent guide on serializing for browser storage.
- Best Practices for Serializing Complex Objects in sessionStorage — reviver patterns for string-only stores.
- IndexedDB vs Cache API for Offline JSON Payloads — how the Cache API models bodies versus IndexedDB records.
- Storing Blobs & Files in IndexedDB — relying on structured clone to persist binary data.
- localStorage vs sessionStorage — the string-only stores where stringify is genuinely required.