skip to content

A page writes to localStorage, but its own window.addEventListener('storage', ...) handler never runs — while another tab's handler does. Why, and which documents actually receive the storage event?

level: middleimportance: should knowfreq 52%

answer

  1. a notification for everyone else
  2. the writer already knows what it wrote
  3. branch on which key changed
  4. clear() reports no key at all
  5. rewriting the same value changes nothing

basics

~20 s

The storage event is a notification to OTHER documents: it fires on every same-origin window except the one that performed the write. The writing page already knows what it did, so it must update its own state directly rather than waiting for an event.

solid answer

~40 s

By specification the `storage` event is dispatched at every `Window` whose document shares that storage area and origin, *excluding* the document that made the change — so a tab never hears its own write. The event carries `key`, `oldValue`, `newValue`, `url` and `storageArea`; `key` and both values are `null` when `clear()` was called, and `newValue` is `null` for a removal. A write whose new value is identical to the stored one is a no-op and fires nothing, which breaks naive "ping" keys. For `sessionStorage` the event only reaches other documents sharing that tab's store, such as a same-origin iframe — never another tab. The practical shape is therefore: apply the change locally *and* let the event drive the other tabs, and remember a listener will not run in a frozen or discarded page.

code

javascript · 11 lines
javascript
window.addEventListener('storage', (event) => {
  if (event.storageArea !== localStorage) return;
  if (event.key === null) return logOut(); // clear() emptied the store
  if (event.key !== 'auth-token') return;
  if (event.newValue === null) logOut(); // removed in another tab
});

function signOut() {
  localStorage.removeItem('auth-token');
  logOut(); // this tab gets no event, so act directly
}

go deeper

for a junior

Remember the listener goes on window, is named 'storage', and notifies other tabs — the tab that made the change does not hear itself, so update your own UI directly.

for a middle

Explain who receives the event and who is excluded, name the event's fields, and describe what key, oldValue and newValue look like for a set, a remove and a clear.

for a senior

Show that you design the write path and the handler together, guard on the key and the storage area, handle a clear, and treat delivery as best-effort — idempotent handlers plus a re-read when the page becomes visible.

for a principal

Own the cross-tab consistency model: which state is genuinely shared, where a storage-event sync is adequate versus where a dedicated channel or lock is required, and how logout and session changes propagate predictably across every open surface.

## What the event is for `localStorage` is shared by every tab on an origin, but each tab has its own JavaScript heap and its own in-memory copy of whatever it read at startup. The `storage` event exists to close that gap: it tells the *other* documents that the shared store changed, so they can reconcile. The asymmetry in the question is the whole design. The specification dispatches the event at every `Window` whose document uses that storage area and origin, except the document that performed the write. From the platform's point of view the writer already has the new value in hand, so notifying it would be redundant. From a developer's point of view this is a trap, because the natural architecture — "write to storage, let the storage handler update the UI" — works in every tab except the one the user is looking at. The fix is to make the write path do both jobs: ```js function setTheme(theme) { localStorage.setItem('theme', theme); applyTheme(theme); // this tab, right now } window.addEventListener('storage', (event) => { if (event.key === 'theme') applyTheme(event.newValue); // other tabs }); ``` ## What the event object carries `StorageEvent` has five properties beyond the usual event members: - `key` — the key that changed, or `null` when `clear()` emptied the store. - `oldValue` — the previous value, `null` when the key did not exist before. - `newValue` — the new value, `null` when the key was removed or the store cleared. - `url` — the URL of the document that made the change. - `storageArea` — a reference to the `Storage` object involved, which lets one handler distinguish a `localStorage` change from a `sessionStorage` one. Always branch on `event.key` first. A handler that assumes its own key was the one that changed will misfire on every unrelated write, including writes from third-party scripts sharing the origin. And handle `key === null` explicitly: that is a `clear()`, which in an auth context usually means "everything went away", not "nothing happened". ## The same-value no-op The `setItem` algorithm compares the incoming value against what is already stored and returns early when they are equal. No write, no event. This quietly defeats the common trick of using storage as a message bus by writing a constant sentinel value: the first write notifies the other tabs, and every subsequent identical write does nothing at all. If you genuinely want to broadcast an event through storage, the value must change each time — a timestamp or a random nonce in the payload — and you should remove the key afterwards so it does not sit in the quota forever. That said, `BroadcastChannel` is the purpose-built API for tab-to-tab messages and does not abuse the store at all; reach for `storage` events when what you are syncing genuinely *is* persisted state. ## sessionStorage fires too, but narrowly The event is not exclusive to `localStorage`. A `sessionStorage` write also dispatches it, but only to other documents sharing that tab's session store — most commonly a same-origin iframe embedded in the page. It never crosses tabs, because the store itself never crosses tabs. ## Delivery is not a guarantee of promptness The handler runs as a task in the receiving document's event loop, so a heavily loaded or backgrounded tab may process it later than you expect; browsers throttle timers and other work in hidden tabs. A page sitting in the back/forward cache is frozen and will not run the handler while it is there. Handlers should therefore be written to converge on the current state — read the value or trust `newValue` and apply it idempotently — rather than to replay a sequence of deltas that assumes nothing was missed. On a page that has just been restored or made visible again, re-reading the store is more reliable than assuming every event arrived. ## Typical uses that hold up Cross-tab logout is the canonical one: when one tab clears the auth key, the others notice `key === 'auth'` with `newValue === null` and navigate to the login screen instead of firing failing requests. Theme and locale changes are similar. Sharing a "someone already refreshed the token" marker is another, though the coordination there is subtle enough that a dedicated lock mechanism is usually safer. ## What a weak answer sounds like "The event fires in all tabs including this one" — it does not. "I'll rewrite the same flag to poke the other tabs" — an identical value writes nothing. "The listener goes on the storage object" — it is dispatched at `window`. Getting these three right is most of what an interviewer is checking.

  • You use a key called 'ping' and write the string 'go' to it whenever you want the other tabs to refetch. Why does it work once and then stop?
    `setItem` compares the incoming value with the stored one and returns early when they are equal, so the second write of `'go'` performs no write and dispatches no event. Include something that changes — a timestamp or nonce — or use `BroadcastChannel`, which is designed for tab-to-tab messages and does not consume storage quota.
  • How do you tell a removeItem apart from a clear() inside the handler?
    Check `event.key`. A removal reports the specific key with `newValue === null` and the previous value in `oldValue`. A `clear()` reports `key`, `oldValue` and `newValue` all as `null`, meaning the entire area for that origin was emptied. Treating a null key as "nothing happened" is a common bug; in an auth context it usually means everything was wiped.
  • Is the storage event a reliable delivery channel?
    Treat it as a hint, not a guarantee. Handlers run as ordinary tasks, so a backgrounded tab can process them late, and a page frozen in the back/forward cache does not run them at all while frozen. Write handlers to converge on current state idempotently, and re-read the store when the page becomes visible again rather than assuming every event was seen.

saying these in an interview costs you the question

  • Expects the writing tab to receive its own storage event
  • Registers the listener on localStorage instead of window
  • Ignores event.key and reacts to every change
  • Treats a null key as a harmless no-op rather than clear()
  • Assumes rewriting the same value re-notifies other tabs

context