Web Platform · Browser Storage
Structured Clone Across Storage Boundaries
What the structured clone algorithm keeps when a value enters IndexedDB or crosses to a worker, which types it refuses, and how transfer differs from copying.
Storing a value in IndexedDB and sending it to a worker use the same serialisation: the structured clone algorithm. It is not JSON. JSON understands exactly six types and formats them as text; structured clone understands a fixed list of platform types, preserves object identity and cycles inside a single clone, and produces an internal representation that is not interchangeable with JSON and is not guaranteed to be portable between browsers. Every “my object lost its methods” and every “why is this not the same array” question about storage reduces to the properties of that algorithm.
A graph copy, not a tree serialisation
JSON serialisation walks an object tree and emits text; a cycle is an error and a shared reference becomes two independent copies. Structured clone walks the object graph and records each object it has already visited in a memory map. When it encounters a previously visited object it emits a back-reference rather than copying again.
Two properties follow. A cycle — an object that contains itself through any chain of properties, arrays, maps, or sets — serialises successfully and deserialises as a cycle. A value referenced from two places deserialises as one object referenced from two places, so clone.a === clone.b holds if the original had original.a === original.b. This is why the output is a graph copy and why the operation is proportional to the number of distinct objects, not the number of edges.
What is not preserved is the prototype chain. The algorithm records that a value is an ordinary object and copies its own enumerable properties; it never records the constructor. A class instance written to IndexedDB comes back as a plain object with the same properties and a prototype of Object.prototype. Methods, getters defined on the prototype, and private fields are gone. The same is true of postMessage.
class Money {
constructor(cents, currency) {
this.cents = cents;
this.currency = currency;
}
format() { return `${(this.cents / 100).toFixed(2)} ${this.currency}`; }
}
const tx = db.transaction('invoices', 'readwrite');
tx.objectStore('invoices').put({ id: 1, total: new Money(1250, 'EUR') });
// After reading back: { id: 1, total: { cents: 1250, currency: 'EUR' } }
// restored.total instanceof Money is false; format() is undefined.
Applications that need their classes back must rehydrate on read, reconstructing instances from the stored properties. Attempting to store the instance with its prototype does not work, and adding a method through a getter on the instance itself does not help either: the getter is invoked during serialization, and a function value throws DataCloneError.
Prototypes, array holes, and property descriptors
Three ordinary-object details behave unexpectedly in transit. An object created with Object.create(null) comes back with Object.prototype as its prototype, because the algorithm does not record the absence of a prototype; the clone is a normal object, so a key such as constructor that was previously a plain data property now collides with an inherited one. Sparse arrays keep their holes: the algorithm records the array’s length and then copies only own enumerable properties, so an index that was absent stays absent and 0 in clone is still false. Property descriptors are flattened to values, which means a frozen or non-writable property becomes writable and an accessor becomes a data property holding whatever the getter returned at clone time.
The prototype and descriptor changes matter when an object graph is passed through a boundary without being rebuilt: code that checks Object.getPrototypeOf(value) === null for a dictionary will not recognise the restored copy, and a frozen object comes back mutable. None of this affects a record built from primitives, arrays, maps, and blobs.
Which types survive intact
The algorithm has an explicit list. Primitive values other than symbols survive as themselves, including undefined, null, and NaN. Date objects survive as dates, with the time value preserved exactly, not as ISO strings. RegExp objects survive with source and flags. Map and Set survive as maps and sets, with their entries cloned recursively. ArrayBuffer and every typed-array view survive as the same kind of buffer or view, with the bytes copied. Blob and File survive and are stored by reference to their underlying data in a browser’s storage, so a large blob does not necessarily get copied into the database file. ImageBitmap survives after being decoded, and Error objects survive with their message and, for the standard error kinds, their name; a custom name is replaced by Error.
The refusals are equally explicit. Functions throw DataCloneError, which surfaces from postMessage synchronously and from an IndexedDB request as an error event that aborts the transaction. DOM nodes throw, because they belong to a live document that the clone cannot own. Promises throw. WeakMap and WeakSet throw, since their contents are not enumerable. Symbols as values throw, and symbol-keyed properties are simply not copied.
Plain objects are copied property by property, own and enumerable only. Inherited properties, non-enumerable properties, and properties keyed by symbols do not appear in the clone. Arrays are copied as arrays with their length and index properties, and extra own properties on an array survive as ordinary properties of the result.
The storage variant adds refusals
IndexedDB uses a restricted form of the algorithm, often called structured serialise for storage. The difference is not only in what types are accepted but in what is available in a storage context: there is no transfer list, so every buffer is copied rather than moved, SharedArrayBuffer objects cannot be stored at all, and neither can an object graph containing a MessagePort. Blobs and files are supported and are the correct way to store large binary payloads, because the implementation is free to keep them out of the record’s main storage area.
Reading a record returns a fresh deserialisation. The value the caller gets is not the object that was written and is not shared with the database; mutating it has no effect on what is stored, and storing a modified version requires another write. Two reads of the same record produce two distinct object graphs, so identity comparisons across reads fail, and a page that reads a large record repeatedly allocates a large graph each time instead of holding one.
Sizes are worth stating as orders of magnitude rather than measurements. Cloning cost scales with the bytes in buffers plus the number of properties; a record holding a megabyte-sized typed array copies that megabyte on write and again on read, through the browser’s internal representation and into the page’s heap. Values that are expensive to clone belong in a Blob or in separate records addressed by key, not inline in a large record that is read for one field.
Transfer is a move, and only for messages
postMessage accepts a second argument listing transferable objects. A transferred ArrayBuffer is not copied: ownership moves to the receiver and the sender’s buffer is detached, with byteLength becoming 0 and every view over it throwing on access. Message ports, ImageBitmap, OffscreenCanvas, and ReadableStream are transferable in current implementations. Transfer turns an O(n) copy into an O(1) hand-off, which is why bulk buffer transfer between workers is built on it.
The distinction matters because the storage APIs offer no transfer. IndexedDB has no transfer list, and neither does localStorage, whose strings are copied on every write and read. A design that moves a large buffer into a worker and then wants to persist it pays for a copy at the storage boundary regardless. A buffer listed once is transferred once even when the graph references it from several places: the serialization records the transferable before walking the graph, so every reference in the received value points at the same transferred buffer.
Snapshots, versions, and migration
Because the clone is a snapshot, a record’s schema is whatever the writer produced. Changing a class’s property names does not change stored records; the read path sees old shapes until the data is migrated. The versionchange transaction is the hook for that migration, and it runs the same clone rules, so a migration that writes class instances stores plain objects just as the original write did. Keeping the shape of stored records explicit — plain objects of primitives, arrays, and blobs — makes migration mechanical, and it keeps the boundary between a value in memory and a value at rest visible in the code rather than hidden behind a prototype that does not survive the trip.
References
- HTML Standard — Structured data — WHATWG
- MDN — Structured clone algorithm — MDN Web Docs
- Indexed Database API 3.0 — W3C