Skip to content

A returning tab catches up within a time budget, or starts from the snapshot ​

A browser tab that already holds local state used never to take a published snapshot: openAndBootstrap answered kept-local whenever a cursor existed, and the tab caught up from that cursor however far behind it was. After a long absence that goes wrong twice over: a non-archive public node REFUSES the older ranges (core reports it as the terminal ArchiveRefusedError, work/notes/findings/what-nodes-answer-when-a-getlogs-range-is-too-big.md), so the tab stops for good; and where the node does serve them, catching up months of blocks takes far longer than downloading the state ADR-0095 publishes. We decide, with the maintainer on 2026-09-27, that a returning tab whose publication names a snapshot for its generation further along than its own state CATCHES UP from its own cursor, and SWITCHES to the snapshot mid-run when the node refuses the catch-up as an archive refusal, or when the catch-up is estimated to take longer than ONE time budget the app gives (catchUpWithinSeconds, thirty seconds by default, or 'always' to catch up whatever it costs and take the snapshot only on the refusal). A fresh tab is unchanged: it starts from the snapshot as before.

The switch goes through the one install there is ​

The install is the APP's: createState calls openAndBootstrap, which opens snapshot-aware and installs through SnapshotAwareStateStore.bootstrap, which wipes a store that computed its own state before installing (ADR-0028's floor is then the snapshot's, and a-snapshot-install-replaces-a-self-computed-store made the wipe). @etherfold/browser deliberately depends on no entity runtime and cannot install anything itself, and a second install path in the hook would be a second place the snapshot's floor and cursor are written. So the decision runs where the catch-up runs, in the hook's advance (advanceOnce, the one step every driver takes), and the install stays where it is: the hook stops the container folding the local state, opens it again over the same registry and the same factories, and hands createState the snapshot with replaceLocal: true (PublicationSnapshot.replaceLocal); openAndBootstrap reads replaceLocal (BootstrapOptions) as leave to install over a store that has synced, and bootstrapFromSnapshot still decides, so a store at or ahead of the snapshot is kept. Indexing then resumes from the snapshot's cursor, and the switch is reported on the status surface that already reports the lookup, syncing.publication, as {status: 'switched', reason: 'archive-refused' | 'over-budget', at, left, estimateSeconds?, budgetSeconds?}.

A createState that does not forward replaceLocal installs nothing, and the hook sees that rather than trusting it: the switch counts only when the rebuilt generation loads a cursor past the one it left. Otherwise the tab goes on as it would have (the refusal is reported as before; an over-budget catch-up goes on catching up), and the switch is tried ONCE per init, so an install that cannot happen is not retried every cycle.

The budget is a time, measured by the loop, and one value ​

The estimate is the loop's own arithmetic: the blocks the canonical generation's advances covered since the container opened and the time they took (fetching and folding, which is what a user waits for), extrapolated over the rest of the gap to the tip. It needs nothing from RangeLogFetcher: a fetcher that learns a wider range makes the next advance cover more blocks per second, and the estimate follows. Only a catch-up that could switch is measured, which means one still BEHIND the snapshot: past it, there is nothing better to switch to.

The default is thirty seconds, because it is a wait a user sits through looking at stale state, and past about that an app reads as broken rather than busy; a snapshot is sized by the state and not the history, so installing one costs a few seconds. etherfold takes ONE value and carries no per-chain table: which chains are slow, or which nodes are rate-limited, is the app's knowledge (its deployment tooling's chain info), and a table here would be a guess that goes stale.

Considered options ​

  • Deciding before indexing, in openAndBootstrap. It runs in createState, before the first fetch, so it knows neither whether the node will refuse nor how fast the catch-up will go. A rule on the block gap alone would be a per-chain constant in disguise.
  • A second install in the hook (SnapshotAwareStateStore.bootstrap called directly). Rejected: the install and its mirror selection, reorg-window check and processor check are bootstrapFromSnapshot's, and duplicating them is how the two drift.
  • Explicit snapshot locations with no publication option. Not covered: the hook does not know such a snapshot exists or where it stands, so it cannot tell a switch that helps from one that does not. An app that wants the switch names its snapshot through the publication index.

Consequences ​

  • The switch runs in EVERY hosting shape (amended 2026-09-27, a-worker-hosted-tab-starts-from-a-publication; it was first recorded as main-thread only, because the worker hosts did not read a publication). The decision, the estimate, the report and the one-try rule are ONE implementation (packages/browser/src/publishedStart.ts), which the main-thread host's advance and the worker hosts' driver (host/serve.ts) both call; what each host does itself is only let go of its container and open it again. A worker host reports the switch on the port, as HostProgress.publication, with the same value syncing.publication carries on the main thread, and the wipe and install run inside the worker, off the UI thread.
  • The switch rebuilds the container, so it is made only while the container holds the ONE generation init built: rebuilding it with a successor beside it would drop the successor.
  • A snapshot-seeded generation is a LEAF (ADR-0028, ADR-0095), so a tab that switched loses the history below the snapshot's floor that its own state had. That is the price of starting at all when the node will not serve the gap, and of starting within the budget when it would.