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.
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.
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.
// 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.
| Question | Network-first | Offline-first (server-authoritative) | Local-first |
|---|---|---|---|
| Where the UI reads | The server, through fetch | The local store | The local store |
| Where the UI writes | The server, then waits | The local store, then the outbox | The local store |
| Who decides conflicts | The server, at once | The server, during sync | Each device, with merge rules all devices share |
| Works with no server at all | No | Only for data already synced | Yes, the server is just another peer or a relay |
| Typical tools | REST, React Query | Replicache, PowerSync, custom sync | Automerge, 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).
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.
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.
- The pull returns rows up to cursor 8815 and confirms
m-5a1candm-5a1d. - Drop them from the outbox: their effects are now in the server rows.
- Write the new rows as the confirmed state.
- Replay what is left in the outbox (say
m-5a1e) on top to build what the UI shows. - Notify the UI once, so it renders the result in one update.
One sync cycle, step by stepStep 1 of 6: Offline edits
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.
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.
// 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.
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?
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.
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.
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.
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.
| Strategy | How it picks | Good for | Watch out for |
|---|---|---|---|
| Everything | Sync the whole account | Small accounts: a to-do app, a notes app | First sync time and storage grow with the account |
| Recent | What the user opened in the last N days | Mail, chat, documents | The one old thing they need on the plane is missing |
| Scoped | The user's team or workspace, with a size cap | Issue trackers, CRMs | Large workspaces hit the cap |
| Pinned | What the user marks for offline | Large files, big document stores | Users 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
offlineevent as a fast hint to stop trying and show the offline state. - Use the
onlineevent 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
HEADto a health endpoint with a 5-second timeout).
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";
}
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.
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.
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.
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
| Tool | Model | Conflict handling | When it fits |
|---|---|---|---|
| Replicache (Rocicorp) | Client mutators pushed to your server; pulls with a cursor | Your server code decides; pending mutations are rebased | Server-authoritative web apps. Now in maintenance mode; its successor, Zero, does not queue writes while offline |
| PowerSync | Postgres and other backend databases synced to SQLite on the device | Your backend applies uploads; server data wins on download | Apps with an existing SQL backend, including mobile |
| Electric (ElectricSQL) | Streams subsets ("shapes") of Postgres to clients over HTTP | Reads are synced; writes go through your API | Read-heavy apps that need live local data |
| Automerge, Yjs | CRDT documents shared between peers or via a relay | Automatic merge by data type | Collaborative and local-first documents |
| Workbox Background Sync | Replays failed requests from a service worker | None: requests are retried as they were | Simple "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
- 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.
- The service worker caching the app shell, so the app opens with no network.
- 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.
- A small table of conflict rules per data type.
- A status line in the UI wireframe: pending count, last synced, offline state.
How deep to go, by level
| Level | What a strong answer covers |
|---|---|
| Junior | Store 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. |
| Mid | Local 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. |
| Senior | Rebasing 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.
Each message gets a client-made id and goes into the local list and the outbox in one transaction. It shows at once with a "not sent yet" clock icon.
The offline event (or a failed push) puts the app in the offline state; the status line says "Offline: 3 messages will send when you reconnect".
On reconnect, the sync engine pushes the three messages in order. The server deduplicates by id, so a retry after a lost reply never posts twice.
The server gives each message its final position and timestamp. The next pull brings other people's messages sent meanwhile, and the client rebases so the list shows the server's order.
If a message is rejected (the user was removed from the channel), it is marked "Not sent" with a retry or delete option, rather than silently vanishing.
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?
Offline-first means the UI reads and writes a local store on the device, and a separate sync engine moves changes to and from the server in the background. The network is never on the path of a click.
An offline page is a fallback for when a network-first app fails. It tells the user they're offline but lets them do nothing. Offline-first keeps the app working, and also makes it faster online, because reads take milliseconds instead of a round trip.
The cost is that there are now two copies of the data, so you need a sync protocol, conflict rules, and honest status in the UI.
Question 2
Walk me through how local changes reach the server safely.
The user's change is written to the local record and to an outbox in one IndexedDB transaction, so neither can exist without the other.
The sync engine sends outbox entries in order. Each has a client-made mutation id, so the server can skip an id it already applied when a retry follows a lost reply.
Permanent rejections such as 403 or 422 leave the queue, undo their local effect, and show the user a message. Temporary failures back off with jitter and retry.
The server's next pull response says which mutation ids it confirmed; the client removes them and rebases the rest on top of the new server data.
Question 3
How does the client learn about changes made elsewhere without downloading everything?
The server numbers every change with an increasing counter. The client stores the last number it saw, the cursor, and asks for changes since it.
Deletes come through as tombstones, so offline clients learn what was removed. Tombstones can't be kept forever; if a cursor is older than the retention window, the server tells the client to reset and pull everything again, keeping its outbox.
A WebSocket or server-sent events can carry a tiny "poke" so clients pull at once instead of waiting for a timer.
Question 4
Two people edit the same record, one of them offline. How do you resolve it?
First, detect it: each change carries the version it was based on, ideally per field, so edits to different fields never collide.
Then choose a rule per kind of data. Collaborative text and lists merge automatically with CRDTs or operational transformation. Low-stakes fields like a colour use last writer wins by server order, never by device clock. Invariants like stock or seat counts use a server rule. High-stakes single values, like an inspection result, keep both and ask the user.
Edits against deleted records usually lose to the delete, with a message and a way to recover the text.
Question 5
How do you tell the user whether their work is saved?
Show a quiet status line built from real sync results: synced, syncing N changes, offline with N changes saved on this device, or a failure that needs action. Add the last synced time when data may be stale.
Don't trust navigator.onLine: true only means a network link exists. Use the events as hints and the outcome of pushes and pulls as the truth.
Put the text in a role="status" live region, announce only meaningful changes, pair icons with words, and warn before logout or reset would delete pending changes.
Question 6
What can go wrong with data stored on the device for a long time?
Browsers can evict best-effort storage under disk pressure, and Safari removes a site's script-writable storage after seven days of use without interaction unless it is a Home Screen web app. Request persistent storage, sync quickly, and detect a reset store on startup.
Quotas can run out: check navigator.storage.estimate(), catch QuotaExceededError, and drop cached server data before ever touching the outbox.
Users come back with expired tokens, old cursors, old app versions, and old database schemas. Keep the outbox through re-login, support resets, version the mutation format, and test migrations against saved snapshots of old databases.
Practice it
These design problems lean on offline-first design.
- Design a podcast audio playerMedium
- Design a QR code event check-in appMedium
- Design a gamified learning app like DuolingoMedium
- Design an online exam platformMedium
- Design InstagramHard
- Design Uber's rider web appHard
- Design a collaborative document editor like Google DocsHard
- Design a collaborative design tool like FigmaHard
- Design a whiteboard like ExcalidrawHard
- Design NotionHard
- Design GmailHard
- Design Google CalendarHard
- Design SlackHard
- Design an offline sync engineHard
- Design a browser video editorHard
References
- Local-first software: You own your data, in spite of the cloud (Ink & Switch, 2019) The essay behind the local-first movement and its seven ideals.
- Storage quotas and eviction criteria (MDN) Per-browser limits, best-effort versus persistent storage, and eviction.
- Navigator: onLine property (MDN) What the flag does and does not tell you.
- Using IndexedDB (MDN) Transactions, versioning, and
upgradeneeded. - Updates to Storage Policy (WebKit blog) Safari 17 quotas and how eviction works in WebKit.
- Web Locks API (MDN) Electing one tab to run the sync loop.
- Replicache: how it works A clear description of push, pull, and rebasing pending mutations.
- Scaling the Linear Sync Engine (Linear) A talk on how Linear's client-side sync engine works.
- How we made Notion available offline (Notion engineering blog) Tracking offline pages, push-based updates, and moving offline pages to a CRDT model.
Read next
- Progressive web appsA website that the browser can install as an app: a manifest gives it a name, icons, and its own window, a service worker makes it open without the network, and the result lives on the home screen without an app store.
- IndexedDBThe browser's built-in transactional database: object stores of structured values, indexes for queries, versioned migrations, and room for hundreds of megabytes, which makes it the place for offline data, drafts, and large blobs.
- Conflict resolution strategiesWhat to do when two people change the same data: from last write wins per field, to version checks with
If-Match, to three-way merges and merge screens, chosen separately for each kind of data. - CRDTsConflict-free replicated data types: data structures that every device can edit on its own, offline or online, and that always merge to the same result without a central server deciding the order.
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- Service worker cachingUsing a service worker as a programmable cache between your page and the network, so you choose per request whether to answer from a stored copy, the network, or both: the basis of instant loads and offline apps.