# What happens when the user opens a second tab Source: https://selfstore.dev/blog/the-second-tab A server app serialises writes at the database. A local-first app has no such point, and every user eventually opens the app twice. The four ways that goes wrong, and the three browser primitives that fix it. A server-backed app has one place where concurrent writes meet, and it is not in the browser. Two tabs, two devices, two people - it does not matter, because every write ends up as a request to the same database, and the database decides what happens in what order. The client can be as naive as it likes. Somebody downstream is being careful on its behalf. Local-first removes that somebody. The database is in the tab, the tab is one of several, and nothing sits between them. This is usually described as a sync problem, which pushes it off to a chapter about devices and merge strategies. It is not. The second tab is the same browser, the same profile, the same IndexedDB, and it is open right now. Users make one constantly. They middle-click a link, they open the app from a bookmark while it is already running, they restore a session after a crash and get every tab back at once. ## The four ways it goes wrong **Stale state, silently.** Each tab holds its own in-memory copy of the data and its own reactive graph. IndexedDB does not notify anyone when a transaction commits: there is no change event, no subscription, nothing. Tab A saves, tab B keeps rendering what it read twenty minutes ago, and the user watches two windows of the same app disagree with each other about their own data. **Lost updates.** Anything shaped read-modify-write is a race the moment there are two writers. Load a record, change one field, put it back. Two tabs interleave those three steps and the second write silently overwrites the first one's field with the value it read before the change. Nothing errors. The data is not corrupt - it is just missing an edit the user made and watched succeed. **The upgrade that hangs.** Ship a version that bumps the IndexedDB schema. The user reloads one tab, which asks to open the database at the new version while the old tab still holds a connection at the old one. The upgrade cannot start: the new tab gets a `blocked` event and, unhandled, sits on a loading state forever - while the tab actually causing it looks fine and says nothing. **The backup written twice.** If the app writes a snapshot to a file or an external store on a timer, two tabs run two timers. They both write. The later one wins, and it may be the one that started from the older read. This is the worst of the four, because it is the only one that damages something outside the browser, and it does it to the copy the user is keeping precisely because it is supposed to be safe. ## What the browser actually gives you Three primitives, and the useful part is knowing exactly where each one stops. **IndexedDB transactions** are atomic and isolated, and the browser serialises overlapping `readwrite` transactions on the same object store. That is real, and it is enough to stop one transaction being torn in half. It is not enough for anything above it: the unit of atomicity is the transaction, not the operation your app thinks it is performing. A read in one transaction and a write in another is exactly the lost-update race, and nothing is obliged to notice the two were meant to go together. **BroadcastChannel** (Baseline widely available since March 2022) delivers a message to every other context on the same origin listening to the same channel name. Note *other*: the sender does not receive its own message, which is usually what you want and occasionally the reason a handler never fires. Payloads must be structured-cloneable. **The Web Locks API** (`navigator.locks`, also Baseline since March 2022) is the one most apps have never touched, and it is the one that does the real work. `navigator.locks.request(name, callback)` waits until it can hold the named lock, runs the callback, and releases when the callback settles. Locks can be `exclusive` (the default) or `shared`, for a readers-writer split. `ifAvailable` turns the wait into an immediate `null` when the lock is taken. And the property that makes the whole thing usable in a browser: **a lock is released when the context holding it goes away.** No timeout to tune, no stale lock file, no tab that crashed at the wrong moment and wedged the app until someone clears storage. What none of them give you is a leader. There is no built-in notion of a primary tab, and the last tab to be focused is not it. ## Three patterns that hold up ### Announce the write, not the payload After a transaction commits, post a small message on a BroadcastChannel saying what changed - a collection name, an id, a version number. Other tabs re-read from IndexedDB and update their own state. Send the fact, not the data. A message carrying the new record makes the channel a second source of truth, and the two diverge the first time one is dropped, arrives out of order, or comes from a version of the app that shapes the record differently. The database is already the source of truth in every tab; the message only has to say *look again*. ### One writer for anything that is not a single transaction Wrap read-modify-write in an exclusive lock named after what it protects, not after the tab holding it: ```js await navigator.locks.request('accounts:write', async () => { const current = await load(id); await save({ ...current, ...changes }); }); ``` The same applies to the backup writer, and there it matters more. One lock per external destination, held across the whole read-serialise-encrypt-write cycle, means the second tab's timer waits instead of racing. If waiting is wrong - a periodic job that is pointless to run twice - use `ifAvailable` and skip when the callback gets `null`. ### Elect a leader for the jobs that must run once Some work should happen once per browser profile, not once per tab: a sync poll, a compaction pass, a scheduled export. Request a lock and simply never release it. The tab that wins is the leader for as long as it lives, and when it closes the lock is freed and the next tab in the queue becomes leader without anyone detecting a failure or running an election. ```js navigator.locks.request('leader', () => new Promise(() => { startPeriodicWork(); })); ``` That callback never settles on purpose. It is the one place where a promise that never resolves is the correct design. ## Closing the connection is part of the contract The hanging upgrade is not fixed with locks. It is fixed by every tab handling `versionchange` on its open database connection, closing it, and telling the user in plain words that a newer version is loading. One handler, a few lines, and the failure mode changes from "the app never loads and the reason is in another window" to a reload the user understands. You will not see it in development: it appears the first time you ship a schema change to somebody who had the app open. ## Where the honesty line sits All of this is scoped to one origin in one browser profile. Web Locks do not coordinate across browsers, across profiles, or across devices, and they are not a substitute for a merge strategy - two devices editing offline is a different problem with a different answer. But it is the problem that bites first, and it bites in the least forgiving place: one user, one machine, two windows of your app disagreeing, with no network to blame. The second tab is a cheap way to lose the claim that the data is theirs and safe where it sits, and a cheaper one to keep it. Map of this site for a model: https://selfstore.dev/llms.txt Every page in one file: https://selfstore.dev/llms-full.txt