Most apps stop the moment the signal drops. You tap a button, a spinner turns, and your work is gone. Some apps keep going: you can still read, write and save, and everything catches up later. This article explains how those apps are built, in plain words first and in engineering detail after.
What an offline app is, and what it is made of
An offline app is an app that keeps its most important features working when there is no internet. It does not pretend the internet is there. It saves your work on the device, tells you honestly that it has not been sent yet, and sends it when the connection comes back.
People often call this "offline-first". The idea is simple: the app talks to the device first and to the server second. The server is still the final record, but the screen never waits for it.
An offline app is made of these parts:
- A local database on the device. The app keeps its own copy of the data it needs, on the phone or in the browser. On a phone this is often SQLite. In a browser it is usually IndexedDB.
- Screens that read from the local database. The list, the form and the detail page all read from the copy on the device, not straight from the network. This is why they open fast and why they still work with no signal.
- An outbox of pending changes. Every change the user makes is written down as a small task: "create this", "update that". The tasks wait in order until they can be sent.
- A sync engine. This is the part that sends the outbox to the server and pulls new data back from the server. It runs when the connection returns, when the app opens, or on a timer.
- Conflict rules. When the same thing was changed in two places, the app needs a rule for which version wins, or how to combine them.
- Network detection. The app needs to know whether it is online, so it can show the right state and start syncing at the right moment.
- Storage limits and cleanup. Phones fill up. The app needs a budget for how much it keeps and a rule for what it removes first.
- Background jobs. Some of this work should happen when the user is not looking: syncing, downloading, cleaning up.
The parts of an offline app: screens work against the local database, and the sync engine moves changes between the outbox and the server.
None of these parts is hard alone. The difficulty is that they all have to agree with each other, every time, even when the app is killed halfway through.
A shopkeeper's notebook
Imagine a small shop. The shopkeeper records every sale on a computer that is connected to the head office. One morning the internet goes down. Customers keep coming in.
A sensible shopkeeper does not close the shop. They take out a paper notebook and write each sale there: what was sold, how much, the time. When a customer asks if something is in stock, they look at the shelf and at today's notebook, not at the office system. In the evening, when the line is back, they copy every notebook entry into the office register and tick each one as done.
That shop is an offline-first app. Here is how each part maps:
| In the shop | In the app |
|---|---|
| The paper notebook | The local database on the device |
| Looking at the notebook to answer a customer | Screens that read from local data |
| The list of sales not yet copied to the office | The outbox of pending changes |
| Copying entries into the office register in the evening | The sync engine |
| The office register | The server, which holds the final truth |
| Two branches both sold the last bag of rice | A conflict, which needs a rule |
| Checking whether the office line is working | Network detection |
| Tearing out old pages that are already copied | Storage limits and cleanup |
| Doing the copying when the shop is quiet | Background jobs |
Keep this shop in mind. Every problem later in this article has a version that happens in the shop too.
Online-only vs offline-first
Most apps are online-only, and that is often the right choice. Here is how the two compare.
Online-only waits for the server on every tap; offline-first saves on the phone first and syncs later.
| Online-only | Offline-first | |
|---|---|---|
| Where the truth lives | On the server only | On the server, with a working copy on each device |
| What happens with no signal | Errors, spinners, lost input | The app keeps working and syncs later |
| Speed | Every screen waits for the network | Screens read from the device, so they open at once |
| Complexity | One copy of the data, simple rules | Two copies, an outbox, sync, conflicts, migrations |
| Cost to build | Lower | Higher, and it stays higher as the app grows |
| When it is right | Users are usually online, or data must be live (payments, stock levels, bookings) | Users work in places with weak or no signal, or must never lose what they typed |
When not to build offline-first
Be honest about the cost. Offline-first is not a feature you add at the end. It changes how every screen reads data and how every change is saved.
Do not build it when the action only makes sense live. A payment, a seat booking or a bid needs the server to say yes at that moment. You can save a draft offline, but you cannot confirm a booking with no server.
Do not build it when your users are almost always online and a short "you are offline" message would be enough. Do not build it for data that changes every second and is useless when old, like live prices.
A middle path often works well. Cache what the user reads, so screens still open, and keep writes online-only with a clear message. That covers many apps for a fraction of the cost.
The architecture: one change, from tap to server and back
Follow one change through the system. In the shop, this is one sale. In an app, it might be a student marking a lesson as done, or a field worker saving a form.
One change, from tap to server and back, in seven steps.
Step by step:
- Write locally. The change goes into the local database first. This is a normal database write, so it is fast and it works with no network.
- Show it at once. The screen reads from the local database, so the change appears immediately. The item is marked as pending, so the user knows it has not reached the server yet.
- Queue it. In the same local transaction, the app adds a task to the outbox. The task gets a unique ID, created on the device, called an idempotency key. If the write and the task are not saved together, a crash can leave a change that never syncs.
- Sync when online. When the network comes back, the sync engine takes tasks from the outbox, oldest first, and sends them.
- The server confirms. The server checks the change, saves it and replies with the saved record and its version number. If it has seen that idempotency key before, it returns the earlier result instead of saving twice.
- Resolve conflicts. If the server's copy changed since the device last saw it, the conflict rule decides what happens.
- Update local state. The app replaces its local copy with the server's version, removes the task from the outbox and marks the item as synced.
Here is a short sketch of the sync step. It is not from any real project, just the shape of the idea:
type Task = { id: string; op: string; body: unknown; tries: number };
async function flushOutbox(db: LocalDb, api: Api) {
for (const task of await db.outbox.oldestFirst()) {
try {
const saved = await api.send(task.op, task.body, { idempotencyKey: task.id });
await db.transaction(async (tx) => {
await tx.records.replace(saved);
await tx.outbox.remove(task.id);
});
} catch (err) {
if (!isRetryable(err)) { await db.outbox.markFailed(task.id); continue; }
await db.outbox.update(task.id, { tries: task.tries + 1 });
return; // stop here and keep the order; try again on the next reconnect
}
}
}
Two details matter. The task ID is created when the user taps Save, not when the request is sent, so every retry carries the same key. And on a network error the loop stops instead of skipping ahead, so changes reach the server in the order the user made them.
const BASE_DELAY_MS = 1_000;
const MAX_DELAY_MS = 5 * 60_000;
async function syncLoop(db: LocalDb, api: Api, net: Network) {
let attempt = 0;
for (;;) {
await net.waitUntilOnline();
await flushOutbox(db, api);
if ((await db.outbox.pendingCount()) === 0) {
attempt = 0;
await db.outbox.waitForNewTask();
continue;
}
attempt += 1;
const delay = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** attempt);
// random jitter, so phones that reconnect at the same moment do not all retry together
await sleep(delay / 2 + Math.random() * (delay / 2));
}
}
A sketch of the loop around flushOutbox: wait for a signal, and back off with some randomness after a failure.
What goes wrong
Offline apps fail in a small number of ways. Each one has a version in daily life.
Two devices edit the same thing
You change a shared shopping list on your phone while on the train. Your partner changes the same list on a laptop at home. Both devices sync later. In the shop, two branches each sold the last bag of rice while their lines were down.
Two phones edit the same version offline: the first sync is accepted, and the second gets a conflict that a rule resolves.
A retry creates a duplicate
You send a message on a weak signal. The app is not sure it went, so it tries again, and the other person sees it twice. In the shop, the shopkeeper copies a sale into the register, gets interrupted, and copies it again.
The phone runs out of storage
A podcast or video app downloads episodes until the phone is full. Photos stop saving, and other apps start to fail. In the shop, the notebook runs out of pages in the middle of a busy day.
The user sees stale data
A delivery or travel app shows a status from an hour ago as if it were live. The user makes a decision on old information. In the shop, the notebook says three bags of rice are left (an example figure), but the office already moved them to another branch.
Device clocks disagree
One phone's clock is a few minutes fast, for example, and another is set to the wrong time zone. The app uses device time to decide which edit is newer, so the older edit wins. In the shop, one branch's wall clock is wrong, so the evening register puts sales in the wrong order.
Something deleted comes back
You delete a note on your phone. A week later an old tablet that was switched off connects, still has the note, and syncs it back. The deleted note returns. In the shop, an entry crossed out in the register is copied in again from a notebook that never saw the crossing out.
The login token expires while offline
You work offline for a long trip. When you reconnect, your session has expired. The app sends your queued changes, the server rejects them as unauthorised, and some apps quietly drop them. In the shop, the evening clerk at the office has changed, and the new one will not accept the notebook without a signature.
An app update changes the local data shape
The new version of an app stores data in a different format. You update with changes still waiting in the outbox. The new code cannot read the old records, and drafts disappear or the app crashes on start. In the shop, the office changes the register's columns, and the old notebook pages no longer fit.
Large files on a slow network
A student downloads a large book on a weak mobile signal. The connection drops near the end, and the download starts again from zero. After a few tries they give up. In the shop, a big delivery arrives in pieces, and the driver insists on reloading the whole van each time one box falls off.
How to solve each one
These match the problems above, one to one.
Two devices edit the same thing: choose a conflict rule
There are three common rules.
- Last write wins. The newest change replaces the older one. It is simple and fine for things like a profile photo or a setting, where losing the older change does not hurt.
- Merge. Combine both changes. Two edits to different fields of the same record can both be kept. For lists and shared text, CRDTs (data types designed to merge without a central referee) can combine edits from many devices. They are powerful but add real complexity, so use them where people truly edit the same content together.
- Ask the user. Show both versions and let a person choose. This is right when a wrong guess is costly: a medical form, a financial record, a contract.
Pick the rule per type of data, not one rule for the whole app. A shopping list can merge. A stock count should go to the server, which knows the real number.
A retry creates a duplicate: idempotency keys
Give every change an ID on the device when it is created. Send that ID with every attempt. The server keeps a record of IDs it has already handled and returns the earlier result for a repeat. Retries then become safe, and the client can retry as often as it needs. In the shop, every notebook entry gets a number, and the clerk skips numbers already in the register.
async function handleChange(req: ChangeRequest, db: ServerDb) {
const key = req.headers["idempotency-key"];
if (!key) return { status: 400, body: { error: "missing idempotency key" } };
return db.transaction(async (tx) => {
const seen = await tx.idempotency.find(req.userId, key);
if (seen) return seen.response; // a repeat: send the first answer again
const response = await applyChange(tx, req.userId, req.body);
// (userId, key) is unique, so a repeat arriving at the same moment fails here and rolls back
await tx.idempotency.insert({ userId: req.userId, key, response });
return response;
});
}
A sketch of the server side of an idempotency key: store the key with the first result, and return that result on a repeat.
The phone runs out of storage: budgets and eviction
Decide how much the app may keep, and enforce it. A budget can be a total size, a number of items, an age, or a mix. When the budget is full, remove something by a clear rule. The most common is "least recently used": remove what the user has not opened for the longest time. Never remove anything still in the outbox. On a phone, keep re-downloadable files in the cache folder, which the system may clear when space is low, and keep the user's own unsynced work in a place the system will not touch.
The user sees stale data: show "last synced" and pending state
Do not hide the age of the data. Show "Last synced 2 hours ago" (an example) on screens where freshness matters. Mark items that have not reached the server yet. When the device is back online, refresh what is on screen and update the label. Honest staleness is better than a confident screen that is wrong.
Device clocks disagree: server time and version numbers
Do not trust device clocks to order events. Let the server stamp the time when it accepts a change. To detect conflicts, give each record a version number that the server increases on every save. The device sends "I am changing version 4" (an example). If the server is already on version 5, it knows the device missed a change, and the conflict rule runs.
type Update = { id: string; baseVersion: number; fields: Partial<Item> };
async function updateItem(tx: Tx, update: Update) {
const current = await tx.items.findForUpdate(update.id); // locks the row
if (!current) return { status: 404 };
if (update.baseVersion !== current.version) {
// the phone edited an older copy: send back the current one for the conflict rule
return { status: 409, body: { current } };
}
const saved = await tx.items.save({
...current,
...update.fields,
version: current.version + 1,
updatedAt: new Date(), // server time, not the phone's clock
});
return { status: 200, body: saved };
}
A sketch of a version check: a write made on an old version gets a conflict instead of being saved.
Something deleted comes back: tombstones
Do not delete a record outright on the server. Mark it as deleted and keep that marker, called a tombstone, with a version number. When an old device syncs the record back, the server sees the tombstone has a newer version and keeps the record deleted. Remove tombstones only after a long time, longer than any device is likely to stay offline.
The login token expires while offline: refresh first, then send
Before the sync engine sends anything, check the session. If the token has expired, try to refresh it. If refresh fails, keep every task in the outbox, ask the user to sign in, and send the queue after they do. A rejected login must never be treated as a reason to throw changes away.
An app update changes the local data shape: local migrations
Give the local database a version number. Each app release that changes the shape ships a migration: a small step that moves data from the old shape to the new one. Run migrations on start, before any screen reads data. Migrate the outbox too, or make the server accept older task formats for a while.
Large files on a slow network: chunks and resume
Download large files in parts, and remember how much has arrived. If the connection drops, continue from where it stopped instead of starting again. HTTP range requests do this for plain files. Write straight to disk, not into memory, so a large file does not crash a small phone. Show progress, and let the user choose when to download big files, for example only on Wi-Fi.
What else you need
UX states
Users should never have to guess. Four states cover most cases:
- Offline: a small banner saying the app is offline and changes will sync later.
- Pending: a quiet marker on items not yet sent.
- Failed: a clear message on items the server rejected, with a way to retry or fix them.
- Synced: the normal state, often shown by removing the pending marker.
Testing offline
You cannot trust offline code you have not broken on purpose.
- Turn on airplane mode, use the app, then turn it off and watch the sync.
- Use a slow network profile on the device or in the browser's developer tools.
- Kill the app in the middle of a sync, then open it again. Nothing should be lost or sent twice.
- Use two devices on the same account, change the same thing on both offline, then reconnect.
- Install the old version, create pending changes, then update to the new version.
Monitoring sync failures
Offline bugs happen on users' phones, not on your laptop. Log sync errors to your server when the device is back online: which task failed, why, and how many times it was tried. Watch how long tasks stay in the outbox. A growing queue means something is wrong long before users complain.
Security
The device now holds real data, so protect it. Encrypt sensitive local data, using the platform's secure storage for keys and tokens. On logout, wipe the local database, the outbox and any cached files, after warning the user if unsynced changes would be lost. Do not keep data on the device that the user does not need offline.
A checklist you can copy
- [] Screens read from local data, not straight from the network
- [] Every change is saved locally and added to the outbox in one transaction
- [] Every change has an idempotency key created on the device
- [] The server ignores repeated keys and returns the earlier result
- [] A conflict rule is chosen for each type of data
- [] The server sets timestamps and version numbers, not the device
- [] Deletes are tombstones, not hard deletes
- [] The session is refreshed before sync, and the outbox is kept if it fails
- [] The local database has versions and migrations
- [] Large files download in chunks and can resume
- [] There is a storage budget and a cleanup rule that never touches pending work
- [] The UI shows offline, pending, failed and synced states
- [] Tested in airplane mode, on a slow network, and with the app killed mid-sync
- [] Sync failures are logged and watched
- [] Sensitive local data is encrypted and wiped on logout
What I built
I worked on the textbook library inside a mobile learning app for students. Students open books on their phones, often cheap ones, often on weak mobile networks. A teammate built the first library screens. I took over the loading, the reader, the page sidebar and the cache, and I added bookmarks.
When I started, there was no single place that knew what state a book was in. Tapping a book twice could start two downloads of the same file. Books stayed on the phone for good, so storage only grew. And the reader's sidebar of page thumbnails stopped at page 15, so a student could not jump to a later chapter from it.
I moved all loading into one store, where each book is in exactly one phase at a time. A second tap while a book is loading is ignored, so there is one download. The file is written straight to disk with progress, and once it is on the phone it is reused, so a downloaded book opens with no signal. The sidebar now finds the real page count and reaches the last page.
For storage, the books live in the app's cache folder, which the phone can clear when space is low. On top of that, the app cleans up after itself in the background: books not opened for 30 days are removed, and the total size of the cache has a limit that follows how much free space the phone has, because a limit that suits a large phone would fill a small one. These are rules in the code, not measured results.
Bookmarks are saved on the phone only. They survive closing the app, but they do not follow the student to another phone. I did not build syncing for them. The first download of any book still needs the internet. I also have no usage numbers, so I will not claim how often students read offline.
On a separate language learning web app, I built the other half of the pattern: lesson progress is saved in a queue while offline and sent when the connection returns.
How to decide
Start with your users, not the architecture. If they work where the signal is weak and losing their input would hurt, build offline-first, and budget for the extra work. If they are almost always online, cache what they read and show a clear offline message. Whichever you pick, be honest on screen about what is saved and what is still waiting. Read next: how I built these systems in real projects, in my case studies.




Top comments (1)
The shopkeeper notebook analogy works well, because it makes the outbox idea concrete instead of abstract. When two devices edit the same record offline, do you prefer one conflict rule for the whole app, or a different rule per data type, for example last write wins for notes but a merge for stock counts? I am also curious how you handle a delete that arrives after an edit made on another device.