The replica
The local store, the worker that owns it, how tabs share it, and what happens when its identity does not match.
This guide covers the machinery under every other page. You will learn:
- Why one worker owns the store and tabs attach to it
- What a fingerprint is and why a mismatch refuses rather than clears
- How leadership is decided and what happens when it moves
- What a torn store is, and why locel can detect one exactly
- How to inspect and recover a replica in the field
Status: Built. The API below exists and is under test.
You can build a whole application without reading this. Read it when something has gone wrong, or before you ship to a user base that opens six tabs.
Overview
A local-first store in a browser has three problems that have nothing to do with sync, and every one of them has been solved wrongly at least once by a serious library.
Several tabs want the same file. Storage from a previous version of your app is still there. Something crashed halfway through a write.
locel's answers are, in order: one worker owns the store; identity is checked before anything is opened; and there is nothing to be halfway through, because every write is one transaction.
One owner
The replica lives in a worker. Tabs attach to it and talk to it; they do not open storage themselves.
This is not an optimisation. Two tabs each opening an exclusive file handle on the same origin private filesystem is a race with no correct outcome — the second one fails, and no amount of lock retrying changes that, because the lock and the file handle are different mechanisms that know nothing about each other. Systems that take per-tab handles and coordinate with Web Locks end up with a second tab that error-loops forever.
Since the worker owns the store, it owns everything that follows from the store:
- One outbox, so a write made in one tab is durable for all of them.
- One connection, so ten tabs are one subscriber rather than ten.
- One subset registration per distinct query, shared and reference-counted across tabs.
None of this is configurable, and none of it appears in your code.
Leadership
One attached tab drives the connection at a time. The others read and write through the same replica; they simply are not the one holding the socket.
Leadership prefers a tab the user is actually looking at. A background tab that has been hidden for an hour is a poor choice to own a connection whose failures nobody will notice, so visibility and the time since last visible are both inputs.
When a leader goes away — closed, crashed, or backgrounded long enough — another is promoted and resumes from the first unanswered transaction. The resend is safe because every transaction carries a client-generated id the server applies at most once.
A promotion is identified separately from the tab that received it, because the same tab can be promoted repeatedly across failovers and a log that conflates the two is unreadable exactly when you need it.
replica.onLeadershipChange(({ isLeader, leadershipId }) => {
console.log(isLeader ? `leading as ${leadershipId}` : 'following')
})You should not need this outside diagnostics. Writes work identically from a follower.
Fingerprint
Before a replica opens a store, it checks that the store is its store.
replica.fingerprint
// {
// key: { // which store
// app: 'todo-workspaces',
// server: 'https://app.example.com/sync',
// principal: 'p_4c1a…',
// scope: { workspaceId: 'w1' },
// mode: 'partition',
// },
// stamp: { // what is inside it
// schemaHash: 'sha256-9f3c…',
// protocolVersion: 'locel-v1',
// storageFormatVersion: 'locel-store-v1',
// },
// }The two halves behave differently, and the difference is the whole design.
The key selects a store. A value it has never seen is not an error — it is a different store, which starts empty and syncs. Signing in as somebody else, or switching workspace, lands on a different key and the previous store is left exactly as it was, unsent writes included.
The stamp describes what is inside the store it found, and a mismatch there
refuses: E_FINGERPRINT_MISMATCH, at attach, before anything is read or
written. A different schema hash means the columns are not the columns. A
different protocol version means the framing changed. There is no version of
those where the bytes still mean what they meant.
The writer is in neither half. It is minted when the store is created and read back out of it, so there is nothing to compare it against — derived state, not identity.
principal is an opaque identity for the signed-in subject, returned by
hello and derived server-side the same way
the scope is. It is in the key because two people using one browser must not
share a store — and because if they did, they would share a writer, and "a
writer's own earlier write is never a conflict" would quietly stop being true
between two different humans.
This is the decision most worth defending, because the obvious alternative — clear what does not match and re-sync — is what almost everyone does, and it has a failure mode that is very hard to see. Clearing is never atomic across every piece of a store unless the store was designed for it, so you get a store whose rows were dropped and whose sync cursor was not. On the next start it resumes from a cursor that is past all the data it just deleted, receives nothing, because nothing has changed since, and reports itself ready. The app is blank, confident, and silent, and there is no error anywhere.
A refusal cannot half-happen. Recovery is then explicit:
try {
await replica.attach()
} catch (error) {
if (error.code === 'E_FINGERPRINT_MISMATCH') {
await replica.rebuild() // drops the store and re-syncs from the server
}
}rebuild() is one transaction: the rows, the checkpoint, the outbox and the
conflicts go together, and the new fingerprint is written in the same commit. It
replays any surviving outbox entries against the new schema afterwards and
reports the ones that no longer apply, so a user who worked offline through a
deploy is told what happened rather than quietly losing it.
In development, where the schema changes every few minutes, opt into doing that automatically:
createReplica({ schema, server: '/sync', store: stores.opfs(), rebuildOnMismatch: import.meta.env.DEV })Leave it off in production. In production a mismatch means something you did not expect, and you want to see it.
Scopes
A partition scope is part of the fingerprint, so each scope is its own store.
await replica.switchScope({ workspaceId: 'w2' }) // attach a different store
await replica.forget({ workspaceId: 'w1' }) // drop it, including unsent writesSwitching detaches rather than destroys. Unsent writes in the old scope stay durable and resume when you switch back — the laptop that went offline mid-edit in one workspace and came back in another is a real Tuesday, and re-pulling on every switch would throw that work away.
forget() is the only thing that deletes a scope's store, and it is never
automatic. Deleting a user's unsent work is not a decision an eviction policy
should be making.
Signing out, and switching users
Because principal is part of the key, signing in as somebody else attaches a
different store. You do not have to clear anything for correctness — one user
cannot read another's rows, because they are not in the same store.
What you do have to decide is what happens to the store they left behind, and it is a product decision rather than a technical one:
// Personal device: keep it. Signing back in resumes exactly where they were,
// including writes that never reached the server.
await replica.detach()
// Shared device: flush first, then delete. Order matters.
await replica.sync()
await replica.whenIdle()
await replica.forget(replica.scope)forget() before a flush throws away work the user believed was saved. If the
device is offline at that moment there is nothing to flush to, and the honest
options are to keep the store until it can be flushed or to tell the user what
they are about to lose. locel will not choose for you, and it will never delete
unsent writes on its own.
The writer is not a user identity. It is minted once per store and persisted in it — a client that forgot who it was would conflict with its own replayed outbox. So a shared device has one writer per person per workspace, which is what the conflict rules assume.
Knowing the scope before you can ask for it
There is an ordering problem here worth being explicit about, because the obvious design does not survive being offline.
The scope is derived server-side and returned by hello. But the scope is part
of the fingerprint, and the fingerprint decides which store to open — so a
replica that waited for hello before opening anything would be unable to read
its own local data without a network. That is the failure this whole library
exists to avoid.
So the replica keeps a small scope index per app, outside the scoped stores
and holding nothing but the last scope each writer resolved. On start it opens
that store immediately and syncs afterwards. If the next hello returns a
different scope — the user's default workspace changed while they were away —
that is a scope switch, not a mismatch: the old store is detached with its outbox
intact and the new one is attached.
The one case with no answer is a first-ever start with no network and no
explicit scope: there is nothing cached and nobody to ask. That raises
E_UNKNOWN_IDENTITY, and the app should render its signed-out or first-run state.
Pass scope explicitly at construction if your application already knows it —
from the URL, say — and this case disappears.
Torn stores
A store is torn if its row versions and its checkpoint disagree: some $version
exceeds the checkpoint, or some outbox base version does. Either means the store
holds something it cannot possibly have received in order.
locel checks this on every attach, exactly:
max(row.$version) <= checkpoint AND every outbox baseVersion <= checkpointExactly, rather than heuristically, because the row versions and the checkpoint come from the same value space — both are sequence numbers assigned by one server. Systems whose sync cursor is an opaque offset cannot do this; the best available check there is "zero rows but a non-initial cursor", which catches one case and misses the rest.
A torn store raises E_STORE_TORN at attach. It should never happen, and it will
not happen from a crash — one transaction per write means there is nothing to be
halfway through. If you see it, the store was modified by something other than
locel, or the storage layer lied about a commit. Recovery is rebuild().
Inspecting one
await replica.inspect()
// {
// fingerprint: { … },
// attachedTabs: 3,
// leadershipId: 'ld_7a1f…',
// status: 'idle',
// checkpoint: 4128,
// rows: { todos: 812, categories: 14 },
// outbox: { pending: 0, oldest: null },
// conflicts: 2,
// rejections: 1,
// subsets: [{ id: 'issues:7', rows: 50, refs: 2, releasedAt: null }],
// store: { driver: 'opfs', bytesHeld: 4_182_930 },
// torn: false,
// }Ship a way for users to send you this. Every field in it is a question you will otherwise be asking over email: how many rows do you have, is anything stuck in the outbox, how old is the oldest one, which subsets are open, is the store torn.
Stores
stores.opfs() // origin private filesystem — the default for browsers
stores.memory() // tests; accepts a handle you can carry across instancesA driver provides transactional key-range storage. The transaction boundary is the contract — a driver that cannot commit rows, checkpoint and outbox together cannot back a replica, and there is no configuration that relaxes this.
Lifecycle
const replica = createReplica({ schema, server: '/sync', store: stores.opfs() })
await replica.attach() // fingerprint check, torn check, outbox replay
replica.status // { state, pending, oldest, checkpoint }
await replica.sync() // flush the outbox and drain changes now
await replica.whenIdle() // resolve when nothing is in flight
await replica.detach() // release this tab; the broker lives on if others hold itattach() is called for you on first use. Call it explicitly when you want the
fingerprint failure at a point in your startup where you can handle it, rather
than at the first render.
Where to go next
- ADR 0008 — why a mismatch refuses
- ADR 0009 — why one process owns the store
- ADR 0010 — why the torn check is exact
- Offline and durability — what survives what
- Partial sync — subsets, which the broker owns
@locel/clientreference — every option and method