cf.completefrontend
My progress
SYSTEM DESIGN · #129

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

RequirementTargetWhy this number
Local write to screenunder 16 msOne frame at 60 Hz. The whole point of the engine is that a click never waits.
Live query update after a writeunder 50 ms for 20,000 rowsLists must stay instant while the engine works in the background.
Remote change visibleunder 1 s at p95 when onlineFeels live for teammates without a full real-time collaboration stack.
Cold start with warm cachefirst useful screen under 1 sRead from disk, render, then sync. Never block the first paint on the network.
First load of a new deviceunder 10 s for a 20,000-issue workspaceA compressed snapshot of about 4 to 6 MB, streamed in.
Offline duration7+ days, 5,000+ queued mutationsA week of heavy offline work must replay without data loss.
Main threadno task over 50 ms from sync workApplying 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?

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?

Server first, with optimistic updates

Components fetch from the API through a query cache.

Writes patch the cache optimistically, then call the API; failures roll back.

Pros

  • Simplest model; the server is always the truth.
  • No local schema, no migrations, no storage limits.

Cons

  • Offline writes vanish on reload; offline reads only show screens already in memory.
  • Each optimistic write must patch every cached query that contains the row.

Persisted query cache

Same as above, but the cache is saved to IndexedDB and writes wait in a paused mutation queue.

TanStack Query's persisters and paused mutations work this way.

Pros

  • Small step from an existing app.
  • Covers short offline periods well.

Cons

  • Data is stored per request, not per row, so the same issue lives in many places.
  • No local queries: a filter the user never ran online shows nothing offline.

Local database as the source of truth for the UI

A normalized local database holds the rows. Components subscribe to live queries over it.

Writes run as named mutations against the local database and are queued durably. A sync loop pushes the queue and pulls server changes into the same database.

Pros

  • Every read and write is local: a few milliseconds, online or not.
  • Any query works offline, because the data is there, not a cached response.
  • One write updates every view at once, because every view reads the same row.

Cons

  • A real database in the browser: storage limits, migrations, tabs, eviction.
  • Rebasing and conflicts become our problem, not the server's.

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
The core loop of a sync engine. In the browser, UI components call mutators with a mutation name and args. Mutators write rows and append to the queue in the local database in one transaction. The local database re-runs live queries, which update the UI. The sync loop reads the queue and pushes mutation batches to the server's push endpoint, which runs authoritative mutators. The server's pull endpoint returns a patch and a new cookie, which the sync loop applies to the local database.BrowserUI componentslive queriesMutatorscloseIssue(args)Local DBrows + queueSync looppush, pull, rebaseone transactionre-runqueue out, patch inServerPOST /pushauthoritativePOST /pulldelta since cookiepush batchpatch + new cookie
The UI only ever talks to the local database, so every click is local. The network lives entirely on the right side of the picture, in the sync loop, and the server always has the final word.

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.

ts
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" });
What an app registers: tables and named mutators. The same mutator runs locally and, in its server version, on the server.

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?

Raw IndexedDB

Object stores per table, indexes for the fields queries filter on, one transaction per mutation.

Live queries track which stores they read and re-run when a write touches them.

Pros

  • Built into every browser, no download.
  • Works on the main thread and in any worker, including shared ones.

Cons

  • Clumsy API; transactions auto-commit as soon as you await anything that isn't an IndexedDB request, such as a fetch.
  • No joins, no GROUP BY; complex queries run in JavaScript.
  • Bulk writes of 10,000+ small rows are slow compared with SQLite.

IndexedDB through a wrapper such as Dexie

Dexie adds promises, compound indexes, bulk operations, versioned schema upgrades, and liveQuery, which re-runs a query when the data it read changes, across tabs too.

Pros

  • Same storage and reach as raw IndexedDB, far less code.
  • Migrations and live queries come built in.

Cons

  • Still IndexedDB underneath: same query limits and bulk speed.
  • One more dependency in the engine's core.

SQLite in WebAssembly on OPFS

The official SQLite WASM build stores its file in the Origin Private File System, a private per-origin file system with fast synchronous file handles.

Runs inside a dedicated worker, because synchronous OPFS access handles only exist there.

Pros

  • Real SQL: joins, aggregates, partial indexes, and fast bulk writes in one transaction.
  • One storage engine on web, iOS, and Android, as PowerSync and Notion do.

Cons

  • About 1 MB of WASM to download and compile before the first query.
  • The "opfs" VFS (SQLite's storage adapter, its virtual file system) needs SharedArrayBuffer, which needs the COOP and COEP cross-origin isolation headers on the page. The "opfs-sahpool" VFS avoids the headers but allows only one connection at a time.
  • Only one tab can own the file safely, so a leader or shared worker must route queries.

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.

ts
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();
Ask for persistence once the user has opted in, and detect a database that vanished.

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?

Raw row diffs

Record what changed: { table: "issue", id: "i42", set: { status: "done" } }.

The server applies the diff to its row, usually last writer wins.

Pros

  • Generic: the server needs no app logic to apply it.
  • Easy to coalesce: three edits to one field become one.

Cons

  • The server sees the result, not the intent. "Increment votes" arrives as "votes = 8" and loses concurrent votes.
  • Business rules (who may close an issue, unique numbers) have to be reverse-engineered from diffs.
  • Replaying a diff on top of new server state can produce states no real action could create.

Named mutations with arguments

Record the intent: { id: 17, name: "closeIssue", args: { id: "i42" } }.

Client and server each have a mutator with that name. The client's runs optimistically; the server's is authoritative.

Pros

  • Intent survives: addVote replays as +1 on whatever the current count is.
  • The server enforces permissions and rules in one obvious place.
  • Replaying mutations during a rebase gives sensible results on new data.

Cons

  • App code exists twice (client and server mutator) and must agree.
  • Mutations are an API: renaming or changing args needs versioning.

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.

ts
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);
  });
}
Server push handler: skip what's done, stop at a gap, record progress in the same transaction as the change.

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.

Sequence diagram for closing an issue. The user clicks Close. The component calls mutate.closeIssue with the issue id. The engine runs one transaction in the local database that writes the row and appends mutation 36 to the queue. The local database's live query re-runs and the component re-renders with the issue closed. Later the leader engine pushes mutation 36 to the server, which replies 200 received. The engine pulls with its cookie, and the server replies with a patch and lastMutationId 36. The engine drops mutation 36 from the queue.UserComponentEngineLocal DBServerclick ClosecloseIssue(id)1 tx: row, #36re-run: closedpush [#36]200 receivedpull(cookie)patch, #36 okdrop #36
The screen updates on the fourth arrow, before any network call. The push reply only says "received"; the mutation leaves the queue when a pull reports 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: createIssue must land before addComment on 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 lastMutationId at 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.

Sequence diagram of a push whose response is lost. The engine pushes mutations 15 to 20. The server applies them and sets lastMutationId to 20, but its 200 response is lost in the network when the train enters a tunnel. After a 15 second timeout with no reply, the engine retries after 1 second with the same mutations 15 to 20. The server sees all six ids are at or below 20 and skips them, replying 200. The engine pulls with its cookie, and the server replies with lastMutationId 20 and the patch, so the queue empties. No duplicates were created.EngineNetworkServerpush #15 to #20200 OK, lost in tunneltimeout: no replyapplied, last = 20retry in 1 s: #15 to #20200: all 6 skippedpull(cookie)lastMutationId 20, patch
The client can't tell "never arrived" from "arrived, reply lost", so it always resends. The pair (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?

Last pull timestamp

Client sends since=2026-10-20T10:00:00.500Z; server returns rows updated after that time.

Pros

  • Trivial to build on an updatedAt column.

Cons

  • Misses rows from transactions that commit late; breaks under clock skew; can't express deletes without extra work.

Version vector per client

Each row carries a counter per writer; the client sends the highest counter it has seen from each writer.

Pros

  • Works without a central server, so it suits peer-to-peer sync.

Cons

  • Grows with the number of writers; overkill when one server orders every change anyway.

Server change log with an opaque cursor

Every committed change gets a strictly increasing version in commit order, per workspace (or globally).

The server returns changes after the client's cursor, plus a new cursor. The client stores it and sends it back untouched.

Pros

  • No missed rows if versions follow commit order.
  • Deletes are just entries in the log.
  • The cursor is opaque, so the server can change what it encodes later.

Cons

  • The server must keep a change log or per-row versions, and decide how long to keep them.

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

json
{
  "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
}
A pull response: the new cursor, which of this client's mutations are done, and the row operations to apply.
  • 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.
The server's change log for workspace w7, as eight entries from v335 to v342: put i12, del c88, put i42, put i9, put i42, del c901, put i5, put i77. The client's cookie points at v338, so the pull returns the four entries after it, v339 to v342, including the tombstone del c901, and the new cookie v342. Below, a laptop whose cookie is older than the 30 days of log the server keeps gets a reset: a clear operation and a full snapshot, after which its pending mutations are replayed.server change log, workspace w7v335put i12v336del c88v337put i42v338put i9v339put i42v340del c901v341put i5v342put i77cookie v338: send v339 to v342already on the clientpruned: older than 30 dayscookie v120: too oldreset: clear + snapshot,then replay the queue
The cookie is a position in one ordered log, so "what changed since" is a range scan with no gaps. Deletes are entries too (tombstones), which is why the server can only answer cookies newer than its oldest kept entry.

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.

The rebase in four columns for issue 42. Before the pull, the base state v100 has two pending mutations on top, m36 close and m37 vote, giving status done, no assignee, 4 votes. Step 1, rewind: the pending mutations are set aside, leaving base v100: todo, none, 3. Step 2, apply patch: the base becomes server v104: todo, assigned to Ben, 4 votes. Step 3, replay: m36 and m37 run again on top of v104, giving done, Ben, 5 votes.before pullm37 vote +1m36 closebase v100done, none, 41. rewindm37 vote +1m36 closebase v100todo, none, 32. apply patchm37 vote +1m36 closeserver v104todo, Ben, 43. replaym37 vote +1m36 closeserver v104done, Ben, 5each column: status, assignee, votes
Pending mutations are never merged into server rows; they're set aside and run again. Because m37 means +1 rather than votes = 4, replay keeps both Chen's vote from the server and Ana's own.

A worked example

StepIssue 42 statusAssigneeVotesNote
Confirmed base (cookie v100)todonone3Last state the server sent.
After pending m36 closeIssuedonenone3What Ana sees right away.
After pending m37 addVotedonenone4Still optimistic.
Pull arrives (v104): Ben assigned, Chen votedtodoBen4Server state. lastMutationId is still 35.
1. Rewind to basetodonone3Undo m37 then m36, newest first.
2. Apply patchtodoBen4Exactly the server's state at v104.
3. Replay m36doneBen4Intent reapplied to new data.
4. Replay m37doneBen5Ben'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.

Server changes
Queue holds
Step: Local view

Ana sees base v100 plus her 3 pending mutations. A pull has just arrived with the server changes you picked.

Rebase lab at step Local view. Issue 42 shows status done, assignee none, votes 4, title Safari login. Pending mutations: m36 close, m37 vote, m38 rename. Ana sees base v100 plus her 3 pending mutations. A pull has just arrived with the server changes you picked.issue 42: Local viewstatusdoneassigneenonevotes4titleSafari loginpending queuem36 closem37 vote +1m38 renameintent: +1server: 4 votesgreen: changed from base v100
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?

Two layers: base plus overlay

Server rows live in base tables; pending writes live in an overlay. Reads merge overlay over base.

Pros

  • Rewind is free: drop the overlay.
  • Close to Replicache, which keeps immutable commits.

Cons

  • Every query and index must merge two sources, which is hard to do fast on IndexedDB.

Undo log of before-images

Main tables hold the optimistic state. Each pending mutation stores the old value of every row it wrote.

Rewind restores before-images newest first.

Pros

  • Reads stay simple: one table, normal indexes.
  • Rewind only touches rows pending mutations wrote.

Cons

  • Undo entries must be written in the mutator's transaction.
  • Bugs here corrupt state, so it needs heavy tests.

Re-download and replay

Fetch the full state on each pull, then replay pending mutations.

Pros

  • No rewind logic at all.

Cons

  • 30 MB per pull. Only works for tiny datasets.

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?

Last writer wins, whole row

The mutation that reaches the server last overwrites the whole row.

Pros

  • Simplest to build.

Cons

  • Ana's arrival erases Ben's assignee even though she never touched it. Users lose work they can't see was lost.

Last writer wins, per field

Mutations write only the fields the user changed. Each field resolves on its own, in server order.

Pros

  • Edits to different fields both survive: Ben keeps the assignee, Ana keeps her title.
  • Same field: one clear winner, and nobody's other edits are touched.

Cons

  • For the same field, the earlier edit is silently overwritten. Fine for priority, painful for a long description.

Business rules in server mutators

The server mutator decides: reject, adjust, or merge, using rules only it can check.

Pros

  • Handles what no generic rule can: permissions, uniqueness, workflow states, stock levels.

Cons

  • Each mutator needs thought and tests; the client's optimistic guess may be wrong and get corrected.

CRDT field for collaborative text

A long description is stored as a text CRDT (such as Yjs or Automerge); the mutation carries the CRDT update; the server merges.

Pros

  • Both people's typing survives, character by character.

Cons

  • Extra metadata and a library; only worth it for text people actually co-edit.

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.

Strategy
Reconnects last

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.

Partition lab. Issue 42 starts as title Login bug, status todo, priority Medium, no assignee. Offline, Ana sets title Safari bug, status done, priority High. Ben sets status canceled, priority Low, assignee Ben. Ana reconnects last. Final server row: title Safari bug, status done, priority High, assignee Ben. 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.titlestatuspriorityassigneeAnaSafari bugdoneHigh-Ben-canceledLowBenserverSafari bugdoneHighBenboth devices converge after their next pull
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 like ENG-1042 are assigned by the server mutator; the client shows a placeholder such as ENG-? 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, removeLabel are 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?

Everything the user can access

The server computes the user's full readable set and syncs all of it.

Pros

  • Every query works offline; no misses.

Cons

  • A user in a big org can read hundreds of MB, most of which they'll never open.

Named scopes per workspace or team

Data is grouped into scopes such as team:eng or project:p7. The client subscribes to a list of scopes; every row belongs to one.

PowerSync's buckets follow this idea. ElectricSQL's shapes (a table plus a filter that the client subscribes to) are a finer-grained relative.

Pros

  • Predictable size and cost per scope.
  • Permission changes map cleanly to adding or dropping a scope.

Cons

  • Opening something outside your scopes needs a fetch, so it isn't available offline.

Query-driven sync

The client syncs exactly the rows its active queries need, kept for a while after the query closes. Rocicorp's Zero works this way, and it is built for mostly-online apps: it rejects writes while offline.

Pros

  • Smallest footprint; fast first screen.

Cons

  • The server must track every client's queries; offline coverage depends on what was viewed recently.

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.

Partial sync by scope. The server holds four scopes: team:eng, team:design, team:hr, and project:p9. Ana's device subscribes to team:eng and team:design, which arrive as a snapshot then deltas. team:hr was never synced because she isn't a member. She used to hold project:p9, but after an admin removed her, the pull sends dropScope p9 and the device deletes every row tagged with that scope. Comments older than 90 days load on demand and are marked partial.Server scopesteam:engteam:designteam:hrproject:p9Ana's deviceteam:eng+ team:designproject:p9old comments:on demand, partialsnapshot,then deltasdropScopehr: never synced
Every row carries its 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.

  1. Fetch the snapshot as a stream in the sync worker and decompress it with DecompressionStream("gzip") (or let the server send Content-Encoding: gzip).
  2. Parse line by line and insert in batches of about 2,000 rows per transaction, so memory stays flat and progress is visible.
  3. 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.
  4. 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?

Wipe and re-download

When the schema version changes, delete the local database and bootstrap from a fresh snapshot.

Pros

  • No migration code on the client; always correct for confirmed data.

Cons

  • 5 MB download per release per device; the app is empty offline after an update; pending mutations must be pushed first, which fails offline.

Migrate in place

Versioned migration steps (v3 to v4, v4 to v5) run in the IndexedDB upgrade transaction and rewrite stored rows.

Pros

  • Works offline; no re-download; pending work is kept.

Cons

  • Every migration is code that runs on unknown old data; bugs corrupt local state.

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 assignIssue from v3 has args { id, userId }. The server keeps accepting it and translates it to assignees: [userId]. A changed shape gets a new name or a v: 2 field; 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.
Three paths through the v3 to v4 upgrade. Top row: v3 rows on disk, with assignee as one id, go through the v3 to v4 step in onupgradeneeded, and become v4 rows with an assignees list. Middle row: a queued assignIssue mutation at version 1 goes through an adapter step from v1 to v2 args, then replays with the v4 mutator; on the server, the old version is still accepted for 30 days. Bottom row: an old v3 tab receives a versionchange event, closes its connection, and shows a reload prompt, which unblocks the upgrade.stored rowsv3 rowsassignee: idv3 to v4 steponupgradeneededv4 rowsassignees: [id]queued mutationsassignIssue v1{ id, userId }adapt v1 to v2userId to [userId]replay with v4server accepts v1an old tabv3 tabstill connectedversionchangeclose connectionReload promptupgrade unblocked
Data, mutations, and tabs each migrate differently. If the old tab ignored 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?

Every tab syncs on its own

Each tab pushes, pulls, and holds a socket.

Pros

  • No coordination code.

Cons

  • N times the traffic and server load; racing pushers; rebases in several tabs fighting over the same rows.

A SharedWorker owns sync and storage

One worker shared by all tabs of the origin runs the loop and the database; tabs talk to it over MessagePort.

Pros

  • Exactly one instance by construction; tabs stay thin.

Cons

  • Chrome on Android only added SharedWorker in version 148 (2026), so older mobile browsers still need a fallback.
  • Synchronous OPFS access handles aren't available in shared workers, which rules out SQLite on OPFS there.

Leader election with Web Locks

Every tab asks for the same exclusive lock with navigator.locks.request. The holder is the leader and runs the sync loop. The lock is released when its tab closes or crashes, and the next tab in line gets it.

Tabs announce local writes and finished syncs over BroadcastChannel so every tab's live queries re-run.

Pros

  • Supported in all major browsers since 2022.
  • Failover is automatic, with no heartbeats or timeouts to tune.

Cons

  • Followers must route anything the leader owns (the socket, the rebase) through messages.

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.

ts
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();
};
Leader election: the promise inside the lock callback never settles, so the lock is held until the tab goes away.

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.)

Three tabs of the app. Tab C holds the Web Lock named tracker-sync-leader, so it runs the sync loop and is the only tab that talks to the server, with push, pull, and one socket. Tabs A and B run their live queries. All three tabs read and write the same IndexedDB database directly, and all three post and receive local-write and applied messages on one BroadcastChannel.BroadcastChannel: local-write, appliedTab AqueriesTab BqueriesTab Csync loopIndexedDBrows, queue, metaevery tab readsand writes itWeb Lockheld by CServerpush, pullleader only
The database is shared; the network is not. When Tab C closes, the browser releases its lock and Tab A or B takes over, picking up the durable queue where C left it.

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.onLine is false, or after 2 consecutive failed requests (network error or timeout). A request timeout is 15 s, enforced with AbortController.
  • Try now on the online event, on visibilitychange to 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.

The sync loop as a state machine with four states. Synced moves to syncing on a write, a poke, or the timer. Syncing returns to synced when the queue is empty and the pull is done. Syncing moves to error on 401, quota full, or client too old, and error returns to syncing when the user fixes it. Syncing moves to offline after 2 failures or when navigator.onLine is false, and offline returns to syncing on the online event, the tab becoming visible, or a backoff timer. While offline, the queue keeps growing and the status shows how many changes are waiting.syncedqueue emptysyncingpush, pull, rebaseerrorneeds the userofflinebackoff 1 s to 60 swrite, pokequeue empty401, quota, too olduser fixes it2 failures, or onLine falseonline, visible, backoffqueue keeps growing14 changes waitingwrite during sync:one more round
The status UI is a direct view of this machine plus the queue length. 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.

StatusWhenWhat the user sees
SavedQueue empty, last sync succeededA quiet check mark; no text needed.
SyncingPush or pull in flightA small spinner only if it lasts over 1 s, so fast syncs don't flicker.
OfflineOffline state, any queue size"Offline: 14 changes will sync when you reconnect". Saved on this device, which is true.
Needs attentionA mutation was rejected or a conflict copy was madeA badge that opens a list of exactly what happened.
ErrorAuth expired, storage full, client too oldA 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.

Wireframe of the tracker while offline. A sidebar lists teams Eng and Design. The top right shows a status pill: Offline, 14 waiting. The issue list shows ENG-1042 Login fails, closed; a new issue with a placeholder number ENG-?; and ENG-981 Export CSV. A red notice says 1 change couldn't be saved. At the bottom, a dashed dev panel shows leader yes and cookie w7:v18342, the queue with #36 closeIssue and #37 addVote, and rejected #32 forbidden.Tracker: Eng boardTeamsEngDesignOffline: 14 waitingENG-1042 Login fails (closed)ENG-? New issue (pending)ENG-981 Export CSV1 change couldn't be savedleader: yes, cookie: w7:v18342queue: #36 closeIssue, #37 addVoterejected: #32 forbidden
The status pill answers "is my work safe?" at a glance. New rows show a placeholder number until the server assigns one, and the dev panel (hidden behind a shortcut) shows exactly what the engine is holding.

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.

Architecture of the sync engine. On the main thread, the App UI talks to the Engine client. The Engine client posts messages to the sync worker, where the Live queries manager, the Mutator runner, and the Sync loop live; live query results flow back to the Engine client. The live queries, mutator runner, and sync loop all use IndexedDB, which holds rows, the queue, and the undo log. A Web Lock and a BroadcastChannel coordinate tabs. On the server, the Sync loop pushes to the Push endpoint, which runs mutators, and pulls from the Pull endpoint, which reads the change log. A poke over a WebSocket tells the sync loop when to pull, and snapshots on a CDN serve first loads.Main threadApp UIEngine clientSync workerLive queriesMutator runnerSync loopleader onlypostMessageresultsIndexedDBrows, queue, undoWeb LockBroadcastServerPushmutatorsPullchange logPoke (WS)Snapshots
The main thread only renders and forwards; everything slow sits in the worker, one per tab. Only the sync loop crosses into the server zone, and only in the leader tab.
PieceRuns inResponsibility
App componentsMain threadRender live query results; call named mutations. Know nothing about the network.
Engine clientMain threaduseLiveQuery, mutate.*, useSyncStatus. Thin proxy that posts to the worker and caches the latest results for render.
Live query managerSync workerRuns queries against the store, records which tables and index ranges each one read, re-runs only affected queries after a write or rebase.
Mutator runnerSync workerRuns a mutator, its undo-log entries, and the queue append in one transaction.
StoreSync workerThe Store interface over IndexedDB (Dexie) or SQLite on OPFS. Tables, indexes, meta, queue, undo log.
Sync loopLeader's sync workerPush, pull, rebase, backoff, bootstrap, scope changes. The only code that calls the server.
Tab coordinatorEvery tabWeb Lock for leadership, BroadcastChannel for change notices, versionchange handling.
Poke channelLeaderWebSocket that carries only "pull now" hints.
Service workerBrowserServes the app shell offline; pushes the queue on Background Sync where supported.
Server: push, pull, snapshotBackendAuthoritative 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.

Data flow between the server, the sync worker, IndexedDB, and the UI. Read path: the server sends a poke with no data over the socket; the sync worker sends pull with its cookie; the server replies with a patch, a new cookie, and lastMutationId; the sync worker rebases into IndexedDB in one transaction; IndexedDB's changed tables re-run live queries and result diffs go to the UI. Write path: the UI calls mutate with a name and args, which writes rows and the queue in IndexedDB; the sync worker reads a pending batch of up to 100; and sends POST /push to the server.ServerSync workerIndexedDBUIpoke (no data)pull: cookiepatch, cookierebase, 1 txresult diffsmutate(name, args)batch of up to 100POST /push
Only two things ever cross the network: mutation batches going up and patches coming down. The poke carries no data, so it can be lost without harm.

Think first

Why is the sync loop only in the leader's worker, while every tab has a store and a live query manager?

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.

ts
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 };
Engine tables, stored next to the app's tables in the same database.
The local data model. The app table issue has id, title, status, projectId, plus two engine fields, scope and v. Engine tables: meta has clientId, cookie, nextMutationId, schemaVersion, scopes, and userId. queue has id, name, args, mutatorVersion, and createdAt. undoLog has mutationId, table and key, and the before-image, and points at a queue entry. rejections has mutationId, reason, and seenByUser, and also points at a queue entry. A note says app tables are any shape plus scope and v.issue (app)idtitle, statusprojectIdscopevmetaclientIdcookienextMutationIdschemaVersionscopes, userIdqueueidnameargsmutatorVersioncreatedAtundoLogmutationIdtable, keybeforerejectionsmutationIdreasonseenByUserapp tables: anyshape + scope + v
App tables stay the app's; the engine adds only 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.

http
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 }
Push: a batch of mutations in order. The response only acknowledges receipt; confirmation comes with the next pull.
text
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
Pull, poke, and snapshot. The pull response shape was shown in the pull chapter.

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 signout so other tabs close their connections, call indexedDB.deleteDatabase, clear caches, and have the sign-out response send Clear-Site-Data: "storage" as a backstop in browsers that support it.
  • Check ownership on boot. meta.userId must 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.

ts
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}`);
}
A simulation test: thousands of seeded runs, a few invariants that must always hold.

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.

MinutesWhat to doWhat to draw or say
0 to 5ScopeServer is authoritative? How long offline? How much data per user? Which kinds of edits? Shared computers?
5 to 12The core loopDraw UI, local DB, queue, sync loop, server. Say: reads and writes are local; push mutations, pull deltas; server wins.
12 to 20Queue and pullNamed mutations with (clientId, id); lastMutationId on the server; cookie, delta, tombstones, reset.
20 to 30Rebase and conflictsWalk the rewind, apply, replay example with a counter. LWW per field by server order; rules in server mutators; CRDT only for text.
30 to 38Pick two of: tabs, partial sync, migrations, storageFollow the interviewer's interest. Web Locks leader is a quick win; scopes and revocation show senior thinking.
38 to 45Status, security, testingSync 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?

Think first

A customer has 2 million issues in one team. What changes?

Think first

Why not send full row changes over the WebSocket instead of a poke?

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 advances lastMutationId so 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. Treat navigator.onLine as 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.

Junior 0 of 4
Mid-level 0 of 4

References

Concepts behind it

Try next