Web Platform · Browser Storage

The Synchronous Storage Contract of localStorage and sessionStorage

Why the Web Storage APIs expose a blocking string map, what that costs on the main thread, how storage events propagate, and where the quota boundary shows up.

The Storage interface is deliberately primitive: a string-to-string map with getItem, setItem, removeItem, clear, and a length/key(n) pair for enumeration. There are no transactions, no indexes, no asynchronous callbacks, and no schema. That minimalism is a design statement — the API was specified to be implementable by any browser with a key-value file and to stay out of the way of the page — and nearly every awkward property of localStorage follows from it.

One map per area, resolved per origin

A document gets two areas. localStorage is keyed by the origin, extended by the top-level site when the document is a third-party context, so same-origin documents in the same partition share one map. sessionStorage is keyed by the origin and the top-level browsing context: each tab has its own session area, same-origin iframes inside that tab share it with the parent, and a same-origin tab opened by window.open starts with a copy of the opener’s session area and then diverges. Following a link in the same tab preserves it; restoring a session after a crash may or may not, depending on the browser’s session-restore behaviour.

Both areas are reachable as properties of window and throw a SecurityError when the document’s origin is opaque, which includes sandboxed iframes without allow-same-origin and, in most browsers, documents loaded from file:. The Storage interface is exposed only to Window; workers have no access to either area, so a worker that must share state with a page either messages the page or uses IndexedDB.

Enumeration is not ordered by any guarantee in the specification. key(n) returns “the nth key”, the order is implementation-defined, and the specification permits it to change on most mutations. Code that depends on order is relying on an implementation detail.

Strings only, and the serialisation tax

Every value crosses the boundary as a string. Writing a number and reading it back yields a string; writing an object without serialising it yields "[object Object]" and no error. The idiom is JSON.stringify on write and JSON.parse on read, which means the value is serialised twice over: once by the application into JSON text, and once by the browser into whatever encoding its backing store uses.

The cost is proportional to the payload and is paid on the thread that called setItem: parsing a megabyte of JSON is orders of magnitude more work than parsing a small settings object, and the browser’s own copy of the string is added on top. Since the value returned by getItem is a fresh string, a naive render loop that reads and parses a large collection on every frame allocates continuously and keeps the garbage collector busy.

const settings = { theme: 'dark', density: 'compact', showHints: false };

// Serialise explicitly; setItem accepts only strings.
localStorage.setItem('settings', JSON.stringify(settings));

// Parse defensively: the value may have been written by an older version.
const raw = localStorage.getItem('settings');
const loaded = raw === null ? null : JSON.parse(raw);

Nothing validates a stored value. A schema change in the application leaves old records in place, and the read path must tolerate a parse failure or a missing field. This is the same problem IndexedDB solves with versioned database upgrades and object store indexes, at the cost of an asynchronous API; the trade is documented in IndexedDB key ordering.

Synchronous by design, and what it blocks

getItem and setItem return only after the operation completes. The specification requires the API to be synchronous, and implementations honour that by performing the read or write on the calling thread, potentially touching disk-backed state and potentially synchronising with a storage process shared by other renderer processes. There is no way to await a write.

The consequences are structural rather than incidental. A write can block the main thread while the browser synchronises with its storage backend, so a burst of setItem calls in a loop becomes a burst of synchronous work on the thread responsible for input and rendering. Because the areas are shared across tabs, the browser must also keep copies coherent, which is part of why the operation is not cheap enough to call per animation frame. The guidance that follows is mechanical: keep values small, write on state transitions rather than on every keystroke, and move anything large to an API whose operations can be scheduled off the critical path.

sessionStorage pays the same cost, with one advantage: its contents die with the tab, so a form draft kept there does not accumulate across visits.

Why there is no read-modify-write primitive

An earlier revision of the HTML specification described a storage mutex: a lock the browser would take around script execution that touched the storage areas, so that two documents could not interleave a read and a write. It was never implemented as specified and was removed, because holding a global lock across arbitrary script would have blocked the browser’s own processes and risked deadlock between pages. What remains is a plain map with per-operation atomicity only: a single setItem is not torn, but a getItem followed by a setItem is two operations with a gap between them.

Any concurrent protocol built on top of localStorage therefore has to tolerate the gap. The common attempt is a token written to a key, a delay to let other writers observe it, and a re-read to confirm ownership; it is a race with a timing assumption in the middle, and it fails under scheduler delay, background-tab throttling, and the ordinary case of a slow main thread. The platform’s actual exclusion primitive is the Web Locks API, described in the coordination article, and the storage event is only a notification that a value changed, not a claim on it.

The storage event is a notification, not a lock

When a value in a localStorage area changes, the browser fires a storage event in every other same-origin document whose area is affected. The document that performed the write does not receive the event. The event carries key, oldValue, newValue, url, and storageArea, and it fires only when the write actually changes the stored value — setting a key to the value it already holds is a no-op with no event. Calling clear() fires an event with key, oldValue, and newValue all null.

window.addEventListener('storage', (event) => {
  if (event.storageArea !== localStorage) return; // also fires for sessionStorage
  if (event.key === 'theme') {
    applyTheme(event.newValue ?? 'light');
  }
});

Three properties of this mechanism limit what it can be used for. Delivery is asynchronous, and the specification does not define an ordering relative to the writes, so the last event received is not proof of the last write. It provides no acknowledgement, so a writer cannot tell whether peers processed the change. And it does not fire for sessionStorage changes in other tabs, because those tabs have different areas. Cross-tab mutual exclusion needs a different primitive; the coordination article covers Web Locks and BroadcastChannel.

Where the quota boundary appears

The Storage Standard registers a recommended quota of 5 mebibytes (5 × 2^20 bytes) for the localStorage endpoint, and browsers have converged on a limit of that order. The accounting is against the string data rather than its encoded size on disk: a JavaScript string is a sequence of UTF-16 code units, and a character outside the Basic Multilingual Plane occupies two of them, so text containing emoji or other supplementary characters consumes more quota than its code-point count suggests. Exceeding the limit makes setItem throw a QuotaExceededError (a DOMException with the legacy name QUOTA_EXCEEDED_ERR), and the previous value for that key is left intact.

The failure is per call and easy to miss: an application that catches nothing loses the write and continues as though it succeeded. Browsers in private modes have historically exposed a zero or tiny quota, making every write throw; the same class of failure appears when a user’s disk is full or when site data has been configured to be discarded. A write path that treats storage as best-effort and keeps an in-memory copy is the only version that degrades predictably.

localStorage is not a cache and has no eviction policy of its own: values stay until overwritten, removed, or cleared with the rest of the origin’s data. The five-mebibyte figure applies to the Storage area specifically, while IndexedDB and the Cache API draw on the shelf’s much larger quota; both are bounded by the same per-origin budget, described in quota accounting and eviction.

References

  1. HTML Standard — Web storage — WHATWG
  2. Storage Standard — WHATWG
  3. MDN — Window.localStorage — MDN Web Docs
  4. MDN — StorageEvent — MDN Web Docs