A user clicks log out, switches to their other tab, and is still signed in. You cannot reproduce it, because you test with one tab open, and with one tab open it works perfectly.
Two pieces of browser behaviour meet to produce this, and each is reasonable on its own.
The event you are listening for deliberately skips you
The storage event is how two tabs on the same origin find out about each other. MDN is exact about who receives it: it "fires when another document that shares the same storage area (either localStorage or sessionStorage) as the current window updates that storage area", and then flatly, "The event is not fired on the window that made the change."
That is the right design, and it is usually invisible. The tab where somebody clicked log out does not need an event, because it already knows. So the handler gets written, tested in a single tab, and the local half works. Whatever is broken about the cross-tab half has no symptom on your machine.
The event the other tabs get has no key on it
This is the part that costs the afternoon. Most handlers are keyed on the thing they care about:
window.addEventListener('storage', (e) => {
if (e.key === 'auth.token' && e.newValue === null) {
store.clearSession()
location.assign('/login')
}
})
That is correct if your logout path calls localStorage.removeItem('auth.token'). Plenty of logout paths call localStorage.clear() instead, which is the more cautious instinct when a session ends: take everything, leave nothing behind.
The HTML specification says exactly what clear() broadcasts. Its step is to "Broadcast this with null, null, and null." One event, not one per key, and key, oldValue and newValue are all null. There is nothing in it that names what was removed.
So e.key === 'auth.token' is false. The handler returns. The other tab keeps running with the token it read into memory at load, and keeps making authenticated requests with it until something forces a reload. Nothing throws. Nothing reaches your error tracker. The tab is just still logged in, which is the one outcome the feature existed to prevent.
Treating a null key as "all of it went" is the whole fix:
window.addEventListener('storage', (e) => {
const cleared = e.key === null
const tokenRemoved = e.key === 'auth.token' && e.newValue === null
if (cleared || tokenRemoved) {
store.clearSession()
location.assign('/login')
}
})
Worth knowing even if you never call clear() yourself: a third-party script on your page can, and your handler will not notice that either.
Why nobody reports this usefully
The reporter's description is of one tab. The state that explains the bug is in the other one, and they closed it before writing to you, because it did not look relevant. It was open, it was signed in, and that was the normal state of their afternoon.
So you get "logging out does not work", which is false in the sense that matters to you, since it plainly works. Everything a reporter can tell you about the tab in front of them is consistent with the feature being fine. Nothing about the way the problem presents itself suggests that the number of tabs is the variable, which is why the answer usually arrives by accident, weeks later, from somebody who happened to have two windows open while watching a network panel.
sessionStorage is not the way out
The obvious reaction is to put the token somewhere per-tab instead. That does solve the cross-tab disagreement, and it introduces a subtler question about where a new tab's session comes from.
MDN: "If the page has an opener, the sessionStorage is initially a copy of the opener's sessionStorage object. However, they are still separate and changes to one do not affect the other." The specification's version is that after creating a new auxiliary browsing context, "the session storage is copied over".
Whether a new tab starts with a copy of the old one's data therefore depends on how it was opened. window.open(url) with default features sets an opener, so the copy happens. A plain <a target="_blank"> does not, because target="_blank" now "implicitly provides the same rel behavior as setting rel="noopener"", and with no opener there is no auxiliary context to copy from. Two links that look identical in the markup, two different starting states.
None of this is obscure, and all of it is invisible from a single tab. That is the part worth carrying: the variable that explains the bug was never in the report, because the reporter had no reason to think a second tab was part of the story.













