What happens when the user opens a 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:
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.
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.