Web Storage
Coordinate tabs and handle failures
React to storage events, recognize last-write-wins conflicts, and keep the interface usable when a Web Storage write fails.
Two tabs of the same app share one localStorage. Both can write the same key. The API tells you when that happens, but it won’t resolve the conflict for you.
The storage event
When one document changes a Web Storage value, the browser fires a storage event in every other document on the same origin. The tab that made the change doesn’t get it. That surprises people the first time, but it makes sense: that tab already knows.
For a replaceable preference like the theme, the event is all we need. Another tab changes it, we apply it:
window.addEventListener('storage', event => {
if (event.key === 'field-notes:theme' && event.newValue) {
document.documentElement.dataset.theme = event.newValue
}
})
The event carries key, oldValue, newValue, and url, the address of the page that made the change. Open two tabs, switch the theme in one, and watch the other follow. Switch it back in the second tab, and the first follows. Neither tab ever sees its own change.
sessionStorage changes fire the event too, but only to documents in the same page session, which in practice means frames in the same tab.
Read-then-write is not a transaction
Here’s the conflict the event can’t fix. Two tabs both read a settings object, both change one field, both write the whole object back. The second write wins, and the first tab’s change is silently gone.
const settings = JSON.parse(localStorage.getItem('field-notes:settings'))
settings.fontSize = 'large'
localStorage.setItem('field-notes:settings', JSON.stringify(settings))
If another tab ran the same code with theme = 'dark' between your read and your write, your write erases the theme change. There’s no lock, no compare-and-set. Last write wins.
My rule: keep shared Web Storage values simple, one small value per key, so a write can’t clobber an unrelated field. When you need several tabs to update structured state safely, use IndexedDB transactions or let a server be the referee.
Writes can fail
setItem() can throw. The quota is full, often around 5 MB per origin. Or the browser blocks storage by policy. Some private modes and embedded contexts throw on every write. If you don’t catch it, the exception kills the rest of your event handler.
Catch at the write boundary and be honest with the user:
function saveTheme(theme) {
try {
localStorage.setItem('field-notes:theme', theme)
return true
} catch {
return false
}
}
When it returns false, apply the theme for this session anyway and show a small “not saved” note. The app keeps working. What you must not do is pretend it saved and let the user discover the truth after a restart.
Test it
Open two tabs and change the theme in each. Confirm that only the other tab receives each event. Then fill localStorage with junk until setItem() throws, or use a browser setting that blocks storage, and change the theme again. The interface must switch theme, show the non-persistent state, and log nothing red in the console.
Lesson completed