StorageGENERALBROWSER-SPECIFICDEVICE-SPECIFIC

localStorage and sessionStorage

A synchronous, string-only, origin-scoped map. The convenience is real, and so is the fact that every read and write blocks the thread that owns rendering.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

What am I actually doing to the main thread when I call localStorage.getItem, and why is sessionStorage not shared with the tab next to it?

The user intent

A person expects the small things to be remembered — the theme they chose, the sidebar they collapsed, the draft they were halfway through — without signing in or waiting for a request.

The obvious build

Web Storage is the easy one: two methods, no setup, works everywhere. Read it wherever you need it, write it whenever something changes, and store the object as JSON.

Why it breaks

Both APIs are synchronous. getItem returns a value rather than a promise because the browser blocks the main thread until the backing store answers — and that thread is the one that runs your JavaScript, computes style and layout, and produces the next frame (What the Main Thread Owns).

How it breaks in a real browser
  • Both APIs are synchronous. getItem returns a value rather than a promise because the browser blocks the main thread until the backing store answers — and that thread is the one that runs your JavaScript, computes style and layout, and produces the next frame (What the Main Thread Owns).
  • Writing on every change turns a fast interaction into a series of synchronous writes. A keyup handler that persists a draft on every keystroke serializes and writes the whole draft on every keystroke (Interaction Responsiveness).
  • It stores strings, so an object costs JSON.stringify on write and JSON.parse on read, both on the main thread, both proportional to the payload (The Real Cost of JavaScript).
  • A truncated or half-written value fails to parse, and code that does JSON.parse(localStorage.getItem(k)) without a guard throws during startup — which is the worst possible time, because nothing has rendered yet.
  • sessionStorage is per tab, not per browsing session. Two tabs on the same origin have two separate stores; duplicating a tab copies the store rather than sharing it, and closing the tab discards it.
  • Writes can throw. Quota, a private window and a full disk are all real, and the exception arrives on a line that has never failed in development (Storage Security and Durability).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • Both localStorage and sessionStorage implement the same Storage interface: getItem, setItem, removeItem, clear, key, length. Both map strings to strings, and both are scoped to the origin.
  • The difference is lifetime and sharing. localStorage is one store per origin, shared by every tab and window on that origin, and it survives browser restarts. sessionStorage is one store per origin per top-level browsing context, and it dies with the tab.
  • The synchronous contract is the interesting part: the specification defines these as returning values, not promises, which forces the implementation to make the main thread wait. Browsers cache aggressively in memory to make that fast, but the guarantee they must provide is a blocking one.
  • Because two tabs share one localStorage, a write in one tab fires a storage event in the *others* — never in the tab that wrote it. That event is the only built-in cross-tab notification in this API (State Synchronization).
  • Quota is per origin and shared with other storage on some browsers. Exceeding it raises a QuotaExceededError rather than evicting an old key to make room, so the failure is yours to handle.

What this makes the browser do

And which of it is avoidable.

  • Each call is a synchronous lookup in the origin's store, which the browser normally serves from memory but must be able to serve from disk — and disk latency lands directly on your handler.
  • Serialization is your code, not the browser's, and it runs on the main thread: JSON.parse of a megabyte is a long task by any definition (Long Tasks).
  • Writes are journalled to disk so they survive a crash. That work is scheduled by the browser, but the call that queued it was blocking.
  • A storage event dispatched to other tabs wakes those tabs — including backgrounded ones — and runs their listeners.
  • None of this can be moved off the main thread: Storage is not available to workers at all, which is itself a strong hint about how it is meant to be used (Web Workers and the DOM Boundary).

Read once, not on every interaction

The shape of almost every Web Storage performance problem is the same: a synchronous read placed on a path that runs often. The fix is not to make the read faster — you cannot — it is to move it off that path.

The code below is the pattern worth internalising. Hydrate from storage once, keep the value in memory, write back on change with a debounce, and treat everything coming out of the store as untrusted input written by a previous version of your application.

A preference store that does not block interactions
1type Prefs = { v: 2; theme: 'light' | 'dark' | 'system'; reduceMotion: boolean }
2
3const KEY = 'prefs'
4const DEFAULTS: Prefs = { v: 2, theme: 'system', reduceMotion: false }
5
6// One synchronous read, at startup, before anything is animating.
7function load(): Prefs {
8 let raw: string | null = null
9 try {
10 raw = localStorage.getItem(KEY)
11 } catch {
12 return DEFAULTS // private window, or storage disabled entirely
13 }
14 if (!raw) return DEFAULTS
15 try {
16 const parsed = JSON.parse(raw) as Partial<Prefs>
17 if (parsed.v !== 2) return migrate(parsed) // written by an older build
18 return { ...DEFAULTS, ...parsed, v: 2 }
19 } catch {
20 return DEFAULTS // truncated or corrupted: recover, do not throw
21 }
22}
23
24let prefs = load() // in memory from here on
25
26let pending: number | undefined
27function save(next: Prefs, onFail: (e: unknown) => void) {
28 prefs = next
29 clearTimeout(pending)
30 pending = setTimeout(() => {
31 try {
32 localStorage.setItem(KEY, JSON.stringify(next))
33 } catch (e) {
34 onFail(e) // quota or disk: the user may need to know
35 }
36 }, 400) as unknown as number
37}
38
39// The only cross-tab notification this API offers. It fires in OTHER tabs.
40addEventListener('storage', (e) => {
41 if (e.key === KEY && e.newValue) prefs = load()
42})

Three things are load-bearing: the read happens once, the parse can fail without taking startup with it, and the write failure reaches a caller that can decide what to do. The v field is what lets a build from six months ago hand data to a build from today.

What blocking looks like inside one interaction

It helps to see where the time goes in a single click. The handler runs as a task; the synchronous read and the parse run inside it; the browser cannot get to style, layout, paint or the next input event until the whole task finishes (The Event Loop, Precisely).

The two rows below are the same interaction with the same data — one reading and parsing inside the handler, one reading from memory. The absolute cost of either depends entirely on the device and the payload, which is why the units here are relative. What transfers is the shape: the parse is on the critical path to the frame, and it does not have to be.

A click handler, with and without a synchronous store readrelative units — proportions, not measurements; absolute cost scales with payload size and CPU
Input event dispatched
localStorage.getItem
JSON.parse of the value
Handler logic + state update
Style + layout
Paint + composite → frame
Same handler, value already in memory
Style + layout
Paint + composite → frame
  • Input event dispatchedThe task begins; nothing else runs on this thread until it ends
  • localStorage.getItemBlocking by contract — the thread waits for the store to answer
  • JSON.parse of the valueYour code, on the main thread, scaling with payload size
  • Style + layoutCannot start until the task above completes
  • Same handler, value already in memoryThe read and the parse happened once, at startup
  • Paint + composite → frameSame pixels, and the input-to-frame path is a fraction of the work

The saving is not the storage call — it is the parse the storage call forced. That is why "cache the parsed value" beats "call storage less often".

Writes that can fail, and reads that can lie

The second half of this lesson is error handling, which is unusual for an API this small. Both operations have a realistic failure mode that never occurs on a development machine: the write can throw at quota, and the read can return a string that is not the shape your code expects.

The comparison below is not about style. The left version loses user data silently in two different ways, and both of them look like the value was simply never saved.

Persisting a draft
Assumes both operations succeed
input.addEventListener('input', () => {
  localStorage.setItem('draft', JSON.stringify(state))
})

// on load
const state = JSON.parse(localStorage.getItem('draft')!)
Treats both as fallible
input.addEventListener('input', () => {
  scheduleSave(state) // debounced
})

function persist(state: Draft) {
  try {
    localStorage.setItem('draft', JSON.stringify({ v: 1, state }))
    lastSavedAt = Date.now()
  } catch (e) {
    // Do not swallow it: the user is about to lose work
    showBanner('Could not save your draft on this device.')
    report(e)
  }
}

// on load
const raw = safeGet('draft')
const draft = raw ? parseDraft(raw) ?? emptyDraft() : emptyDraft()

The left version writes synchronously on every keystroke, so it competes with rendering on the interaction the user is currently performing; it throws on quota with nothing catching it; and its non-null assertion turns a missing or corrupt value into a startup crash. The right version pays the storage cost once the user pauses, tells them when persistence failed rather than pretending it worked, and treats a bad value as an empty draft instead of an exception.

How to build it

Most important first.

  • Read once at startup, keep the value in memory, and write back on change. The blocking cost is then paid once on a path where nothing is animating, rather than inside every interaction (Who Owns This State?).
  • Keep values small — kilobytes, not megabytes. Web Storage is for preferences and small flags; anything you would describe as a dataset belongs in IndexedDB (IndexedDB).
  • Debounce writes on anything driven by typing or dragging. Persisting a draft once the user pauses is indistinguishable from persisting it per keystroke, and costs a fraction of the main-thread time (Yielding and Scheduling).
  • Wrap every read in a parse guard and every write in a try/catch. Treat a failed parse as "no stored value" and a failed write as something the user may need to know about — silently discarding both is how data loss becomes invisible.
  • Version the payload. Store { v: 2, ... } and migrate or discard on read, because the value in the store was written by a build that may be months old (Long-Lived Clients and Version Skew).
  • Use sessionStorage deliberately when tab-scoped lifetime is what you want — a wizard step, a return path across a redirect — and remember it is invisible to the tab next door (Login Redirects and the Open-Redirect Trap).
  • Subscribe to the storage event when two tabs can disagree in a way the user would notice, and reconcile rather than assuming your tab is authoritative (Auth Across Tabs).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • A synchronous read during an event handler blocks the main thread, and the accessibility tree is computed on that same thread. Focus updates land late, live-region announcements queue, and a screen-reader user experiences the stall with no visual cue that anything is happening (The Accessibility Tree).
  • This is the store that most often holds accessibility preferences — a reduced-motion override, a chosen theme, a larger text size — and that is a legitimate, valuable use of it. Read them once, early, and apply before the first interaction (Contrast, Colour and Motion).
  • Because the read happens in script, a stored theme applies after first paint, which can produce a visible flash. If that flash matters for contrast-sensitive users, the preference belongs in a cookie so the server can render it (Cookies).
  • Losing persisted state silently is worse for people who rely on it to resume. A draft rebuilt with voice control or switch access represents minutes of effort, not seconds, and an empty form with no explanation offers no way to recover (Live Regions and Announcement).

What can go wrong

Failure modes
  • QuotaExceededError on write. The user's work is not saved, the exception is caught and logged, and nobody sees it until a support ticket describes a draft that vanished.
  • A private window where storage is ephemeral or unavailable. Code that assumes setItem succeeds breaks in a mode a meaningful fraction of users are in.
  • JSON.parse throwing on a truncated value written during a crash or an interrupted write, taking down startup with it.
  • A large value read synchronously in an event handler, producing a frame drop that profiles as "your code" rather than as storage.
  • sessionStorage used for something the user expects to survive — they open a link in a new tab, the state is not there, and the app behaves as if they had never started.
  • The mitigation failing: a try/catch that swallows the write error turns a recoverable failure into silent data loss, which is a strictly worse outcome than the exception.
What can arrive out of order
  • Two tabs read the same key, both modify their copy, both write. The second write wins silently and the first tab's change is gone — a lost update with no conflict detection anywhere in the API (State Synchronization).
  • A storage event arrives in a tab that is mid-render, so the tab applies an external change on top of state it has not finished committing.
  • A write interrupted by the tab being closed or the process being killed can leave a partially written value that fails to parse on the next read.
Security
  • Every script running on the origin can read and write the entire store. There is no per-script partitioning, no equivalent of HttpOnly, and no way to hide a key from code you did not write (Third-Party Scripts and the Supply Chain).
  • That makes a successful injection a complete read of everything here, in one line, with no user interaction (Cross-Site Scripting).
  • The store is never transmitted, which means anything the server must see has to be attached by your code — a property that removes the automatic-CSRF surface and adds the responsibility of sending it (Cookies vs Script-Readable Tokens).
  • Values are stored unencrypted in the browser profile. Anyone with the device and the profile can read them, so shared and managed devices deserve explicit thought (Storage Security and Durability).
  • The browser enforces the origin boundary here absolutely: a different origin cannot read this store, and a document with a different scheme or port is a different origin (The Same-Origin Policy).
Misreads
  • "It is fast, so synchronous does not matter." Fast is not the property that matters; blocking is. A fast blocking call inside a mousemove handler still competes with rendering on every event.
  • "sessionStorage is shared between my tabs because it is the same session." It is per tab. This is the single most common surprise in this API.
  • "The storage event tells me when I write." It fires in *other* documents on the origin, never in the one that performed the write.
  • "setItem cannot fail." It throws on quota, and in some private-browsing modes it can throw on the first call.
  • "It is a cache, so losing it is fine." It is frequently the only copy of a user's draft or preference. Decide which it is, and say so in the code.
  • "I can use it from a worker to avoid blocking." Storage is not exposed to workers at all — that is the platform declining to make the blocking API concurrent (When a Worker Is Actually the Answer).

Measuring it, and what changes in the field

How you would see this
  • The Application panel lists every key and value with its size, which is usually where someone discovers that a "small preference" grew into a cached list (A Mental Model of the Devtools).
  • The Performance panel attributes synchronous storage access to the calling task on the main-thread flame chart — look for it inside input handlers specifically (Interaction Responsiveness).
  • A storage-estimate call reports usage against the browser's current quota for the origin, which is the only reliable way to know how much headroom you have.
  • Count quota and parse failures as their own error class in the field; both are effectively impossible to reproduce locally (Frontend Error Tracking).
Slow device, slow network, large data, old tab
  • On a slow device, the same synchronous read costs proportionally more, and so does the parse — the gap between a development machine and a mid-range phone is largest exactly on main-thread work.
  • On a device with little free disk, quota shrinks and writes start failing at sizes that worked yesterday.
  • In a private window, storage may be memory-backed and much smaller, or unavailable entirely, depending on the browser.
  • With a large stored payload, the cost is not the storage call — it is the JSON.parse, and it scales with the payload rather than with the number of keys.
  • In a long-lived tab with several siblings open, cross-tab writes accumulate and the last writer wins with no ordering guarantee (State Synchronization).
What this costs
  • Reading once and caching in memory removes the per-interaction cost and introduces a staleness problem: another tab can change the store underneath you, and only the storage event tells you.
  • Debouncing writes reduces main-thread work and widens the window in which a crash loses the most recent changes. That window is a product decision, not a technical one.
  • Using sessionStorage for tab-scoped state gets you cleanup for free and guarantees the state is missing when the user opens your app in a second tab.
  • Moving a growing value to IndexedDB removes the blocking read and costs you an asynchronous API, a schema and an upgrade path (IndexedDB).

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALThe synchronous contract, the string-only values, the origin scoping and the per-tab lifetime of sessionStorage are specified behaviour and hold across Blink, Gecko and WebKit alike.
  • BROWSER-SPECIFICQuota size, whether Web Storage shares a budget with IndexedDB, and how long script-written storage survives are implementation policy: Safari has applied time-based deletion to script-written storage that Chrome and Firefox do not, so an app that works for months in one browser can lose state in another.
  • DEVICE-SPECIFICThe main-thread cost of a synchronous read plus a parse scales with CPU, so the same code that is unmeasurable on a development laptop can be a visible stall on a mid-range phone with a cold disk cache.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Domains that do not exist yet
  • Programming Languages & Runtime Internals — JSON.parse allocates an entire object graph, so a large stored payload is also a garbage-collection event on the same thread that owes the user a frame.