Skip to content

One tab indexes, by a Web Lock the app names, and the others are built as readers ​

Every tab that opened an app built its own indexer. The newest writer claim won (ADR-0075, ADR-0077) and the older tabs demoted to readers (ADR-0078): correct, and N times the eth_getLogs calls, bandwidth and CPU for one store's worth of state; nothing handed the write duty back when the indexing tab closed, so the remaining tabs went stale; and every new tab fetched until it lost. We decide, with the maintainer on 2026-09-27 (decisions D1 to D4 of one-tab-indexes-and-the-others-read), that one tab indexes, elected by ONE Web Lock per APP whose name the app supplies; a tab that does not hold it is BUILT AS A READER from a reader factory the app supplies beside createState; the election is opt-in and is the documented default; and a foreground tab taking the lease from a backgrounded one is out of this first cut (built since, on 2026-09-28: see D4 as amended). The writer claim stays the correctness guarantee underneath: election is for COST, never for correctness.

D1: the lock is named per APP, by the app ​

A host option, tabElection: {name} (on createIndexerState's options and on a worker host's entry), names ONE Web Lock (navigator.locks, composed as etherfold/tab-election/<name>), held by the host that indexes for as long as it indexes, covering every generation that host holds. It is named the way a SharedWorker's name is, because the spec's first answer, the store's storage identity, does not survive contact with the host: the claim is scoped to a database name the APP chooses inside createState, per generation, so the host cannot name that lock before it has claimed; and one host holds several generations with a store each, so a lock per store would have one tab holding the canonical generation's lock and not its successor's. Two apps on one origin supply different names and never contend.

Web Locks is the whole mechanism. Acquisition is atomic, and the browser releases the lock when the holder's tab or worker dies, crash included, so there is no heartbeat, no stale threshold and no timeout; a queued tab is granted it the moment it is released. steal is used in exactly one place, the foreground takeover (D4 as amended), and nowhere else. The localStorage plus BroadcastChannel fallback the spec describes is not built.

Where the lock is taken: by the HOST, wherever the host runs. The main-thread hook takes it in the tab. A worker host (serveIndexerHost, reached through hostIndexerInThisWorker / hostIndexerInThisSharedWorker) takes it INSIDE the worker, because the worker is what indexes: the browser then releases it when that worker is terminated or evicted, and when the tab that owns a dedicated worker closes or crashes, since the worker dies with it. A tab that took the lock for a worker it owns would hold the write duty for a fold it does not run.

A SharedWorker host takes it too, through the same code. It is one indexer per origin and script, so its own tabs never contend for it and taking it costs one uncontended request; what taking it buys is that a SharedWorker host and dedicated-worker hosts of the same app (a browser without SharedWorker, say) are in ONE election rather than two writers. There is one path, as the spec asked.

D2: a READER FACTORY sits beside createState ​

BrowserGenerationSpec.openState(context, bundle?) returns {store, state}: the same storage createState would open, opened with openForReading, and the read handle over it (new EntityStateView(store) for an entity processor). A host that finds the lock held is built from it at once: no claim, no processor, no fetch, no seed install and no snapshot bootstrap (both are writes). The context is the {stream} the container would hand createState, computed the same way (streamDigestOf over the resolved stream config), so reader and writer address the same storage. Its reads answer the shared store (the main-thread hook publishes state from it, and a port's reads are served from store), it follows the state-moved signal the leader publishes, and its progress is the leader's. It queues for the lock; when the browser releases it, the reader becomes the writer through ADR-0078's recovery path: a fresh start through createState, taking the claim, loading the stored cursor and indexing forward from it.

A leader publishes; it is not polled. The leader forwards its container's state-moved values (ADR-0083, repointed included) and its HostProgress reports over a BroadcastChannel named from the election name, using the cross-tab adapter's own envelope (openStateMovedAcrossTabs is the same code under a storage-scoped name). A reader forwards the values to its own subscribers unchanged and reports the leader's phase and block figures under its own host and scope; the leader's failure, publication, streamSeed and hotUpdate are not carried over, because they describe the leader's host. A reader answers checkTxInclusion with unknown / not-synced: it has no cursor of its own and the published figures are not one.

The role and a takeover are reported on the existing status surface: HostProgress.election over a port ({name, role: 'reader' | 'writer', tookOver}) and SyncingState.election on the main-thread hook. tookOver is true for a host that became the writer after waiting behind a lock another tab held, measured by asking for the lock with ifAvailable first.

While reading, the main-thread hook drives nothing. startAutoIndexing() is remembered and answers true (the loop starts on the takeover); indexMore() and its siblings answer undefined, the same "no cursor" answer a demoted tab gives. A worker host's driver waits for the lock the way it waits for a tab's provider, and a reconfigure or hot update asked of a reader waits for the takeover, because both build generations and building one claims.

A host that stops being a writer gives the lock back. A demoted main-thread tab (a refused write, or demoteToReader) and a worker host whose driver stopped on a failure resign, because holding the lock while writing nothing would stop every other tab from taking over. A demotion is still one-way for that container, as ADR-0078 says.

D3: opt-in, and the documented default ​

The election runs when the app supplies BOTH the name and the reader factory, and where navigator.locks exists in the host's scope. Otherwise a host behaves exactly as it did before (a runtime without the primitive is noted once in the log, not refused). Existing entry points are therefore unchanged. The guide ("When another tab takes the store") and examples/browser-reference use it, which is what makes it the default an app copies.

D4: a visible tab takes the lease from a backgrounded leader (amended 2026-09-28) ​

As first decided (2026-09-27): a throttled background tab holds its lock perfectly well while doing little, and without a way for a visible tab to ask for the lease a backgrounded leader indexes slowly: a cost, not a correctness problem. Deferred to a later task.

As built (2026-09-28, with the maintainer, task a-visible-tab-takes-the-lease-from-a-backgrounded-leader): when a READER's tab has been VISIBLE while the leader is HIDDEN, without a break, for a settle time, the reader takes the lease and the hidden leader becomes a reader.

  • The leader publishes its visibility beside its progress, on the election's existing channel, as HostProgress.election.visibility (TabElectionState.visibility). A leader that publishes NONE does not take part and is never displaced: that is a leader with the takeover turned off, a SharedWorker host, and a worker no tab has reported to.
  • The rule a visible reader applies (leaderIsDisplaceable): the leader said it is hidden, or it has said NOTHING. A reader asks for the leader's progress as it is seated and a live leader answers at once (a new leader also announces its seat the moment it holds the lock, before its container opens), so silence past the settle time is what a FROZEN leader looks like, and a frozen leader is the case this exists for. A leader that said it is visible is never displaced.
  • The settle time is two seconds (DEFAULT_FOREGROUND_SETTLE_MS, overridable as foregroundTakeover: {settleMs}), measured as how long the whole condition has held: longer than a sweep across tabs with the keyboard or the mouse, so passing over a tab moves nothing, and short beside what it fixes, since a hidden tab's timers are clamped to a second or a minute and a tab hidden for minutes is frozen. It is also the bound on displacing a frozen leader.
  • The mechanism is navigator.locks.request(name, {steal: true}), and no cooperative step-down. A FROZEN tab cannot answer a request to step down, so a cooperative protocol needs the steal as its fallback anyway, and the fallback alone does the whole job: the browser releases the lock from its holder at once, grants it to the visible tab ahead of any queue (a cooperative release would hand it to whichever tab queued first, often another hidden one), and REJECTS the holder's own lock request, which is how a live holder learns it lost, with no message of ours. The one thing a cooperative step would add is a leader that stops before the new one starts, and that is exactly the window the writer claim already covers: if both write for a moment, the loser's write is refused and it demotes, as the section below says.
  • The displaced leader stops cleanly and reads. On the main thread it goes through the ordinary demotion (demoteToReader, reason lease-lost: the loop stops, the container's in-flight batch is cancelled and never applied, the cursor is dropped), then lets go of the container, is built from openState again, follows the new leader, and QUEUES for the lock again, so it takes over once more when the tab that displaced it goes away. A worker host does the same with its driver: the tenure ends, the batch is abandoned, the container is let go, and the driver waits for the lock again. Its seat says so (displaced: true). The new leader makes ADR-0078's fresh start (a new claim through createState, the stored cursor), and its seat names why (takeoverReason: 'leader-backgrounded', beside 'leader-gone' for the close-or-crash handover).
  • Visibility lives in the tab. The main-thread host reads its own document. A dedicated-worker host has none, so the TAB reports it over the port: connectToIndexerHost posts the visibility case from any scope that has a document, after the connect, on every visibilitychange, and again to a restarted host. A SharedWorker host does not take part: it is one indexer for every tab it serves, not a background tab (its timers are not a page's), so it ignores the case, publishes no visibility, is never displaced and never takes the lease. Counting it visible while any of its tabs is was considered and not built, since the throttling it would answer does not happen to it.
  • On by default whenever the election is on, the maintainer's preference; tabElection: {name, foregroundTakeover: false} keeps the first cut's behaviour exactly (no visibility published, no steal). Without the election nothing changes at all.

Residual: two tabs visible at once (two windows side by side) with a leader that has not been heard yet can move the lease once, to the other visible tab; neither is then displaceable, so it does not bounce, and the writer claim keeps the store correct either way.

Election is for cost, never for correctness ​

Every failure of the election costs RPC calls and never data: two hosts that both believe they lead (a tab with no election on the same store, a runtime without Web Locks, a zombie waking from a throttled tab) are settled by the writer claim, and the loser demotes exactly as before. That case is asserted rather than stated (test/oneTabIndexesAndTheOthersRead.test.ts, and browser/oneTabIndexesAndTheOthersRead.spec.ts in real tabs).

Amendments ​

  • ADR-0077: where the claim is taken is still the factory that builds a generation's store (createState), and a tab that only renders still never comes through it; what is new is that a HOST can now be that tab, built from openState, and it moves from the one factory to the other only by winning the election.
  • ADR-0082: "the non-leader workers are not idle" was written ahead of any leader; with the election on, a non-leader worker serves its own tab's reads from the shared store and follows the leader, and a SharedWorker still needs no election of its own but takes the same lock, uncontended, so it can share one election with dedicated-worker hosts of the same app.
  • ADR-0078: a demotion is still one-way for the CONTAINER it demotes; a tab whose lease a visible tab took lets that container go and, when it takes over again, builds a new one through createState, which is ADR-0077's "a demoted writer becomes one again by claiming again".
  • ADR-0024: its criterion 3 ("single-tab by construction, or willing to build leader election") is satisfied for the election half: leader election exists.

Considered options ​

  • One lock per store (the spec's original scoping). Rejected in D1: the host cannot name it, and one host holds several stores.
  • A role argument on createState instead of a second factory. Rejected: createState returns a claimed WritableStateStore, and a reader must be unable to write by type, so the reader is its own factory with its own return type.
  • Default-on. Rejected in D3: it needs a reader factory no existing entry point supplies.
  • The tab takes the lock for its dedicated worker. Rejected: the lock would outlive a worker the port restarts, and a killed worker would not release it.