An offline-first web app saves every change to the device first and treats the server as something to sync with later. To build one, store data in the browser's local database, cache the app's files with a service worker, record each change in a queue, send the queue when a connection returns, and decide in advance how conflicts are resolved.
Gyms are bad places for software. Basements, thick walls, and a phone held in a chalky hand between sets. When we built Reps, our training log for sets, meals, and bodyweight, the requirement was blunt: it has to work with no signal at all and never make anyone wait.
So Reps writes to the device first and syncs in the background. It never shows a spinner between sets. When you walk out of the building and the phone finds a connection, the data goes up without anyone asking.
That pattern is called offline-first. It is more work than a normal web app, and it is worth understanding before deciding whether your product needs it.
What is an offline-first web app?
An offline-first web app is a web application designed to work fully without a network connection. It reads and writes data on the device, shows results instantly, and synchronizes with a server whenever a connection happens to be available. The network is treated as an improvement, not a requirement.
A conventional web app does the opposite. Each tap sends a request to a server and waits for the reply before showing anything. That works on office wifi. On a weak connection, every action stalls, and with no connection the app is a blank page.
Offline-first reverses the order. The device is the first place data lives. The server is the place data ends up.
How does an offline-first app store data on the device?
An offline-first app stores its data in IndexedDB, a database built into every modern browser, and stores its own files, such as pages, scripts, and styles, in a cache managed by a service worker. Together those let the app open and work with no connection at all.
The two stores do different jobs:
- The service worker cache holds the app itself. A service worker is a script the browser runs in the background. It intercepts requests for the app's files and answers from the cache, so the app opens even in airplane mode.
- IndexedDB holds the user's data: in a training log, the workouts and entries. It stores structured records and can hold far more than the older
localStorage, which is small and blocks the page while it works.
Most teams use a small wrapper library over IndexedDB, because its raw interface is awkward. The choice of library matters less than the rule: every read and write goes to the local database first.
How does syncing work when the connection comes back?
Syncing works through a queue. Every change the user makes is saved locally and also added to an outbox of pending changes. When a connection is available, the app sends the outbox to the server in order, removes each change once the server confirms it, and pulls down anything new.
In outline, a write looks like this:
async function logSet(set) {
await db.put('sets', set); // save on the device
await db.add('outbox', { table: 'sets', op: 'upsert', row: set }); // queue the change
render(); // update the screen now
flushOutbox(); // try to sync, do not wait
}
Here db stands for whichever IndexedDB wrapper you use. The user sees the result at the third line, before any network request has been made.
Two details keep the queue honest. First, records get their IDs on the device, as random unique IDs, so nothing waits for a server to assign a number. Second, every queued change must be safe to send twice. Connections drop mid-request, the app retries, and the server must not create a duplicate.
What happens when two devices change the same record?
When two devices change the same record while apart, the app needs a conflict rule chosen in advance. The common choices are last write wins, merging field by field, keeping both as separate entries, or asking the user. The right rule depends on what the data means.
| Rule | How it works | Fits |
|---|---|---|
| Last write wins | The most recent change replaces the older one | Settings, profile fields, single-user data |
| Field-level merge | Changes to different fields are both kept | Records several people edit |
| Append only | Nothing is overwritten, every entry is kept | Logs, messages, journal entries |
| Ask the user | Show both versions and let a person choose | Documents where losing an edit is costly |
Pick the simplest rule that is safe. A personal log is mostly append only: a new set is a new row, and two phones adding rows do not collide. Last write wins is easy to build and fine for one person's settings, but it silently discards work when several people edit the same thing.
Whatever you choose, write it down before building the sync. Conflict rules added afterward tend to be discovered by users.
What are the steps to design an offline-first app?
Design an offline-first app in order: decide what must work offline, model the data on the device, cache the app shell, build the outbox, define the conflict rules, then show sync status in the interface. Skipping ahead to sync before settling the data model is the usual mistake.
- Decide what must work offline. Often it is the core action and not everything. Logging a set must work. Changing a billing plan can wait for a connection.
- Model the data locally. Design the on-device database as the main one, with device-generated IDs and a timestamp on every record.
- Cache the app shell. Use a service worker so the app's own files load without a network.
- Write local first. Every action saves to the device and updates the screen before any request is sent.
- Build the outbox. Queue each change, send in order, retry on failure, and make every operation safe to repeat.
- Set the conflict rules. One rule per kind of data, written down.
- Show sync state quietly. A small "saved on this device" or "synced" indicator builds trust without getting in the way.
- Test it offline. Use airplane mode, a connection that drops mid-sync, and two devices editing at once.
Installing the result to a phone's home screen is the last piece, covered in our PWA vs native app comparison.
Frequently asked questions
Is offline-first the same as caching?
No. Caching keeps copies of things already fetched so they load faster or load without a connection. Offline-first also lets the user create and change data while disconnected, then reconciles it with the server later. Caching is one part of an offline-first design.
Do all web apps need to be offline-first?
No, and most should not be. Offline-first adds a local database, a sync queue, and conflict handling, all of which take time to build and test. It earns its cost when people use the app in places with poor signal, or when any waiting ruins the experience.
Can users sign in while offline?
Not the first time, since the server must verify who they are. After that, the app can keep the session on the device and let the user keep working offline. Anything queued is sent under their account once the connection and session are confirmed again.
What happens if a user clears their browser data?
Anything not yet synced is lost, because the device held the only copy. This is why an offline-first app should sync whenever it can and show clearly when changes are still waiting. Browsers may also clear storage on their own when a device is low on space.
Does offline-first make an app faster when online?
Yes. Reads and writes hit the device, so the interface responds at once on any connection, fast or slow. Some teams choose the pattern for that speed alone.
If your users work in basements, vehicles, job sites, or anywhere else signal is poor, tell us where they are and what they need to record. For more on choosing where the server-side data lives, see how to choose a database.