Design an offline sync engine
A reusable client-side sync layer where the local database drives the UI and changes sync in the background. The hard parts are the durable mutation queue, pulling deltas with a cursor, rebasing pending local work on top of server changes, resolving conflicts, partial sync, client schema migrations, and many tabs sharing one database.
The question
Our company builds a project tracker, a bit like Linear: issues, projects, comments, labels. Users complain that every click waits for the server, and that the app is useless on a train. We want every read and write to feel instant and the app to keep working offline.
Design the client-side sync engine. It should be a reusable layer that two other teams can adopt later, not code written only for issues. A large workspace has 20,000 issues, and a user may stay offline for days.
Real-life examples: Linear, which keeps a workspace in IndexedDB and syncs changes in the background, Replicache from Rocicorp (now in maintenance mode), and its successor Zero, which syncs queries into a local cache but rejects writes while offline, PowerSync and ElectricSQL, which sync a Postgres database into the browser, RxDB and WatermelonDB, local databases with a sync protocol, Notion and Figma offline modes
Requirements exploration
Most web apps work like a phone call: the user clicks, the app asks the server, the server answers, the screen updates. Every action costs a round trip of 100 to 300 ms on a good connection and fails completely on a bad one.
A sync engine flips this. The app keeps a copy of the user's data in a database inside the browser. The UI reads only from that local copy and writes only to it, so every action takes a few milliseconds. In the background, a sync loop sends local changes up to the server and brings other people's changes down.
That one sentence hides most of the hard problems in distributed systems: two copies of the same data that drift apart, writes that cross on the wire, a server that rejects a change the user already saw succeed, and a browser that may delete your database when the disk is full. So the first five minutes of the interview are about scope. The answers change the design more than in almost any other problem.
Questions to ask first
- Is the server the source of truth, or are the clients equal peers? Peer-to-peer (true local-first, no authority) pushes you toward CRDTs everywhere. A central server that can say no is much simpler, and it's what Linear, Replicache, and PowerSync all assume.
- How long can a user be offline? Minutes (a tunnel) and two weeks (a field engineer) need different answers for queue size, conflict handling, and how old a sync cursor can get before we start over.
- How much data does one user see? 20,000 issues at about 1.5 KB each is 30 MB of JSON. That fits in the browser. 2 million rows doesn't, so we'd need partial sync from day one.
- What kind of edits? Setting a field (status, assignee) is easy to merge. Free text in a long description edited by two people at once needs a text CRDT. Moving money needs the server to decide.
- Are there business rules the client can't check alone? "Only admins can close a project", "an issue number is unique per team". These decide where conflicts are resolved.
- Which browsers and devices? Desktop Chrome only, or Safari and mobile too? Storage limits, eviction rules, and worker support differ.
- Can users share a computer? Data at rest on a library PC is a security question we must answer before we store anything.
- Is this one app or a platform? Here it's a platform, so the engine can't know what an issue is. Apps register their tables and their mutations.
Functional requirements
- The UI reads from a local database through live queries: a query that re-runs and re-renders whenever the rows it depends on change, from any source.
- Every write applies to the local database first and shows on screen in the same frame. The write is queued and sent to the server later.
- The queue survives reloads, crashes, and closing the laptop. Nothing the user saw saved is ever lost silently.
- Changes made by other users and other devices arrive in the background and appear without a refresh.
- The app works fully offline for reads and writes, for at least 7 days, and catches up when the network returns.
- Conflicts resolve automatically by sensible rules. When the server rejects a change, the user is told what happened and the screen shows the true state.
- Several tabs of the app stay consistent and don't fight over the database.
- A sync status the user can trust: saved, syncing, offline with N changes waiting, or an error.
Non-functional requirements
| Requirement | Target | Why this number |
|---|---|---|
| Local write to screen | under 16 ms | One frame at 60 Hz. The whole point of the engine is that a click never waits. |
| Live query update after a write | under 50 ms for 20,000 rows | Lists must stay instant while the engine works in the background. |
| Remote change visible | under 1 s at p95 when online | Feels live for teammates without a full real-time collaboration stack. |
| Cold start with warm cache | first useful screen under 1 s | Read from disk, render, then sync. Never block the first paint on the network. |
| First load of a new device | under 10 s for a 20,000-issue workspace | A compressed snapshot of about 4 to 6 MB, streamed in. |
| Offline duration | 7+ days, 5,000+ queued mutations | A week of heavy offline work must replay without data loss. |
| Main thread | no task over 50 ms from sync work | Applying a 10,000-row delta must not freeze typing. |
Out of scope, and why
- Live character-by-character co-editing of long documents. That's a text CRDT or OT system (see the Google Docs guide). We'll show where one plugs in, but the engine is about records.
- Server storage design. We define what the server must provide (a change log, a cursor, idempotent mutations), not how its database shards.
- Peer-to-peer sync without a server. We assume one authoritative server.
- Offline login. The user must have signed in on this device before. We cache the session, we don't invent auth.
Check your understanding · Exercise 1
Which answers from the interviewer would change the design the most? Select all that apply.
What makes this hard
A normal app has one copy of the truth: the server. A sync engine has at least three: the server, the local database on each device, and the pending changes that one device made but the server hasn't confirmed. Every hard part of this design comes from keeping those copies honest with each other. Five traits drive every decision that follows.
- Two sources of truth, one authority. The UI trusts the local database, but the server has the final word. The engine must show the local answer now and quietly correct it later, without the user noticing anything except when it truly matters.
- Writes cross on the wire. While your "set status to Done" is in flight, a teammate's "assign to Priya" for the same issue arrives. Each side saw a different order of events. The engine must reach the same final state on every device anyway.
- Time is long and unreliable. A mutation may be queued for a week. The server's code, the schema, and the user's permissions may all change before it's sent. The network can drop mid-request, so the client often doesn't know whether a push landed.
- The browser is a hostile place to keep a database. Storage is quota-limited and can be evicted. Every open tab is a separate process that may try to write. A tab can be killed at any moment, including halfway through applying a batch of changes.
- It must be reusable. The engine can't know what an issue is. Apps give it a schema and named mutations; the engine handles storage, queueing, sync, and rebasing for any of them.
Here's a concrete morning that touches every trait. Ana opens the tracker on a train with three tabs open. She closes 12 issues and edits two descriptions while offline for 40 minutes. Meanwhile her teammate Ben, online, moves four of those issues to another project, and an admin removes Ana from one private project.
When Ana's train reaches the station, her laptop has a pending queue of 14 mutations, a sync cursor that's 40 minutes old, and data she's no longer allowed to see. Within a second or two, all three tabs must show the same correct state. Her 14 changes must land exactly once, Ben's moves must not be undone, and the private project's issues must vanish from her disk.
Think first
Why isn't a sync engine just a cache like TanStack Query with staleTime set high and the data saved to localStorage?
A query cache stores server responses keyed by request. It answers "what did GET /issues?project=7 return last time", not "what is issue 42 now". The same issue lives in many cached responses, and an offline write has to patch every one of them by hand.
A sync engine stores rows, normalized, with indexes, and runs queries over them locally. A write changes one row and every live query that touches it re-renders. It also owns a durable write queue, a pull cursor, and rebasing. A cache with optimistic updates is a fine design for mostly-online apps; it stops scaling when offline writes, many tabs, and conflicts all matter.
The core loop: the local database drives the UI
The first decision shapes everything else: where does the UI read from, and where does a write go first? Let's start naive and see where it breaks.
Naive version: every component fetches from the API, and every write is a POST that the UI waits for. On a 200 ms connection, closing 12 issues in a row costs 12 spinners; on a train it costs 12 error toasts.
Adding optimistic updates helps the online case: the UI changes first and rolls back if the request fails. But the optimistic state lives in memory. Reload the tab while offline and the 12 changes are gone, and the next screen you open has no data to show at all.
Decision Where should the UI read from and write to?
What we pick: We pick the local database. The requirements say instant everywhere and a week offline, which rules out anything that keeps truth only in memory or only per request. The server stays the authority: it can reject or change any mutation, and the client always converges to what the server decides. This split (local database for speed, server for truth) is the model behind Linear, Replicache, and PowerSync.
The four moves of the loop
- Read. A component runs a live query, such as "open issues in project 7, sorted by priority". The engine runs it against the local database and re-runs it when any table it read from changes.
- Write. The component calls a named mutation such as
closeIssue({ id: 42 }). The engine runs the app's mutator code against the local database in one transaction, and in the same transaction appends{ id, name, args }to the pending queue. Live queries re-run; the screen updates in the same frame. - Push. The sync loop sends pending mutations to the server in order. The server runs its own version of each mutator, with full permission checks, and records which mutation ids it has applied.
- Pull. The client asks "what changed since my cursor?". The server returns a delta and a new cursor. The engine applies the delta, drops the pending mutations the server has confirmed, and replays the rest on top. That last step is the rebase, and it gets its own chapter.
Notice that the UI never talks to the network. That's the boundary that makes the engine reusable and testable: components only know live queries and mutations, and the sync loop only knows tables, mutations, and cursors. Here's what an app team writes to use the engine.
const engine = createSyncEngine({
name: "tracker",
schemaVersion: 4,
tables: {
issue: { key: "id", indexes: ["projectId", "status", "updatedAt"] },
comment: { key: "id", indexes: ["issueId"] },
},
mutators: {
async closeIssue(tx, args: { id: string }) {
const issue = await tx.get("issue", args.id);
if (!issue) return; // deleted elsewhere: do nothing
await tx.put("issue", { ...issue, status: "done" });
},
async addComment(tx, args: { id: string; issueId: string; body: string }) {
await tx.put("comment", { ...args, createdAt: tx.now() });
},
},
});
// In a component:
const issues = useLiveQuery((q) =>
q.table("issue").where("projectId", "p7").where("status", "!=", "done").orderBy("priority"));
await engine.mutate.closeIssue({ id: "i42" });
Check your understanding · Exercise 2
Ana closes issue 42 while offline, then reloads the tab. What does she see, with the local-database design? Select all that apply.
Choosing the local database
The engine needs a database in the browser that holds 30 MB or more, survives restarts, supports indexes for live queries, and writes many rows atomically. localStorage is out on every count: it's synchronous (it blocks the main thread), holds about 5 MiB per origin, stores only strings, and has no transactions. The real candidates are IndexedDB and SQLite compiled to WebAssembly. The IndexedDB and Client storage lessons cover the APIs; here we only weigh them for a sync engine.
Decision Which local database should the engine use?
What we pick: For this tracker we start with IndexedDB through a small wrapper like Dexie: no WASM, no special headers, and 20,000 issues is well within what indexed object stores handle. The engine hides storage behind a Store interface (get, put, delete, scan(index, range), transaction) so an app with heavy relational queries can swap in SQLite on OPFS later. The Notion guide shows that path in depth; we won't repeat it.
The one IndexedDB trap that breaks sync engines
An IndexedDB transaction commits on its own as soon as there are no pending requests and control returns to the event loop. If a mutator does await fetch(...) or even await sleep(0) in the middle of a transaction, the transaction commits early and the next write throws TransactionInactiveError.
For a sync engine this means two rules. Mutators must be pure local code: they read and write the database and nothing else. And the row writes plus the queue append must happen in the same transaction, so a crash can never leave a changed row without its queued mutation, or the reverse.
Quotas, eviction, and asking to persist
Browsers give each origin a generous quota (the most it may store). Chrome and Edge allow up to 60% of total disk. Firefox allows 10% of disk or 10 GiB per site, whichever is smaller, in its default "best-effort" mode. Safari (macOS 14, iOS 17 and later) allows about 60% of disk for the browser app.
Size is rarely the problem. Eviction is: under storage pressure, browsers delete whole origins, least recently used first, unless the origin has persistent storage. Safari also deletes script-written data for sites the user hasn't interacted with in the last 7 days of browser use, when tracking prevention is on. A Home Screen web app keeps its own counter, which resets each time it is opened.
For a cache, eviction is fine. For a sync engine it can destroy unsent work, so the engine does three things. First, it calls navigator.storage.persist() after the user shows intent (signs in, or turns on offline mode). Chrome grants or denies this silently based on engagement, Firefox asks the user, and Safari grants it by heuristics such as running as a Home Screen web app.
Persistence protects against eviction under storage pressure, but Safari's 7-day rule may still apply, so the engine can't rely on it alone. Second, it checks navigator.storage.estimate() and warns before the quota fills. Third, it treats the pending queue as the precious part: if the queue is non-empty and the user is online, it pushes promptly instead of letting the queue sit.
async function ensureDurableStorage() {
if (!navigator.storage?.persist) return "unsupported";
if (await navigator.storage.persisted()) return "persisted";
const granted = await navigator.storage.persist();
return granted ? "persisted" : "best-effort";
}
// On boot: a client id in IndexedDB that's missing means the data was
// evicted or cleared. Start a fresh bootstrap and tell the user if
// the last session had unsent changes (tracked server-side by client id).
const meta = await store.get("meta", "client");
if (!meta) await bootstrapFromSnapshot();
Check your understanding · Exercise 3
A mutator for moveIssue reads the issue, calls fetch to look up the target project's rules, then writes the issue. Testers see TransactionInactiveError on some moves. What are good fixes? Select all that apply.
The mutation queue
Every local write leaves a record in a queue, and the queue is what the server eventually sees. Two questions shape it: what goes into each entry, and how we make sure each entry is applied on the server exactly once, in order, even when requests time out and tabs crash.
Decision What does one queue entry contain?
What we pick: Named mutations. Intent is what makes rebasing and conflicts tractable, and it puts business rules where they belong. Replicache and Zero both use this shape. Raw diffs still have a place for simple field edits where last writer wins is fine, and an app can register a generic setFields mutation for that.
Mutation ids and exactly-once
Each device gets a random clientId the first time it boots, stored in the local database. Each mutation gets the next integer for that client: 1, 2, 3, and so on, so the pair (clientId, mutationId) is unique forever. The server stores one number per client: lastMutationId, the highest one it has processed. That makes its push handler simple and safe to repeat.
for (const m of push.mutations) {
await db.transaction(async (tx) => {
const last = await tx.lastMutationId(push.clientId); // 0 if new
if (m.id <= last) return; // duplicate: already applied
if (m.id > last + 1) throw new OutOfOrder(); // a gap: client must resend
try {
await serverMutators[m.name](tx, m.args, { userId });
} catch (err) {
// Rejected by a rule: record it, apply nothing, but still advance.
await tx.recordRejection(push.clientId, m.id, String(err));
}
await tx.setLastMutationId(push.clientId, m.id);
});
}
Two details matter. First, the lastMutationId update is in the same server transaction as the mutation's writes, so either both happen or neither does. Second, a rejected mutation still advances the counter; otherwise one mutation that always fails (a deleted project, a lost permission) would block every mutation behind it forever. This is the poison message problem, and the fix is to reject loudly but move on.
lastMutationId 36 together with the state it produced.Retries, batching, and a queue that survives reloads
- Durable. The queue is a table in the local database, written in the same transaction as the mutator's row changes. A reload, a crash, or a closed laptop loses nothing.
- Ordered. Only one push is in flight per client at a time, and it always starts from the oldest unconfirmed mutation. Order matters:
createIssuemust land beforeaddCommenton that issue. - Batched. A push sends up to 100 mutations or about 100 KB, whichever comes first. After a week offline, 5,000 mutations go up in about 50 requests, not 5,000.
- Retried with backoff. A network error or a 5xx retries after 1 s, 2 s, 4 s, up to 60 s, with random jitter so 10,000 clients coming back after an outage don't all retry in the same second (see the Retries and backoff lesson). A 401 (an expired session) stops the loop and asks the user to sign in; the other error answers are listed with the protocol.
- Confirmed by pull, not by push. A successful push response only means "received". The client drops a mutation from its queue when a pull reports
lastMutationIdat or above it, because that pull also carries the server state that mutation produced. This keeps the rebase consistent.
The timeout case shows why ids matter. The client sends mutations 15 to 20, and the server applies them, but the response is lost when the train enters a tunnel. The client doesn't know whether they landed, so it sends 15 to 20 again. The server sees lastMutationId = 20 and skips all six; without ids, Ana would get six duplicate comments.
(clientId, id) and the server's lastMutationId turn that resend into a harmless no-op.Check your understanding · Exercise 4
Ana's queue holds mutations 31 to 35. Mutation 32 is closeIssue on an issue in a project she just lost access to. What should happen? Select all that apply.
The pull side: cursors, deltas, and tombstones
Push sends our changes up. Pull answers the opposite question: what changed on the server since I last asked? The naive answer, "give me everything", means a 30 MB response every few seconds for 20,000 issues. The next idea is timestamps ("give me rows with updatedAt after my last pull time"), which is subtly wrong in a way worth seeing.
Suppose the client pulls at 10:00:00.500 and remembers that time. A server transaction that started at 10:00:00.400 writes a row with updatedAt = 10:00:00.400 but commits at 10:00:00.700. The pull at .500 couldn't see it (not committed), and the next pull asks for rows after .500, so the row is missed forever. Clock skew between servers and many writes in the same millisecond cause the same bug.
Decision How does the client tell the server what it already has?
What we pick: A server-assigned version in commit order, sent to the client as an opaque cursor (Replicache calls it a cookie). The client never parses it; it just echoes it back. To keep commit order simple, each workspace has one version counter that every writing transaction increments, which also serializes writes within a workspace. That costs throughput only for very busy workspaces, and they can move to a database change feed (such as Postgres logical replication) later without changing the client.
What a pull returns
{
"cookie": "w7:v18342",
"lastMutationId": 35,
"rejected": [{ "id": 32, "reason": "forbidden" }],
"patch": [
{ "op": "put", "table": "issue", "id": "i42", "value": { "status": "todo", "projectId": "p9", "v": 18339 } },
{ "op": "del", "table": "comment", "id": "c901" },
{ "op": "put", "table": "issue", "id": "i77", "value": { "title": "Fix login", "v": 18342 } }
],
"hasMore": false
}
- Puts carry whole rows, not field diffs. A put is idempotent: applying it twice gives the same result, so a retried pull is harmless.
- Deletes are tombstones. When a row is deleted, the server keeps a small record (
table,id, version) in the log. Without it, the client can't tell "deleted" from "unchanged", and the row lives on its disk forever. - Pages. A large delta comes in pages of about 1,000 operations with
hasMore: true. The client applies each page in its own transaction and only stores the new cookie after the page is applied, so a crash mid-sync just repeats a page. - Rejections ride along, so the client can tell the user which changes didn't make it.
When the cursor is too old
The server can't keep tombstones forever. Say it keeps 30 days of change log, and a laptop that's been closed for 45 days sends a cookie the server can no longer answer from the log. The server replies with a reset: a clear operation followed by a full snapshot (the same one a new device downloads). The client applies it into fresh tables and swaps them in.
Crucially, the client keeps its pending queue and replays it on top, just like any other rebase. Mutations that no longer make sense (an issue deleted 40 days ago) become no-ops in the mutator or get rejected by the server, and the user is told.
When does the client pull? On boot, on a server "poke" (a tiny WebSocket or SSE message that says "workspace w7 changed, pull now", with no data in it), after each successful push, and every 60 seconds as a safety net. Pokes keep the server simple: there's one code path for getting data (pull), and the real-time channel only says when to use it.
Check your understanding · Exercise 5
Why does the engine store the new cookie only after applying a page of the patch? Select all that apply.
Rebasing: server changes arrive while local work is pending
This is the heart of the engine. A pull brings server state, but the local database already contains the effects of mutations the server hasn't seen yet. Writing the patch straight over local rows would wipe out pending work: Ana closes issue 42, a pull arrives with Ben's version of issue 42 (still open, now assigned to him), and her close flickers away even though it's still in the queue. Ignoring the patch for rows with pending changes is just as wrong: she'd never see Ben's assignment.
Replicache's documentation describes the answer clearly. The client does not try to merge the patch into local state. Hidden from the app, it rewinds to the last state it got from the server, applies the patch to reach the server's current state, then replays the still-pending mutations on top, and reveals the result atomically. It's the same idea as git rebase: your unpushed commits are reapplied on top of the new upstream.
A worked example
| Step | Issue 42 status | Assignee | Votes | Note |
|---|---|---|---|---|
| Confirmed base (cookie v100) | todo | none | 3 | Last state the server sent. |
After pending m36 closeIssue | done | none | 3 | What Ana sees right away. |
After pending m37 addVote | done | none | 4 | Still optimistic. |
| Pull arrives (v104): Ben assigned, Chen voted | todo | Ben | 4 | Server state. lastMutationId is still 35. |
| 1. Rewind to base | todo | none | 3 | Undo m37 then m36, newest first. |
| 2. Apply patch | todo | Ben | 4 | Exactly the server's state at v104. |
| 3. Replay m36 | done | Ben | 4 | Intent reapplied to new data. |
| 4. Replay m37 | done | Ben | 5 | Ben's change and Chen's vote both survive. |
The vote shows why named mutations matter. A raw diff would have said votes = 4, and replaying it would erase Chen's vote. The mutator addVote says +1, so replay gives 5, which is what the server will compute too. When the server later processes m36 and m37, the next pull reports lastMutationId: 37 with the same values, the queue empties, and nothing on screen moves.
Ana sees base v100 plus her 3 pending mutations. A pull has just arrived with the server changes you picked.
How to rewind cheaply
Decision How does the client get back to the confirmed state?
What we pick: An undo log of before-images. It keeps live queries on plain indexed tables, and rewinds are small: a typical pending queue touches tens of rows. The whole rewind, apply, and replay runs in one IndexedDB transaction, so live queries see one jump from old state to new state and never a half-rebased one.
Two refinements keep rebases cheap. If the queue is empty, skip rewind and replay: just apply the patch, which is the common case. And replay runs in a worker, not on the main thread, so 200 pending mutations replaying after a long offline stretch never block typing.
Why not CRDTs for everything?
CRDTs (conflict-free replicated data types, see the CRDTs lesson) merge any two replicas without a server, by design. That's powerful and also a cost: every data type must be a CRDT, documents carry metadata, and there's no natural place for a rule like "only admins can close a project", because every replica must accept every change.
With a central server, the rebase model gets convergence for free: every client ends up at "server state plus my pending mutations", and the server state is one ordered history. Business rules live in server mutators, which is simpler and safer for apps made of records (issues, tasks, settings). Use a CRDT for the one place that needs it, such as a long description edited by two people at once, and store it as a field the server merges.
Check your understanding · Exercise 6
Ana has pending renameIssue(i42, "Login bug"). A pull arrives in which issue 42 was deleted by Ben. What should happen during the rebase? Select all that apply.
Conflict resolution
Rebasing tells us how changes get ordered: the server's order wins, and local work is replayed on top. It doesn't tell us what each mutation should do when it lands on data someone else changed. That's conflict resolution, and the Conflict resolution lesson covers the general strategies. Here we pick per kind of data, because one rule for everything is always wrong somewhere.
Take a real partition (two copies that can't talk to each other for a while). Ana, offline on the train, sets issue 42's priority to High and its title to "Safari bug". Ben, online, sets the same issue's priority to Low and its assignee to himself. Ana reconnects 40 minutes later: what should issue 42 look like?
Decision How should concurrent edits to the same record resolve?
What we pick: Layer them. The default is last writer wins per field, where "last" means last processed by the server, never a client clock. On top, server mutators enforce business rules. Long text fields use a CRDT. In the partition above, issue 42 ends up with Ana's title, Ben's assignee, and priority from whichever mutation the server processed last: Ana's High, because her push arrived after Ben's.
Per-field last writer wins, in server order: different fields both survive, and for status and priority Ana's value wins because Ana reconnected last.
Business rules the client can't decide
- Ids. Clients create rows with random UUIDs (
crypto.randomUUID()), so two offline clients never collide. Human numbers likeENG-1042are assigned by the server mutator; the client shows a placeholder such asENG-?until the pull brings the real one. - Workflow. "An issue with open blockers can't be closed." The client mutator checks what it knows; the server mutator checks the truth and rejects if a blocker appeared meanwhile.
- Permissions. Always the server's call. The client may hide a button, but the server mutator is the check that counts.
- Counters and sets.
addVote,addLabel,removeLabelare written as operations, so concurrent ones combine instead of overwriting.
When to show the user a conflict
Most conflicts should resolve quietly; asking users to merge priorities is noise. Two cases deserve a visible notice. The first is a rejected mutation: the user saw something succeed that didn't, so say which change and why ("Couldn't close ENG-1042: it has an open blocker"), with a link to the issue.
The second is a lost edit to a field with real effort in it, like a 300-word description written offline that lost to a newer edit. For those, the mutation carries the version the user started from (baseVersion). If the server sees the field changed since then, it keeps the winner but saves the losing text as a conflict copy, and the UI says "Your offline edit was saved as a copy" with a button to compare. Nothing typed is ever thrown away.
Check your understanding · Exercise 7
Two offline users both add the label "bug" to issue 42, and one of them also removes the label "ui". Which designs give the result most users expect (labels: "bug" once, no "ui")? Pick one.
Partial sync and the first load
So far each client has "the data". Which data? Syncing the whole company database to every laptop fails three ways at once: it's too big (a company with 300 teams has millions of rows), it's too slow to download, and it leaks: a contractor's browser would hold HR's private projects. The engine needs a way to say "this client holds this subset" and to change the subset when access changes.
Decision How does the engine decide what a client holds?
What we pick: Scopes, sized around teams. On sign-in the client subscribes to the teams the user belongs to: for a typical user, 2 to 5 teams and 2,000 to 20,000 issues. Comments and history for issues older than 90 days load on demand when opened, and are stored with a partial flag so the UI can say "not available offline" for the rest. This keeps the offline promise for the work people actually do.
scope, so adding access is "download a snapshot" and removing it is one indexed delete. Revoked data leaves the disk, not just the screen.When permissions remove data
An admin removes Ana from the private project p9. Hiding it in the UI isn't enough, because the rows are still on her disk. The server records which scopes each client holds, so when membership changes, the next pull includes a dropScope operation for project:p9.
The client deletes every row tagged with that scope in one transaction, removes pending mutations that touch only those rows (the server would reject them anyway), and tells the user their access changed. Every row carries its scope as an indexed field, so this delete is one index range scan, not a full table walk.
Bootstrapping a new device
A new laptop has nothing. Pulling 20,000 issues through the delta path (1,000 operations per page) means 20 round trips and a lot of per-row bookkeeping. Instead, the server builds a snapshot per scope: a gzipped file of newline-delimited JSON rows plus the cookie it corresponds to, rebuilt every few minutes and served from a CDN. 30 MB of JSON compresses to roughly 4 to 6 MB.
- Fetch the snapshot as a stream in the sync worker and decompress it with
DecompressionStream("gzip")(or let the server sendContent-Encoding: gzip). - Parse line by line and insert in batches of about 2,000 rows per transaction, so memory stays flat and progress is visible.
- Load the user's own open issues first (a small, separate snapshot), so the first screen shows in about a second while the rest streams in.
- When the snapshot is in, set the cookie to the snapshot's cookie and pull normally. The delta covers anything that changed while downloading.
Check your understanding · Exercise 8
Ana is removed from project p9 while she has 3 pending mutations on p9 issues and is offline. What must the engine guarantee when she reconnects? Select all that apply.
Schema migrations on the client
A server-only app changes its database schema once, on the server. A sync engine has thousands of copies of the schema, one per device, and some of them were last opened three weeks ago. Every schema change now has three audiences: the server database, the local database on every client, and the mutations sitting in queues that were created under the old schema.
Concrete case: version 4 of the tracker splits issue.assignee (one user id) into issue.assignees (a list). A laptop on version 3 has 20,000 issues stored the old way and 40 queued assignIssue({ id, userId }) mutations. It loads the new code tomorrow morning.
Decision How should a client move its local data to a new schema?
What we pick: Both, with a rule. Small additive changes (a new field, a new index) migrate in place. Big reshapes bump a "data epoch": the client pushes its queue first if online, then re-bootstraps. If it's offline, it migrates in place as a fallback and re-bootstraps later. The local database is a cache of the server plus a queue, so throwing the cache away is always safe once the queue is safe.
Upgrades with several tabs open
IndexedDB runs schema changes inside onupgradeneeded when a page opens the database with a higher version number. If another tab still holds a connection at the old version, the upgrade is blocked until that tab closes it. Old tabs get a versionchange event; the engine must close its connection there and show "A new version is available, reload to continue", or the new tab waits forever. Never leave an old tab writing to a database whose shape changed under it.
Old mutations and old clients
- Mutations are versioned like an API. The queued
assignIssuefrom v3 has args{ id, userId }. The server keeps accepting it and translates it toassignees: [userId]. A changed shape gets a new name or av: 2field; old ones are supported until clients older than the support window (say 30 days) are forced to update. - Each push says who's talking. The push request carries
schemaVersion: 3. The server can answer with a clear error such as{ "error": "client_too_old", "minVersion": 4 }, and the client then pushes nothing, updates its code, and retries, so no mutation is lost. - Pulls are shaped for the client's version. The server can send v3 clients a single
assignee(the first in the list) until they upgrade. That's extra server work, so keep the window short. - Client mutators are versioned too. During replay after an upgrade, a v3 mutation runs through a small adapter that converts its args before the v4 mutator sees them.
versionchange, the new tab's upgrade would stay blocked; if the server dropped v1 mutations, a week of offline work would bounce.Check your understanding · Exercise 9
A user opens the new v4 app in one tab while an old v3 tab is still open. What happens with a well-built engine? Select all that apply.
Many tabs, one database
Power users keep five tabs of a tracker open, and every tab loads the engine. If each one ran its own sync loop, we'd get five WebSocket connections per user, five pulls for every poke, and five pushers reading the same queue. Mutation ids keep the pushes safe from duplicates, but two pushers can still send 41 to 45 and 43 to 48 at the same moment, and the server sees gaps and retries.
The Cross-tab sync lesson covers the browser APIs; here's how a sync engine uses them.
Decision Who runs the sync loop when several tabs are open?
What we pick: Web Locks for leadership, BroadcastChannel for change notices. With IndexedDB, every tab can still read and write the database directly, because IndexedDB serializes overlapping readwrite transactions safely. Only the network side is single: one socket, one pusher, one puller, one rebase at a time. Apps that pick SQLite on OPFS go one step further and let only the leader's worker touch the file, with other tabs sending queries to it.
const changes = new BroadcastChannel("tracker-sync");
let syncLoop: SyncLoop | null = null; // set only in the leader
navigator.locks.request("tracker-sync-leader", async () => {
status.set({ role: "leader" });
syncLoop = startSyncLoop({ onApplied: (tables) => changes.postMessage({ type: "applied", tables }) });
await new Promise(() => {}); // hold the lock for this tab's lifetime
});
changes.onmessage = (e) => {
if (e.data.type === "applied" || e.data.type === "local-write") liveQueries.invalidate(e.data.tables);
if (e.data.type === "local-write") syncLoop?.schedulePush();
};
A follower tab's write runs its mutator in its own IndexedDB transaction. Inside that transaction it reads and increments meta.nextMutationId, so ids stay unique and in order across tabs with no extra locking. Then it posts local-write on the channel: the leader schedules a push, and the other followers re-run their live queries.
When the leader finishes a rebase, it posts applied with the tables it touched. All tabs share one clientId, because they share one queue and one local database. (Replicache instead gives each tab its own client id inside a shared "client group"; either works as long as ids are ordered per client.)
Check your understanding · Exercise 10
The leader tab crashes in the middle of a push. What happens next? Select all that apply.
Connectivity and the sync status
The engine needs to know when to try the network. The obvious signal is navigator.onLine with its online and offline events, but it's a weak signal. false reliably means "no network at all", while true only means the device is connected to some network: hotel Wi-Fi with a login page, a phone with one bar, or a VPN that's down all report true. So the engine treats navigator.onLine as a hint and lets real requests decide.
- Offline when
navigator.onLineisfalse, or after 2 consecutive failed requests (network error or timeout). A request timeout is 15 s, enforced withAbortController. - Try now on the
onlineevent, onvisibilitychangeto visible (the laptop lid just opened), on a poke from the socket, and whenever a local write happens. - Back off between attempts while offline or failing: 1 s, 2 s, 4 s, capped at 60 s, with jitter. Any trigger above resets the wait and tries at once, so a user who opens the lid doesn't wait a minute.
- Stay quiet when hidden. A hidden tab drops its poll interval from 60 s to 5 minutes and keeps the socket only if it's the leader.
What about syncing after the user closes every tab? The Background Sync API lets a service worker get a sync event when connectivity returns, even with no tab open, so it could push the queue. It's only available in Chromium-based browsers (Chrome, Edge, and others), not in Firefox or Safari (see the Background sync lesson).
We use it as a bonus where it exists. The service worker requests the same Web Lock as the tabs with ifAvailable: true, so if a tab is already leading it simply stops; otherwise it opens the IndexedDB queue and pushes. Everywhere else, the queue waits until the app is opened again, and nothing is lost because it's durable.
offline is entered by failed requests, not by trusting navigator.onLine alone, and every trigger back to syncing resets the backoff.A status the user can trust
Users of an offline-capable app have one worry: "is my work saved?" The status indicator answers it, so it must be honest. It derives from two facts the engine knows exactly: the number of pending mutations and the state of the sync loop.
| Status | When | What the user sees |
|---|---|---|
| Saved | Queue empty, last sync succeeded | A quiet check mark; no text needed. |
| Syncing | Push or pull in flight | A small spinner only if it lasts over 1 s, so fast syncs don't flicker. |
| Offline | Offline state, any queue size | "Offline: 14 changes will sync when you reconnect". Saved on this device, which is true. |
| Needs attention | A mutation was rejected or a conflict copy was made | A badge that opens a list of exactly what happened. |
| Error | Auth expired, storage full, client too old | A banner with the one action that fixes it: sign in, free space, reload. |
For screen readers, the status text sits in a polite live region (role="status"), and it only announces transitions that matter: going offline, coming back with changes synced, and errors. Announcing every 1-second sync would make the app unusable with a screen reader.
The status can also drive a beforeunload warning. While the queue is non-empty and the browser lacks Background Sync, the engine adds the listener, so closing the tab shows the browser's generic "leave site?" prompt; it removes the listener once the queue drains, because a listener that's always attached can keep pages out of the back/forward cache in some browsers. The work is safe on disk either way: the prompt only warns that teammates won't see it until the app is opened again.
Check your understanding · Exercise 11
A user on hotel Wi-Fi hasn't accepted the login page yet. navigator.onLine is true, but every request gets the hotel's HTML page. What should the engine do? Select all that apply.
Architecture, data model, and the sync protocol
The core loop drew the logical picture; now let's place each piece on a thread. The rule that sets the boundaries: the UI thread renders and handles input, and nothing slow runs there. Storage, sync, rebase, and snapshot parsing run in a dedicated worker, one per tab, and only the leader tab's worker talks to the network.
| Piece | Runs in | Responsibility |
|---|---|---|
| App components | Main thread | Render live query results; call named mutations. Know nothing about the network. |
| Engine client | Main thread | useLiveQuery, mutate.*, useSyncStatus. Thin proxy that posts to the worker and caches the latest results for render. |
| Live query manager | Sync worker | Runs queries against the store, records which tables and index ranges each one read, re-runs only affected queries after a write or rebase. |
| Mutator runner | Sync worker | Runs a mutator, its undo-log entries, and the queue append in one transaction. |
| Store | Sync worker | The Store interface over IndexedDB (Dexie) or SQLite on OPFS. Tables, indexes, meta, queue, undo log. |
| Sync loop | Leader's sync worker | Push, pull, rebase, backoff, bootstrap, scope changes. The only code that calls the server. |
| Tab coordinator | Every tab | Web Lock for leadership, BroadcastChannel for change notices, versionchange handling. |
| Poke channel | Leader | WebSocket that carries only "pull now" hints. |
| Service worker | Browser | Serves the app shell offline; pushes the queue on Background Sync where supported. |
| Server: push, pull, snapshot | Backend | Authoritative mutators, lastMutationId per client, change log with tombstones, scope snapshots on a CDN. |
Why put the store in a worker if IndexedDB is already asynchronous? Because the slow part isn't IndexedDB's I/O, it's our JavaScript around it: structured cloning 1,000 rows, replaying 200 mutators, re-running 30 live queries after a rebase, parsing a 30 MB snapshot. Each of those can take 50 to 500 ms on a mid-range laptop.
In a worker they cost nothing on the main thread, and the UI gets small result diffs over postMessage. The cost is one hop of latency, about 1 ms, which still fits the 16 ms budget for a local write.
Why does the engine client keep a copy of the latest results? So components render synchronously from memory instead of waiting on the worker every time they render. After a mutation, the worker applies it and the affected queries post new results back within a few milliseconds, typically inside the same frame. If an app needs a write reflected in the very next paint, such as text in an input, that state stays in the component until the mutation settles, just like any controlled input.
Think first
Why is the sync loop only in the leader's worker, while every tab has a store and a live query manager?
Reads and local writes must be instant in every tab, so every tab needs direct access to the data. IndexedDB lets every tab open the same database and serializes writes safely.
The network side has to be single: one socket, one ordered pusher, one rebase at a time. Running it in every tab would multiply traffic and create racing pushes. Leadership puts it in exactly one place, and the lock moves it when that tab closes.
Data model and the sync protocol
The local database holds two kinds of tables. App tables hold rows exactly as the app defined them, plus two engine fields: scope (for dropping data when access changes) and v (the server version that last wrote it). Engine tables hold everything about sync. Keeping them apart means an app can't break sync by accident, and the engine can wipe app data while keeping the queue.
type Meta = {
clientId: string; // random UUID, created on first boot
cookie: string | null; // opaque pull cursor from the server
nextMutationId: number; // incremented inside each mutation's transaction
schemaVersion: number; // local schema, for migrations
scopes: string[]; // e.g. ["team:eng", "team:design"]
userId: string; // owner of this data; checked on every boot
};
type PendingMutation = {
id: number; // per client: 1, 2, 3...
name: string; // "closeIssue"
args: unknown; // JSON, validated by the mutator
mutatorVersion: number; // for adapters after upgrades
createdAt: number; // for the dev panel, never for conflicts
};
type UndoEntry = { mutationId: number; table: string; key: string; before: unknown | null };
type Rejection = { mutationId: number; reason: string; seenByUser: boolean };
scope and v. Everything about sync lives in its own tables, so the engine can wipe app data while keeping the queue.The protocol is two HTTP endpoints, one snapshot URL, and one tiny socket message. HTTP for push and pull keeps them easy to retry, cache-proof (both are POST with Cache-Control: no-store), and observable in any proxy. The socket never carries data, so losing it only delays sync until the next poll.
POST /sync/push
Content-Type: application/json
Authorization: Bearer <token>
{ "clientId": "c-7f3a", "schemaVersion": 4,
"mutations": [
{ "id": 36, "name": "closeIssue", "args": { "id": "i42" }, "v": 1 },
{ "id": 37, "name": "addVote", "args": { "issueId": "i42" }, "v": 1 } ] }
HTTP/1.1 200 OK
{ "lastMutationId": 37 }
POST /sync/pull { "clientId": "c-7f3a", "cookie": "w7:v18342", "scopes": ["team:eng"], "schemaVersion": 4 }
-> 200 { cookie, lastMutationId, rejected, patch, hasMore }
-> 200 { reset: true, snapshot: "https://cdn.example.com/snap/team-eng/v18900.ndjson.gz" }
WS /sync/poke server -> client: { "type": "poke", "scope": "team:eng" }
GET /snap/team-eng/v18900.ndjson.gz (immutable, cacheable for a year)
Errors: 401 sign in again | 409 { "error": "out_of_order", "expected": 36 }
400 { "error": "client_too_old", "minVersion": 4 } | 429/5xx retry with backoff
Note the 409 out_of_order answer. If the server ever sees mutation 38 when it expected 36, something went wrong on the client (a bug, or restored storage), so the server says which id it expects and the client pushes from there. If the client no longer has 36, it can't fill the gap: it reports the lost range, resets the counter with the server, and records the incident in the dev panel and logs. That should be rare enough to page someone.
Security, debugging, and testing
Three topics that interviewers love as follow-ups, because each one is easy to forget and painful to retrofit: what happens to the data on a shared computer, how anyone debugs a sync bug on a user's laptop, and how you test a system whose bugs only appear when messages arrive in an unlucky order.
Data at rest and signing out
IndexedDB and OPFS files are stored unencrypted inside the browser profile. Disk encryption protects a stolen, powered-off laptop, but anyone who can use the OS account (a family PC, a library computer) can open the profile and read 30 MB of company data.
Encrypting rows with a Web Crypto key sounds like the fix, but the key must live somewhere the offline app can reach, which is the same profile. It raises the bar only if the key comes from the server after sign-in and is kept in memory, which gives up offline cold starts. So the engine's real defenses are policy and cleanup:
- Ask at sign-in. "Keep data on this device for offline use?" Off means in-memory mode: same engine, no persistence. Admins can force it off for their organization.
- Sign-out clears everything. First try to push the queue; if changes are pending, say so: "3 changes haven't synced. Sign out anyway?" Then broadcast
signoutso other tabs close their connections, callindexedDB.deleteDatabase, clear caches, and have the sign-out response sendClear-Site-Data: "storage"as a backstop in browsers that support it. - Check ownership on boot.
meta.userIdmust match the signed-in user. If it doesn't, the previous user's data is wiped before anything renders. - XSS is the bigger risk. Any script running on the origin can read the whole database. A strict Content Security Policy and sanitized rich text matter more here than in a server-rendered app.
Seeing inside the engine
Sync bugs are reported as "my change disappeared" with no steps to reproduce. So the engine ships with a dev panel (behind a keyboard shortcut, also enabled for support staff) that shows the client id, leader status, cookie, last pull and push times, the pending queue with names, args, and age, recent rejections, and storage usage. One button exports all of it as JSON for a bug report.
In production, the engine reports a few metrics: queue length at p95, time from local write to confirmation, rebase duration, rejection rate per mutation name, resets per day, and how often a boot finds its database missing (eviction). A rising rejection rate for one mutation after a deploy usually means the client and server mutators disagree.
Deterministic simulation
Unit tests check mutators, but they can't catch the bug where a pull lands between a push and its retry while a second tab writes. For that we run the whole system in one process: three simulated clients, an in-memory server, and a fake network that drops, delays, duplicates, and reorders messages, all driven by one seeded random generator and a fake clock.
Each run performs random user actions, random partitions, and random tab crashes, then heals the network, lets everything settle, and checks invariants. A failing seed replays the exact same run, every time.
for (let seed = 1; seed <= 5000; seed++) {
const sim = createSim({ seed, clients: 3, server: memoryServer() });
for (let step = 0; step < 300; step++) {
sim.randomAction({
mutate: 0.5, partition: 0.1, heal: 0.1,
crashTab: 0.05, deliver: 0.25, // deliver may drop, dup, or reorder
});
}
await sim.healAndSettle();
for (const c of sim.clients) {
assert.deepEqual(c.store.snapshot(), sim.server.viewFor(c.userId), `seed ${seed}`);
assert.equal(c.queue.length, 0, `seed ${seed}: queue drained`);
}
assert.ok(sim.server.eachMutationAppliedOnce(), `seed ${seed}`);
}
Check your understanding · Exercise 12
Ben signs out on a shared office PC; then Chen signs in on the same browser profile. Which steps prevent Chen from seeing Ben's data? Select all that apply.
Running it in the interview
This problem is huge, and the risk is spending 30 minutes on IndexedDB APIs and never reaching rebasing. Interviewers for this question want to see that you understand the three copies of the data and how they converge. Storage details are supporting cast. Here's a plan for 45 minutes.
| Minutes | What to do | What to draw or say |
|---|---|---|
| 0 to 5 | Scope | Server is authoritative? How long offline? How much data per user? Which kinds of edits? Shared computers? |
| 5 to 12 | The core loop | Draw UI, local DB, queue, sync loop, server. Say: reads and writes are local; push mutations, pull deltas; server wins. |
| 12 to 20 | Queue and pull | Named mutations with (clientId, id); lastMutationId on the server; cookie, delta, tombstones, reset. |
| 20 to 30 | Rebase and conflicts | Walk the rewind, apply, replay example with a counter. LWW per field by server order; rules in server mutators; CRDT only for text. |
| 30 to 38 | Pick two of: tabs, partial sync, migrations, storage | Follow the interviewer's interest. Web Locks leader is a quick win; scopes and revocation show senior thinking. |
| 38 to 45 | Status, security, testing | Sync status states; sign-out wipe; deterministic simulation. Leave time for questions. |
Say the trade-offs as you go, briefly: "named mutations cost us duplicate mutator code, but intent is what makes replay correct". And reach for one concrete number per decision: 30 MB for 20,000 issues, 100 mutations per push batch, 30 days of tombstones. Numbers show you've thought about scale without a lecture.
Follow-ups to expect
Think first
How does undo work with a sync engine?
Undo is a new mutation, not a rollback. closeIssue records the old status, and undo sends setStatus(i42, "todo"). It goes through the queue like anything else, so it syncs and survives reloads. Undoing only your own changes in a shared document is its own problem: the Undo and redo guide covers it.
Think first
A customer has 2 million issues in one team. What changes?
The team scope is now too big for a snapshot. Split scopes by recency or project (open issues plus the last 90 days synced; older ones on demand), and consider SQLite on OPFS for faster bulk writes and real indexes. The server's per-team version counter may also become a write bottleneck, so move to a database change feed for the cursor.
Think first
Why not send full row changes over the WebSocket instead of a poke?
It can be faster by one round trip, but now there are two delivery paths that must agree on order, gaps, and permissions. A poke keeps one path for data (pull) that is already idempotent, paged, cursor-based, and permission-filtered. Once that works, inlining small patches in pokes is an optimization you can add safely.
Summary
- Three copies of the data: server state, confirmed local state, and pending local mutations. The UI shows confirmed plus pending; the server decides what's true.
- The UI reads and writes only the local database, through live queries and named mutations. Row writes and the queue append share one transaction.
- Every mutation has a
(clientId, id)pair. The server applies ids in order, skips duplicates, records rejections, and always advanceslastMutationIdso nothing blocks the queue. - Pull with an opaque server cursor in commit order, never with client timestamps. Deletes are tombstones; a cursor older than the log gets a reset to a snapshot.
- Rebase on every pull: rewind to confirmed state, apply the server patch, replay pending mutations. Named mutations make replay keep intent, such as +1 votes.
- Resolve conflicts in layers: last writer wins per field by server order, business rules in server mutators, CRDTs only for co-edited text, and visible notices for rejections and lost effort.
- Sync subsets by scope, and delete local rows when access is revoked. Bootstrap new devices from a compressed snapshot, then pull deltas.
- One tab leads the network side through a Web Lock; all tabs share the database and hear changes over
BroadcastChannel. Treatnavigator.onLineas a hint, back off with jitter, and show an honest sync status.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- How Replicache works Push, pull, cookies, lastMutationID, and the rewind, apply, replay rebase.
- Zero: writing data with mutators Replicache's successor: the same mutator runs on the client, then authoritatively on the server.
- Scaling the Linear Sync Engine Linear's own talk on how its sync engine and its API evolved.
- Storage quotas and eviction criteria (MDN) Per-browser quotas, LRU eviction, and Safari's 7-day rule.
- Updates to storage policy (WebKit) Safari's quotas, eviction order, and how it grants
persist(). - Persistent storage (web.dev) How
navigator.storage.persist()is granted in each browser. - Web Locks API (MDN) Exclusive locks across tabs and workers, used for leader election.
- Persistent storage options for SQLite WASM The OPFS VFSes, COOP and COEP headers, and concurrency limits.
- Origin private file system (MDN) Where SQLite WASM stores its file, and why sync access needs a dedicated worker.
- Background Synchronization API (MDN) The
syncevent and its browser support. - Local-first software (Ink & Switch) The essay that framed local data as primary; useful contrast with a server-authoritative design.
Concepts behind it
- Offline-first designA way of building apps where the device's own copy of the data is what the UI reads and writes, and a sync engine reconciles it with the server in the background, so the app stays fast and usable on a bad connection or none at all.
- IndexedDBThe browser's built-in transactional database: object stores of structured values, indexes for queries, versioned migrations, and room for hundreds of megabytes, which makes it the place for offline data, drafts, and large blobs.
- Conflict resolution strategiesWhat to do when two people change the same data: from last write wins per field, to version checks with
If-Match, to three-way merges and merge screens, chosen separately for each kind of data. - CRDTsConflict-free replicated data types: data structures that every device can edit on its own, offline or online, and that always merge to the same result without a central server deciding the order.
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- Syncing state across tabsEvery open tab of your app is a separate program with its own memory, so a change in one tab is invisible to the others until you send it across with the storage event, BroadcastChannel, a SharedWorker, or a leader tab chosen with Web Locks.
- Retries, timeouts, and backoffHow a client survives a flaky network and a struggling server: give every request a deadline, retry only the failures that can heal, space the retries out with exponential backoff and jitter, and stop retrying when the server or the network is clearly down.
- Background sync and queued writesSaving the user's writes in a durable queue when the network is gone, then sending each one exactly once when it comes back, even if the tab was closed: the write half of every offline-capable app.