DEV Community

Cover image for I Built Cross-Tab Logout. The storage Event Skipped One Tab.
Parsa Jiravand
Parsa Jiravand

Posted on Originally published at bestpractic.org

I Built Cross-Tab Logout. The storage Event Skipped One Tab.

Three tabs open, same account, same session. Click "log out" in the middle one, and the plan is simple: every tab reacts, every tab redirects to the login screen, nobody's left holding a stale session.

I wired it the way you'd expect — localStorage.setItem('session', JSON.stringify({ loggedIn: false })) on logout, a window.addEventListener('storage', ...) listener that reads the new value and updates the UI. Opened two tabs to test it. Clicked logout in the first.

The second tab redirected instantly. The first tab — the one I'd just clicked in — sat there, fully rendered, still logged in, still showing the account menu, like nothing had happened.

The obvious read: "listen for storage changes"

Before BroadcastChannel existed (and still, in plenty of production code, when you need something that works back to IE11 and doesn't need a build step), the storage event was the way to sync tabs. The pitch is simple: any tab writes to localStorage, every other tab watching that origin gets notified.

function applyLogout(loggedIn) {
  if (!loggedIn) {
    renderLoggedOut();
  }
}

window.addEventListener("storage", (e) => {
  if (e.key !== "session") return;
  const { loggedIn } = JSON.parse(e.newValue);
  applyLogout(loggedIn);
});

// somewhere in the logout button's click handler:
localStorage.setItem("session", JSON.stringify({ loggedIn: false }));
Enter fullscreen mode Exit fullscreen mode

Reasonable code. It even looks correct in a two-tab test, if you only check the other tab. I didn't — I assumed the tab making the change would see its own write reflected the same way, because that's how almost every other kind of state-change notification behaves: you change something, your own UI picks it up.

What's actually happening: the event skips its origin

It's in the first sentence of MDN's page for the event, which I'd clearly skimmed instead of read: the storage event fires on Window objects other than the one that made the change. The tab that calls setItem doesn't get a storage event for its own write — not sometimes, not as a quirk in one browser. Every engine implements it this way, because the event's whole job is telling other documents "something changed somewhere else." The document where it changed already knows; dispatching the event back to itself would just be an echo.

Two more edges worth knowing while you're down here, because they're exactly the kind of thing that passes a two-browser-tab smoke test and then breaks in a slightly different shape in production:

  • No real change, no event. setItem('theme', 'dark') when theme was already 'dark' doesn't fire anything, anywhere — the spec compares old and new values first. If your UI update logic lives entirely inside the storage listener, writing the "same" value on purpose (to force a refresh, say) silently does nothing.
  • clear() reports key: null. Wiping the whole storage area doesn't name a key — event.key, oldValue, and newValue all come back null, which is the spec's way of saying "everything," not "nothing."

Neither one bit me this time. The missing self-notification did, because the entire feature's correctness depended on exactly one tab — the one where the user clicked — getting word of its own action, and that's precisely the tab the event is defined to skip.

The fix is two call sites, not a different API

The temptation is to blame the event and reach for something else. The actual fix is smaller: stop treating "write to storage" and "update this tab's UI" as the same step just because they usually travel together.

function applyLogout(loggedIn) {
  if (!loggedIn) {
    renderLoggedOut();
  }
}

window.addEventListener("storage", (e) => {
  if (e.key !== "session") return;
  const { loggedIn } = JSON.parse(e.newValue);
  applyLogout(loggedIn); // every OTHER tab arrives here
});

function logout() {
  localStorage.setItem("session", JSON.stringify({ loggedIn: false }));
  applyLogout(false); // THIS tab has to call itself — no event is coming
}
Enter fullscreen mode Exit fullscreen mode

One function, two call sites: the listener covers every tab that didn't make the change, and the action itself covers the one that did. It's an easy rule to forget specifically because it's invisible in testing unless you remember to check the tab you clicked in — which, if you're the one testing your own feature, is also the tab you're already looking at and already know the "right" answer for.

The one thing this old API still does that a newer one can't

It's tempting to read all of this as "just use BroadcastChannel instead" — the API that replaced storage-event tab-syncing for most new code, because it skips the JSON-stringify dance and doesn't fire on unrelated key changes. It's a genuinely better fit for most of what people reach for storage events to do.

But it trades away something the storage event gets for free, because localStorage isn't just a message pipe — it's a place the value actually lives. Open a third tab a few minutes after the logout happened in the other two, with no tabs sending anything at that moment, and it still loads logged out — because on load, it reads localStorage.getItem('session') directly, the same way it would read any other persisted value. There was no message to miss, because nothing had to be delivered. BroadcastChannel only reaches tabs that are listening at the moment a message is posted; a tab that opens later never receives what it missed, because there's nothing left to receive — the channel doesn't keep a backlog. If a new tab needs to know the current state rather than just future changes to it, something has to persist that state somewhere readable — and localStorage plus the storage event is still a reasonable way to get both the persistence and the live update in one write.

🎮 Try it yourself

▶️ Open the interactive playground →

Runs right in your browser — poke at it and watch the concept react live.

Try it with two real tabs

Everything above is easy to nod along to and easy to get backwards in actual code, because the bug only shows up in the tab you're not looking at — or, worse, the one you are, silently not updating while you assume it did. Open the demo below in two tabs and watch both directions at once: the tab that writes, and the tab that's told.

The part worth remembering

None of this is a storage-event bug. It's a one-line spec detail that's easy to miss because almost nothing else behaves this way — you change a variable, your own scope sees the new value; you call a setter, your own component re-renders. A browser event that deliberately excludes the one document that caused it is the exception, not the rule, and the only way to catch it before production is to actually check the tab you clicked in, not just the one next to it.

What's the last cross-tab or cross-window feature you shipped where you only tested the other window?

🧠 Test yourself

Think it clicked? Take the 8-question quiz →

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

📚 Read next


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Top comments (0)