cf.completefrontend
My progress
FUNDAMENTALS · MODULE 14

Offline-first design

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

The problem: the network is not always there

Meet Priya, a site inspector. She walks through a new building with a tablet, filling in a checklist for each room: photos, notes, a pass or fail for the wiring. The basement has no signal; the upper floors have one bar that comes and goes.

Her company's first app was a normal web app. Every "Save" sent a POST and showed a spinner until the server answered. In the basement the spinner turned forever, then said "Something went wrong". Upstairs, saves worked one time in three, so Priya went back to paper.

Nothing in that app was broken. Its design assumed something untrue: that the network is always there, fast, and reliable.

Online and offline are not two states

We talk about "online" and "offline" as two states. In practice the connection is a spectrum, and its worst part is "barely online".

  • Fully offline. Flight mode, a tunnel, a basement. Requests fail at once, which is at least clear.
  • Lie-fi. Full bars, but the Wi-Fi is a hotel login page or a router with no internet behind it. Requests hang for 30 seconds or more before they fail.
  • Slow and lossy. A train, a crowded stadium, a rural 3G cell: round trips of 1 to 3 seconds, and some requests lost.
  • Good, then gone. A strong connection drops for 20 seconds in a lift, in the middle of a save.

A network-first app (one that asks the server for every read and every write) feels fine on the office Wi-Fi where it was built, and falls apart where many real users spend their day.

Two tablets show the same room 12 checklist with wiring set to fail and a note about a loose socket. On the left, a network-first app shows Saving with a spinner for 30 seconds on a bad connection: every tap waits for the server. On the right, an offline-first app shows Saved on this device in 5 milliseconds, with 1 change waiting to sync: taps are answered from the device.network-firstRoom 12 checklistWiring: failNotes: socket looseSaving... 30 severy tap waits for the serveroffline-firstRoom 12 checklistWiring: failNotes: socket looseSaved here: 5 ms1 change waiting to sync
Same screen, same bad signal. The network-first app makes the user wait for a server that may never answer; the offline-first app saves locally at once and syncs later.

The offline-first idea

Offline-first flips the assumption: the network is an optional extra, not a requirement. The app keeps a copy of the user's data on the device and reads and writes that copy. A separate piece, the sync engine, sends local changes to the server and brings server changes down whenever a connection allows.

For Priya, "Save" now writes the checklist to the tablet in about 5 ms and shows "1 change waiting to sync". When she walks back into signal, the sync engine sends the change on its own. No spinner, and no lost notes.

It also helps users who are never offline. Reading local storage takes milliseconds; a server round trip takes 50 to 500 ms. That is why apps built this way, like Linear, can feel instant on good Wi-Fi: most clicks are answered from data already on the device.

What it costs

Offline-first is not free. Once two copies of the data exist, they can disagree, and you face questions a network-first app never does:

  • How does the device learn about changes made elsewhere?
  • What if Priya and her manager edit the same room while she is offline?
  • Which features can't work offline, and how does the app say so?
  • How does the user know their work reached the server?
  • What if the browser deletes the local copy to free disk space?

Check your understanding · Exercise 1

Which of these users benefit from an offline-first design? Select all that apply.

The mental model: the local copy is the truth for the UI

Think of a field notebook. An inspector writes in it all day, with or without signal. Each evening a clerk copies the new pages into the main files and hands back pages that other inspectors changed. Neither one ever waits for the other.

An offline-first app has the same three parts. The local store is the notebook: a database on the device. The UI is the inspector: it reads and writes only the local store. The sync engine is the clerk: it moves changes between the local store and the server, on its own schedule.

A device holds three layers. At the top, UI components read, write, and subscribe to the layer below. In the middle, the local store in IndexedDB holds confirmed server data plus pending changes; it is what the UI trusts. At the bottom, the sync engine keeps the outbox, the cursor, and rebases changes. Beside them, a service worker serves the app shell. Outside the device, the server has a sync API and a database. The sync engine pushes to the sync API and pulls from it. The UI has no arrow to the server.DeviceUI componentsread, write, subscribeApp shellservice workerLocal storeIndexedDB: confirmed + pendingSync engineoutbox, cursor, rebasewritechangesoutboxnew rowsServerSync APIDatabasepushpull
The UI's only arrows go to the local store. The network sits behind the sync engine, so a slow or missing connection changes how fresh the data is, never whether a tap works.

The one rule: the UI never waits for the network

Every design choice follows from one rule: the UI reads from and writes to the local store. It never calls fetch to show a screen or waits for a server reply to confirm a click.

A network-first task list calls GET /api/tasks, shows a spinner, and renders the reply. An offline-first one queries IndexedDB (the browser's built-in database), renders in milliseconds, and subscribes to the query, so the list updates itself when the sync engine writes newer tasks.

ts
// Network-first: the screen depends on the server answering.
const fromServer = await fetch("/api/tasks").then((r) => r.json());
render(fromServer);

// Offline-first (Dexie): the screen depends only on the local store,
// and re-renders whenever the sync engine writes newer rows.
liveQuery(() => db.tasks.where("projectId").equals(id).toArray()).subscribe(render);

// Writes go local first, and into an outbox for the sync engine.
await db.transaction("rw", db.tasks, db.outbox, async () => {
  await db.tasks.update(taskId, { done: true });
  await db.outbox.add({ id: crypto.randomUUID(), op: "setDone", taskId, done: true });
});

The write is the key part. It changes the task and records the change in an outbox (a queue of changes the server has not confirmed yet) in one transaction. Either both happen or neither does, so a crash can never lose a change or send one that was never applied.

So who owns the truth?

"The local copy is the source of truth" needs care, because there are two truths. For the UI, the local store is the only truth. For the business, the server usually stays the final authority: it decides which change wins, checks permissions, and holds the copy other users see.

So the local store holds two kinds of data. Confirmed data is what the server last said; pending data is the user's changes the server has not accepted yet. The UI shows confirmed data with pending changes on top. An accepted change becomes confirmed; a rejected one is dropped, and the user is told.

QuestionNetwork-firstOffline-first (server-authoritative)Local-first
Where the UI readsThe server, through fetchThe local storeThe local store
Where the UI writesThe server, then waitsThe local store, then the outboxThe local store
Who decides conflictsThe server, at onceThe server, during syncEach device, with merge rules all devices share
Works with no server at allNoOnly for data already syncedYes, the server is just another peer or a relay
Typical toolsREST, React QueryReplicache, PowerSync, custom syncAutomerge, Yjs

Local-first software

In 2019 the research lab Ink & Switch published the essay "Local-first software: You own your data, in spite of the cloud". It argued that the primary copy of the user's data should live on their own devices, with the cloud helping with sync and backup rather than owning the data.

Its seven ideals include no spinners, the network as optional, and user control of the data. It named CRDTs (data types that any two devices can merge without a server deciding) as a promising foundation.

Most products you design in an interview sit between the two ends: the server stays the authority, but the app borrows local reads, local writes, and background sync. This lesson calls that offline-first.

Check your understanding · Exercise 2

In a server-authoritative offline-first app, which statements are true? Select all that apply.

How a sync engine works, step by step

A sync engine does two jobs. Push sends the outbox to the server. Pull asks the server what changed since last time and writes it into the local store. Most engines run both in one loop, push then pull, whenever something changes, a timer fires, or the connection comes back.

Pull: ask only for what changed

The naive pull downloads everything: with 20,000 issues at about 1 KB each, 20 MB per sync. Instead the server gives every change a number that only goes up, and the client remembers the last number it saw: the cursor (also called a version or a watermark).

http
GET /sync/pull?cursor=8812 HTTP/1.1

HTTP/1.1 200 OK
Content-Type: application/json

{
  "cursor": 8815,
  "changes": [
    { "type": "put", "table": "tasks", "id": "t42", "row": { "title": "Fix login", "done": false, "v": 8813 } },
    { "type": "put", "table": "tasks", "id": "t77", "row": { "title": "Ship v2", "done": true, "v": 8814 } },
    { "type": "delete", "table": "tasks", "id": "t13", "v": 8815 }
  ],
  "confirmedMutations": ["m-5a1c", "m-5a1d"]
}

The reply holds only the three rows changed since 8812, plus the new cursor 8815, and the client writes rows and cursor in one transaction. If the tab closes halfway, the old cursor is still stored, so the next pull simply asks again.

Notice the delete entry. A server can only tell a client about a row that no longer exists if it keeps a record of the deletion: a tombstone. Without tombstones, deleted tasks live forever on every device that was offline when they were deleted.

Push: send intentions, not snapshots

The outbox holds mutations: small records of what the user meant to do, such as setDone(t42, true) or rename(t42, "Fix login bug"). Each has a unique id made on the device, like m-5a1c. The engine sends the outbox in order, in a batch.

The id makes pushes safe to retry. Suppose the server applies m-5a1c but the reply is lost in a tunnel. The client can't tell "not applied" from "applied, reply lost", so it sends m-5a1c again, and the server skips an id it has already applied. This is idempotency: applying a request twice has the same effect as applying it once.

Intentions also help with conflicts. "Mark t42 done" can be applied on top of someone else's title change without losing it. "Here is the whole t42 row as I last saw it" would overwrite their title with the old one.

A sequence diagram between the sync engine and the server. Push: the engine posts mutations m-5a1c and m-5a1d, and the server replies 200 OK. Later the server sends a poke over a WebSocket saying changes are ready. Pull: the engine asks for changes since cursor 8812, and the server returns 3 rows, the new cursor 8815, and confirms m-5a1c and m-5a1d. The engine then writes the rows and cursor, drops the confirmed mutations, and rebases the rest, in one transaction.Sync engineServerpushPOST /sync/push [m-5a1c, m-5a1d]200 OKpoke over WebSocket: changes readypullGET /sync/pull?cursor=88123 rows, cursor 8815, m-5a1c + m-5a1d confirmedone transaction: rows, cursor, rebase
Push and pull are separate, repeatable steps. The poke carries no data, so the pull with its cursor 8812 is the only way rows enter the local store.

Rebase: replay pending changes on new data

Now the subtle part. The client shows two pending mutations, and a pull brings newer rows from the server. If the engine just writes those rows, the user's pending changes vanish from the screen until the server confirms them, and the UI flickers.

Good engines keep confirmed state and pending mutations apart. After a pull they replay every mutation still in the outbox on top of the new confirmed rows, the way git rebase replays commits on a newer branch. Replicache made this pattern well known.

  1. The pull returns rows up to cursor 8815 and confirms m-5a1c and m-5a1d.
  2. Drop them from the outbox: their effects are now in the server rows.
  3. Write the new rows as the confirmed state.
  4. Replay what is left in the outbox (say m-5a1e) on top to build what the UI shows.
  5. Notify the UI once, so it renders the result in one update.

One sync cycle, step by stepStep 1 of 6: Offline edits

Three columns: the outbox of pending mutations, the local store's confirmed rows with its cursor, and the server's rows with its latest change number. Steps: offline edits m1 and m2; a push whose reply is lost; a retry the server skips; a pull since cursor 8812 that returns 3 rows; a rebase that drops confirmed mutations and replays m3; and a final synced state with cursor 8816.OutboxLocal storeServerm1 done t42m2 rename t7t42 opent7 Draftt9 opent42 opent7 Draftt9 closedcursor 8812latest 8813UI shows: t42 done, t7 Spec, t9 open (2 pending)
Offline, the user marks t42 done and renames t7. Both changes go into the local rows and the outbox as m1 and m2. The local cursor is 8812. Meanwhile, on the server, Sam closed t9.

Offline edits: Offline, the user marks t42 done and renames t7. Both changes go into the local rows and the outbox as m1 and m2. The local cursor is 8812. Meanwhile, on the server, Sam closed t9.

When the engine runs

The engine needs triggers. A typical setup syncs right after each local write (with a 200 to 500 ms debounce to batch fast typing), on the browser's online event, when the tab becomes visible, on a 30 to 60 second timer, and when the server sends a "something changed" poke over a WebSocket or server-sent events.

The poke carries no data, only a hint to pull, so the pull stays the only way data enters the store. When a push fails, the engine retries with exponential backoff and jitter, as the retries lesson covers, so 10,000 phones leaving the same tunnel don't all retry in the same second.

Check your understanding · Exercise 3

A push of mutation m-77 times out. The client cannot tell whether the server applied it. What makes it safe to send again? Select all that apply.

The parts in detail

An offline-first web app needs more than a sync loop: it must load with no network, store structured data, coordinate tabs, and migrate its database safely. Here is each part, the browser API behind it, and the usual mistake.

1. The app shell: code that loads offline

Local data is useless if the app's code can't load. A service worker (a script the browser runs between the page and the network) caches the HTML, JavaScript, and CSS at install time and serves them on every visit: the app shell, the code without the data. The service worker caching lesson covers the strategies.

The mistake: caching the shell but fetching each screen's data from the network, so the app opens offline straight into an error. The shell and the local store only work as a pair.

2. The local store: IndexedDB or SQLite

The data lives in IndexedDB, the browser's transactional key-value database with indexes. It holds hundreds of megabytes, works in pages and workers, and wraps every write in a transaction. Most teams use a thin wrapper (Dexie or idb) rather than the raw API.

Some engines, PowerSync among them, run SQLite compiled to WebAssembly instead, stored in IndexedDB or in the Origin Private File System (OPFS), a private file area each site gets. You get real SQL on the device, at the cost of a WebAssembly download of several hundred KB and usually a worker to run it in.

The mistake: using localStorage. It is synchronous (it blocks the main thread), holds only strings, and is capped at about 5 MB per origin: fine for a theme setting, not for a user's data.

3. The outbox: an ordered, durable queue

The outbox is an IndexedDB object store with an auto-incrementing key, so entries come back in the order they were written. Each entry holds the mutation id, its name and arguments, when it was created, and how many times sending it has failed.

The outbox as an IndexedDB object store with four rows in key order. Key 1, m-5a1c, setDone t42, 0 tries, is at the front and is sent next. Key 2, m-5a1d, rename t7, 0 tries. Key 3, m-5a1e, create t90, 2 tries after timeouts. Key 4, m-5a1f, rename t90, 0 tries, which depends on key 3. Beside it, a failed list holds m-59ff, rejected with 403: its effect is undone and the user is told.keymutation idintentiontries1m-5a1csetDone t4202m-5a1drename t703m-5a1ecreate t9024m-5a1frename t900failedm-59ff 403undo, tell userfront: sent first, in key orderend: each new write gets the next key
Keys give the order, so create t90 always reaches the server before rename t90. A permanent rejection leaves the queue for the failed list instead of blocking everything behind it.

Order matters: "create t90" must reach the server before "rename t90", or the rename fails. So most engines send the outbox in order and stop at the first entry that fails with a retryable error, such as a timeout or a 503.

The mistake: letting one bad entry block the queue forever. If the server rejects a mutation for good (a 403 because the user lost access, a 422 because the data is invalid), retrying never helps. Move it to a "failed" list, undo its effect on screen, tell the user, and carry on with the rest.

4. Ids made on the device

A task created offline needs an id at once, so links and later mutations can refer to it. So the client makes the id itself instead of waiting for the server's auto-increment number, with crypto.randomUUID() or a time-sortable id such as a UUIDv7 or ULID.

The mistake: temporary ids like temp-1 swapped for server ids later. Every pending mutation, URL, and cached reference that used temp-1 must then be rewritten; ids the server accepts as real avoid the swap.

5. Many tabs, one sync engine

A user with three tabs has three copies of your JavaScript but one IndexedDB. If all three run the sync loop, every push and pull happens three times: still correct thanks to mutation ids, but a waste of battery and bandwidth.

Pick one tab as the leader. With the Web Locks API, every tab asks for a lock named sync-engine; one gets it, and when that tab closes the lock passes to a waiting tab. IndexedDB has no change events across tabs, so tabs tell each other about new data with a BroadcastChannel message. Some engines run sync in a SharedWorker instead, which all tabs share.

ts
// Only one tab at a time runs the sync loop.
navigator.locks.request("sync-engine", async () => {
  await runSyncLoopUntilTabCloses();
});

// Every tab listens for "data changed" and re-queries the local store.
const channel = new BroadcastChannel("store-changes");
channel.onmessage = (event) => invalidateQueries(event.data.tables);

6. Schema migrations on the device

A server team runs a migration once, on one database. You run yours on millions of devices, each at a different old version, whenever each user next opens the app. Some users come back after six months.

IndexedDB has a built-in version number. Opening the database with a higher version fires upgradeneeded, where you add stores and indexes. Write one step per version and run every step from the user's version to the current one, in order.

ts
const request = indexedDB.open("app", 4);
request.onupgradeneeded = (event) => {
  const db = request.result;
  const tx = request.transaction!;
  const from = event.oldVersion; // 0 for a brand new install
  if (from < 1) db.createObjectStore("tasks", { keyPath: "id" });
  if (from < 2) db.createObjectStore("outbox", { autoIncrement: true });
  if (from < 3) tx.objectStore("tasks").createIndex("byProject", "projectId");
  if (from < 4) db.createObjectStore("meta"); // holds the pull cursor
};
// Another tab still has version 3 open: ask it to close and reload.
request.onblocked = () => showReloadOtherTabsNotice();

Three traps hide here. An old tab on version 3 blocks the upgrade until it closes, so listen for versionchange in every tab and close the connection. The outbox may hold mutations in an old shape that the server must still understand. And when a migration is too complex, it is often safer to keep the outbox, drop cached server data, reset the cursor, and pull everything again.

Check your understanding · Exercise 4

Which of these choices are sound for an offline-first web app? Select all that apply.

Conflicts: a rule for each kind of data

A conflict happens when two copies of the same record change independently and then meet. Offline in the basement, Priya changes room 12's status to "fail"; at the office, her manager changes it to "pass" after a phone call. Twenty minutes later Priya's tablet syncs. Which value should room 12 have?

No rule fits every app, or even every field. The conflict resolution lesson covers the theory; the practical move is to pick a rule per kind of data, based on what a wrong merge would cost the user.

Noticing a conflict at all

The server can only resolve a conflict it notices. The usual way is a version number on each record, sent with each change: "set status to fail, based on version 7". If the record is still at version 7, there is no conflict. If it is at version 8, someone else changed it meanwhile, and the server applies the rule for that kind of data.

Many apps track versions per field instead. Then Priya's status change and her manager's notes change don't collide, because they touched different fields. Per-field tracking removes most of the conflicts users would otherwise see.

Decision Which conflict rule should a field use?

Last writer wins

The change that reaches the server last overwrites the field.

Ordered by server arrival, not device clocks.

Pros

  • Simplest to build and explain.
  • Always gives one clear value.

Cons

  • Silently throws away the other person's change.
  • The "last" writer may be whoever was offline longest, with the oldest view.

Merge automatically

Per-field versions, or types built to merge: sets, counters, CRDT text.

Both changes survive unless they touch the same thing.

Pros

  • No prompts in the common case.
  • Collaborative text and lists keep everyone's edits.

Cons

  • More design and storage per record.
  • Some merges are valid but surprising, like one of two renames winning anyway.

Server rejects, user decides

The server refuses a change based on an old version.

The client keeps both values and asks the user to choose.

Pros

  • Nothing is lost without the user knowing.
  • Right where a wrong value is costly.

Cons

  • Interrupts the user, maybe hours after the edit.
  • Too many prompts and users click without reading.

Server rules for the domain

The server applies a business rule: stock can't go below zero, the first booking of a seat wins.

The client shows and explains the outcome.

Pros

  • Protects invariants no merge can.
  • Fits money, bookings, and stock.

Cons

  • An offline action can fail later, after the user thought it worked.
  • Needs clear messaging on the device.

What we pick: Mix them per field: automatic merging for collections and collaborative text, last writer wins for low-stakes settings like a task's colour, a server rule for invariants like stock or seats, and a user prompt only for rare high-stakes fields like an inspection result.

A decision tree for one field. First question: can both edits combine? If yes, merge them, as with sets, lists, and CRDT text. If no, is there a rule to protect? If yes, the server applies a domain rule, as with stock, seats, and money. If no, is a wrong value costly? If yes, keep both values and ask the user, as with an inspection result. If no, use last writer wins, as with a colour or a setting.Can both edits combine?yesMergesets, lists, CRDT textnoIs there a rule to protect?yesServer rulestock, seats, moneynoIs a wrong value costly?yesAsk the userinspection resultnoLast writer winscolour, settings
Walk the tree once per field, not once per app. Most apps end up with all four rules, and last writer wins only for fields where losing one edit does not matter.

Try it: go offline and come back

The lab below plays out Priya's day. Choose the tablet's offline edits and the manager's changes, pick a rule, then come back online to see what survives and what is lost.

Tablet, offline:
Manager, meanwhile:
Rule:
Offline. Make edits, then come back online.
An inspection starts with status pending, notes none, issues tile. Offline, the tablet sets status fail, notes loose plug, issues tile, plug, and syncs last. The manager sets status pass, issues tile, alarm on the server first. The tablet is still offline, so nothing has merged yet.Tablet (offline)syncs at 10:30statusfailnotesloose plugissuestile, plugServermanager at 10:10statuspassnotesnoneissuestile, alarmAfter syncwaitingstatus...notes...issues...offline: the tablet's edits wait in its outbox
Each copy can change on its own while the tablet is offline. Nothing is wrong yet: a conflict appears only when the tablet syncs.

Last writer wins looks fine when only one person edited. It fails only in the conflicting case, and silently: no error, just a manager's correction that disappears. That makes it a dangerous default for anything that matters, and a fine one for things that don't.

Edits against deletes

One conflict needs special care: Priya edits room 12 offline while her manager deletes the whole inspection. Most apps let the delete win and show a note ("This inspection was deleted by Sam; your 3 changes were not saved") with a way to copy the lost text. Some keep deleted records in a trash for 30 days, which turns the conflict into a restore.

Check your understanding · Exercise 5

Pick a sensible rule for each field. Which pairings are sound? Select all that apply.

What works offline, and what can't

No app works fully offline. The real question is which actions work offline, which work in a weaker form, and which are impossible. Answer it feature by feature, early, in a design review or an interview.

Three buckets

  • Works fully offline. Reading data on the device, editing and creating your own records, drafting, and sorting or filtering local lists. This is what the outbox is for.
  • Works in a weaker form. Sending a message (queued and marked as not sent), uploading a photo (stored, uploaded later), search (local subset only), and opening a record never opened before (only if synced ahead of time).
  • Can't work offline. Anything that needs the server to say yes right now: paying, booking a seat, claiming a username, checking stock, first sign-in, accepting an invite.
A matrix of eight features against three columns: works offline, weaker form, and online only. Reading synced records works offline from IndexedDB; editing own records works offline through the outbox; creating records works offline with client ids. Sending a message is queued, uploading a photo is sent later, and search covers only local data: weaker forms. Paying or booking is disabled offline, and first sign-in is blocked: online only.works offlineweaker formonline onlyRead synced recordsIndexedDBEdit own recordsoutboxCreate recordsclient idsSend a messagequeuedUpload a photosent laterSearchlocal onlyPay or bookdisabledFirst sign-inblocked
Decide this table feature by feature, early. The right-hand column is not a failure: an honest disabled button beats a promise the app can't keep.

Why payments and bookings can't be queued

It is tempting to queue "Buy" like any other mutation and show "Order placed". But no order was placed. Two hours later the card may be declined, the item sold out, or the price changed: the app made a promise it had no right to make.

The rule: queue an action only if the user would be fine learning later that it failed. A note, a comment, or a checklist tick passes; its worst outcome is a conflict prompt. A payment, a booking, a stock trade, or a legal signature fails it. For those, disable the button while offline and say why: "Connect to the internet to pay".

Search: local subset or nothing

Offline, a web app can only search what it has stored. A mail client that keeps the last 30 days, about 2,000 emails, can index them in IndexedDB and answer in milliseconds, but it cannot search the other 80,000 on the server.

Say so in the UI: "Showing results from the last 30 days. Connect to search everything." Silent partial results are worse than none, because the user concludes that the email they want does not exist.

Choosing what to keep on the device

The device can't hold everything for a large account, so you choose a subset: the user's own records, projects opened in the last two weeks, the whole team up to a size limit, or records the user marks "Available offline". Google Drive and Notion both offer that explicit choice for files and pages.

StrategyHow it picksGood forWatch out for
EverythingSync the whole accountSmall accounts: a to-do app, a notes appFirst sync time and storage grow with the account
RecentWhat the user opened in the last N daysMail, chat, documentsThe one old thing they need on the plane is missing
ScopedThe user's team or workspace, with a size capIssue trackers, CRMsLarge workspaces hit the cap
PinnedWhat the user marks for offlineLarge files, big document storesUsers forget to pin before going offline

Check your understanding · Exercise 6

Which actions should an offline-first shopping app queue in its outbox while offline? Select all that apply.

Showing sync status honestly

Offline-first removes the spinner, the user's main clue about whether their work is safe. The app saves to the device instantly, so "Saved" alone is ambiguous: saved here, or saved on the server where my team can see it?

Good sync status answers three questions without getting in the way: can I keep working (almost always yes), has my work reached the server, and is what I'm looking at up to date?

Don't trust navigator.onLine

Browsers expose navigator.onLine and fire online and offline events. A false is fairly reliable: the device has no network link at all. A true only means the device is connected to some network, which may not reach the internet or your server.

So the hotel Wi-Fi login page, the router with a dead uplink, and the corporate network that blocks your API all report true. An app that shows "Online" from this flag is lying in exactly the cases that confuse users most.

  • Use the offline event as a fast hint to stop trying and show the offline state.
  • Use the online event as a hint to try a sync now, not as proof that sync will work.
  • Decide what to show from real results. As the retries lesson suggests, treat several failures in a row (say three) as offline even when the flag says true; the first success proves you are back.
  • After a quiet spell, send a tiny probe (a HEAD to a health endpoint with a 5-second timeout).
ts
type SyncState = "synced" | "pending" | "syncing" | "offline" | "error";

function syncState(s: {
  linkDown: boolean; // navigator.onLine === false
  failuresInARow: number; // timeouts and 5xx since the last success
  rejected: number; // changes the server refused with a 4xx
  inFlight: boolean;
  pending: number; // outbox length
}): SyncState {
  if (s.rejected > 0) return "error"; // needs the user to review
  if (s.linkDown || s.failuresInARow >= 3) return "offline"; // no link, or lie-fi
  if (s.inFlight) return "syncing";
  return s.pending > 0 ? "pending" : "synced";
}
A state machine for the app's sync status. Synced moves to pending when the user edits. Pending moves to syncing on a trigger. Syncing moves to synced on success with an empty outbox, back to pending on a timeout or 5xx, and to error on a 4xx. Error moves back to syncing when the user retries or reviews. Three failures in a row move syncing to offline even though the browser reports a link. The offline event moves any state to offline, shown from synced, and offline moves to syncing on the online event.offlinesyncingerrorsyncedpendingedittriggertimeout, 5xxok, empty4xxretryonline event3 failuresoffline
The status comes from real outcomes: a success, a timeout, a rejection. The online and offline events only move the machine to try again or to stop trying.

What to show, and where

The best sync indicators are quiet, because most of the time everything is synced. Status grows louder only when the user needs to act or would otherwise be misled.

  • A small status line in the header or sidebar: "All changes synced", "Syncing 3 changes", "Offline: 3 changes saved on this device".
  • Last synced time when offline or stale ("Last updated 2 hours ago"), so the user knows how old the data may be.
  • Per-item marks on unsynced items: a clock icon on an unsent message, a "Not synced" tag on a new record.
  • A banner only for states that need attention: offline for a long time, a change the server rejected, or storage running out.
  • A warning before data loss, such as "3 changes have not synced and will be lost", before sign-out or clearing data.
A wireframe of an inspections screen. The header says Site A, floor 2 on the left and, on the right, Offline: 2 changes saved here. Three rows: room 12 wiring fail, tagged not synced; room 13 pass, with no tag because it is synced; room 14 notes added, tagged not synced. At the bottom a red banner says 1 change was rejected, with a Review button.InspectionsSite A, floor 2Offline: 2 changes saved hereRoom 12: wiring failnot syncedRoom 13: passRoom 14: notes addednot synced1 change was rejected (403)Review
Quiet by default: one status line and small tags on unsynced items. Only the rejected change gets a banner, because only it needs the user to act.

Status for every user

Put the status text in an element with role="status" (a polite live region), so changes like "Offline: changes saved on this device" are read out without interrupting. Announce going offline, coming back, and failures, not every flip between syncing and synced.

Never use colour alone: a grey dot and a green dot look alike to many people with colour blindness. Pair every icon with words, and make failure states keyboard-reachable with a "Retry" or "Review" button.

Check your understanding · Exercise 7

A user is on hotel Wi-Fi that shows a login page. navigator.onLine is true. Three changes are in the outbox and the last three pushes timed out. What should the app show? Pick one.

Failure modes: storage, eviction, and long absences

Offline-first apps keep state for weeks on a device you don't control, so they fail in ways network-first apps never do. Most of these failures are silent: data just goes missing or stale.

Storage limits

Each browser gives a site a different share of the disk. Per MDN, Chrome lets one origin use up to about 60% of the total disk. Firefox allows the smaller of 10% of the disk or 10 GiB per site in normal ("best-effort") mode, and far more once storage is persistent. Safari 17 and later allow around 60% of the disk for sites in the Safari app and for Home Screen web apps.

Ask the browser rather than guessing: navigator.storage.estimate() returns your usage and quota. A write over the limit fails its IndexedDB transaction with a QuotaExceededError. Catch it, tell the user, and free space by dropping cached server data (it can be pulled again), never the outbox.

ts
const { usage = 0, quota = 0 } = await navigator.storage.estimate();
console.log(`${(usage / 1e6).toFixed(1)} MB of ${(quota / 1e6).toFixed(0)} MB`);

// Ask the browser not to evict this site's data under storage pressure.
const persisted = await navigator.storage.persisted();
if (!persisted) {
  const granted = await navigator.storage.persist();
  // Chrome decides from signals such as an installed app or high engagement.
  // Firefox shows a permission prompt. A "no" is normal: plan for it.
}

Eviction: the browser may delete your data

By default, site storage is best-effort: when the disk runs low, the browser may delete whole sites' data, usually the least recently used first, without asking. Calling navigator.storage.persist() asks to make your storage persistent, so only the user can clear it. Browsers grant it on their own terms, so treat it as a bonus, not a guarantee.

Safari adds one more rule. With tracking prevention on (the default), it deletes a site's script-writable storage, including IndexedDB and service worker registrations, after seven days of Safari use without the user interacting with that site. Home Screen web apps keep their own day counter, which resets each time they are opened. A user who skips your site in Safari for two weeks may come back to an empty store and a lost outbox.

A timeline from now to beyond 90 days, with typical values. The access token lasts about an hour, after which the refresh token renews it. The refresh token lasts 30 days, after which the user must sign in again. Safari may delete the site's storage after 7 days of Safari use without a visit. The server keeps tombstones for 90 days; a cursor older than that needs a full re-pull.access tokenrefresh token renews itrefresh tokensign in againSafari datastore may be wipedtombstonesfull re-pullnow7 days30 days90 days
Typical values; yours will differ. The axis is stretched so short spans are visible. Each lane is a different way a returning user finds the device out of step with the server.

Defend in layers. Sync pending changes quickly, ask for persistent storage once the user shows real use, and encourage installing the app where offline matters. On startup, if the cursor is gone, assume eviction: pull everything again and tell the user this device's data was reset, in case it held edits that never synced.

The user who comes back after a month

  • Expired sign-in. Access tokens often last an hour, refresh tokens days or weeks, so a sync after a long absence gets 401. Keep the outbox, ask the user to sign in again, then resume. Never clear local data just because a token expired.
  • A cursor too old. Servers can't keep tombstones forever. If they keep them for 90 days and the client's cursor is older, the server can't say what was deleted. It should answer "reset" so the client drops cached data and pulls everything, keeping its outbox.
  • An old app version. The service worker may still serve last month's code. Version the mutation format, keep the server able to read the last few versions, and answer "please update" when it can't.
  • Lost permissions. The user was removed from a project while offline, so their queued edits come back as 403. Drop them, remove the project's data from the device, and explain.

Bugs that only show up offline

Some bugs appear only after long offline spells or in odd orders: a rejected mutation blocking the queue, a migration crashing on a database two versions old, two tabs both syncing.

Test for them on purpose. Run migrations in CI against saved IndexedDB snapshots from older app versions. Add a debug screen showing the outbox, cursor, and last sync results. Log sync outcomes (pushed, rejected, reset) so a spike in rejections after a release shows up within the hour.

Check your understanding · Exercise 8

A user opens your offline-first web app in Safari after three weeks away. The local store is empty. What are the likely causes and right responses? Select all that apply.

In real products

Several well-known products are built around a local store and a sync engine, and ready-made engines now do the hard parts. The details below come from public talks, docs, and blog posts; products change, so treat them as examples of the patterns, not exact specs.

Linear: a sync engine as the whole data layer

Linear, the issue tracker, is the example people cite most. In talks about its sync engine, co-founder Tuomas Artman has described a client that keeps a local copy of the workspace data in IndexedDB, renders screens from an in-memory model built from it, and applies the user's changes locally first.

In those talks, changes go to the server as transactions, the server stamps each accepted change with an increasing sync id, and other users' changes stream back over a WebSocket. The pay-off is mostly speed: almost no click waits for the network.

Google Docs: opt-in offline for documents

Google Docs, Sheets, and Slides support offline editing once the user turns it on in Drive's settings (in Chrome this uses the Google Docs Offline extension). Recent files are kept on the device, and offline edits merge with other people's when the connection returns, with the same kind of operational transformation that powers live collaboration.

Two lessons: offline is opt-in per device, because documents stored on a shared computer are a privacy risk; and text merges automatically, because losing a paragraph is not acceptable.

Notion: offline pages chosen ahead of time

Notion was for years an online-first app with a best-effort SQLite cache. In August 2025 it launched Offline Mode in its desktop and mobile apps (not the browser). Users mark pages "Available offline", and Notion can also keep recent and favorite pages downloaded. It is the pinned strategy from the previous chapter: a workspace can be far too big to sync whole.

Notion's engineering blog describes turning that cache into a persistent store that records why each page is offline, subscribing to an update channel per offline page, and moving offline pages to a CRDT data model so that offline text edits merge.

Sync engines you can use

ToolModelConflict handlingWhen it fits
Replicache (Rocicorp)Client mutators pushed to your server; pulls with a cursorYour server code decides; pending mutations are rebasedServer-authoritative web apps. Now in maintenance mode; its successor, Zero, does not queue writes while offline
PowerSyncPostgres and other backend databases synced to SQLite on the deviceYour backend applies uploads; server data wins on downloadApps with an existing SQL backend, including mobile
Electric (ElectricSQL)Streams subsets ("shapes") of Postgres to clients over HTTPReads are synced; writes go through your APIRead-heavy apps that need live local data
Automerge, YjsCRDT documents shared between peers or via a relayAutomatic merge by data typeCollaborative and local-first documents
Workbox Background SyncReplays failed requests from a service workerNone: requests are retried as they wereSimple "send it later" for forms and posts

The last row is not an offline-first data layer. It retries failed requests (the background sync lesson, next, covers it) but gives no local reads, rebasing, or conflict rules. Knowing that difference is a strong interview signal.

Check your understanding · Exercise 9

What do Linear, Google Docs offline, and Notion's offline mode have in common? Select all that apply.

Using it in a system design interview

Offline support comes up in the prompt ("Design a note-taking app that works offline") or, more often, as a follow-up: "What happens on a bad connection?" Either way, the interviewer is checking whether you see the distributed system hiding behind the feature.

When to raise it yourself

Raise it during requirements if users are mobile or in the field, or the app is a tool people use all day (notes, tasks, mail). Ask which actions must work offline and how stale data may be. For a checkout page or a live dashboard, just say those parts are online-only and why.

What to draw

  1. Three boxes on the client: the UI, the local store (IndexedDB), and the sync engine with its outbox. Arrows from the UI go only to the local store.
  2. The service worker caching the app shell, so the app opens with no network.
  3. Two arrows between the sync engine and the server: push mutations with ids, pull changes since a cursor. Add a WebSocket "poke" if the app is real-time.
  4. A small table of conflict rules per data type.
  5. A status line in the UI wireframe: pending count, last synced, offline state.

How deep to go, by level

LevelWhat a strong answer covers
JuniorStore data in IndexedDB, cache the shell with a service worker, queue writes while offline, show an offline banner, and name which features need the network.
MidLocal store as the UI's source, an outbox written in the same transaction, idempotent pushes with mutation ids, pulls with a cursor, a conflict rule chosen per field, and honest status that does not trust navigator.onLine.
SeniorRebasing pending mutations after a pull, tombstones and cursor resets, client schema migrations and old outbox formats, multi-tab leadership, storage quotas and eviction, what subset to sync, and when to buy a sync engine instead of building one.

Think first

You designed a chat app. The interviewer asks: "The user sends three messages in a tunnel. What happens?" Walk through it.

Check your understanding · Exercise 10

Which of these would weaken an answer about offline support? Select all that apply.

Summary

  • Offline-first means the UI reads and writes only a local store; a separate sync engine talks to the server. The app gets instant reads online and keeps working offline.
  • Write each change and its outbox entry in one transaction. Push mutations in order, with client-made ids so the server can deduplicate retries.
  • Pull with a server-issued cursor and tombstones for deletes, then rebase pending mutations on the new data so the UI never flickers.
  • Choose conflict rules per field: merge for text and lists, last writer wins for low-stakes values, server rules for invariants, and a user prompt only for costly single values.
  • Decide feature by feature what works offline. Payments, bookings, and search over data that never reached the device are online-only, and the UI should say so.
  • Base sync status on real results, not navigator.onLine: show pending counts, last synced time, and failures, and make them accessible.
  • Device storage is borrowed: plan for quotas, eviction, Safari's seven-day rule, schema migrations, and users returning after a month. Cached data is disposable; the outbox is precious.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

What does "offline-first" mean, and how is it different from adding an offline page?

Question 2

Walk me through how local changes reach the server safely.

Question 3

How does the client learn about changes made elsewhere without downloading everything?

Question 4

Two people edit the same record, one of them offline. How do you resolve it?

Question 5

How do you tell the user whether their work is saved?

Question 6

What can go wrong with data stored on the device for a long time?

Practice it

These design problems lean on offline-first design.

References

Read next