Designing Offline-First Frontends as Replicas: Queues, Retries, Conflicts
Learn why offline-first turns the browser into a data replica, and how local stores, durable mutation queues, idempotent retries and conflict rules fit together.
This article is published in English.
A frontend that behaves well on a fast, stable connection proves very little. The interesting cases start when a request hangs, Wi-Fi drops, a train enters a tunnel, a laptop hops between networks, or a phone loses signal a moment after someone taps Save. Conventional frontend architecture treats all of these as exceptions: call the API, wait, update state, continue.
Offline-first design begins from the opposite premise: the network is allowed to fail, and the application must remain useful anyway. That sounds like a UX refinement, but it is a structural change. As soon as the browser can read data, accept edits, persist them and synchronize later, it stops being a thin client and becomes one node of a distributed system. This guide walks through what that means in practice: where data should live, how writes are queued and retried safely, how the UI should communicate sync status, and why conflict handling is ultimately a product decision.
Taking the network out of the critical path
Picture a simple notes app. Someone opens a note, edits the title and saves. In a traditional design the flow looks like this:
User → React → Server
↓
Data
The server sits directly inside the interaction. A slow server means a slow UI; a lost connection means saving stops; a failed request leaves the app deciding, on the spot, what to do with the user's changes.
An offline-first design moves a meaningful share of that responsibility into the browser:
User → React → Local Store
↓
Sync Later
The server does not become unimportant. For most systems it is still where shared truth lives. What changes is that it no longer needs to be reachable for every single interaction. That separation, between acknowledging the user's action and propagating it to the backend, is the foundation everything else rests on.
The browser now holds state that matters
If the app has to keep working without connectivity, it needs durable storage on the device. For anything beyond small preferences, that usually means IndexedDB or a higher-level database layered on top of it. The specific library matters less than the consequence: the browser now owns state you cannot afford to lose.
Think of cached records and unsent drafts, mutations waiting to go out, sync metadata such as timestamps and version numbers, and bookkeeping about what has already been uploaded. With it comes a question that a plain request-response app never has to answer: what should happen when the browser's copy and the server's copy disagree? In an offline-first app, that is not an edge case; it is routine.
Caching reads is not the same as being offline-first
This distinction is easy to blur. An app can cache API responses and still depend completely on the network. Suppose it fetches a list of projects and stores the result locally, so the list reappears without a connection. That is helpful, but it only covers reads.
The design becomes genuinely offline-first when users can change data while disconnected: creating a project, editing a document, ticking off a task, adjusting a setting. Those actions cannot simply vanish because an HTTP request did not go out. The app needs a place to record what the user intended to do, so that the intent survives until it can be delivered.
Every offline write becomes a sync obligation
When someone edits a document offline, the usual approach is to apply the change to the local store first, then record the operation that still has to reach the server. In simplified form:
User Saves
↓
Save Locally
↓
Update UI
↓
Add To Queue
↓
Sync When Online
Note the order: the local write and the UI update happen before anything is queued, which is why the interface can respond immediately. The queue at the end deserves more attention than it usually gets. Rather than a pile of requests that errored, it is a durable log of work the user expects the system to finish.
That has concrete requirements. The queue must survive page reloads, browser restarts, transient server errors and network changes, which means it belongs in persistent storage rather than in a JavaScript array. It must also behave correctly when one operation gets delivered twice or more. That second requirement leads to one of the trickiest failure modes in the whole design.
When success looks exactly like failure
Consider this sequence. The user saves. The request arrives. The server writes the data successfully. Then, before the response makes it back, the connection drops. The client concludes the save failed; the server knows it succeeded.
Client
|
| Save
v
Server
|
| Saved
X
Response Lost
|
v
Client Retries
If the client simply retries, the server may apply the same change twice. For an idempotent "set title" operation that might be harmless, but for "add item", "create order" or "increment counter" it produces duplicates.
This is why idempotency is central to offline-first systems. Each mutation needs a stable identity so the server can tell that a retry is the same logical operation, not a new one. A common approach is to generate an operation ID on the client when the change is first recorded, store it with the queued mutation, and send it on every attempt. The server keeps track of which IDs it has already processed and, on seeing a repeat, acknowledges it without applying it again.
This is not a performance tweak. It is what stops an unreliable network from turning ordinary retries into duplicated work. A practical detail worth getting right: the ID must be created once and persisted with the queue entry, not regenerated per request, otherwise every retry looks new again.
Optimistic UI as a statement of intent
Optimistic updates are usually pitched as a way to make apps feel faster. In an offline-first app they carry more weight. If a user completes a task while offline and the interface waits for a server response before reflecting it, the app simply appears broken. The local change should normally show up at once, and synchronization can bring the remote side up to date later.
That yields a helpful split in responsibilities:
- Local state reflects what the user has done.
- Sync status reflects whether the shared system has caught up.
The two are related but not identical, and treating them separately lets the UI communicate more honestly than a single online/offline indicator. For example, an item can be marked as:
- saved on this device
- queued for sync
- syncing now
- synced
- needing the user's attention
A user who sees "queued for sync" understands that the work is safe but not yet shared, which is far more reassuring than an ambiguous spinner or an error toast.
Let the local store own data, and React render it
A common source of accidental complexity looks like this: data is stored in IndexedDB, loaded into React state, copied again into a separate client-side store, and then everything is manually kept in sync. The app now holds several representations of the same records, which invites stale values, race conditions, needless re-renders and synchronization bugs that exist entirely inside the frontend.
A cleaner model gives ownership of persistent data to the durable local store, with React acting as a view over it:
Local Store
↓
React UI
↓
User
↓
Local Store
The loop is the point: the UI reads from the local store, the user acts, and the action writes back to the local store, which then drives the UI again. React keeps its job of handling interaction and presentation; the database is responsible for client-side state that must persist. In practice this usually means subscribing components to queries against the local store rather than copying results into component state. The benefit grows with the size of the app, because there is exactly one place to look when a value seems wrong.
When two devices are both right
Conflict resolution is the point where offline-first stops being a storage question and becomes a distributed-systems one. Suppose someone opens the same document on a laptop and a phone, and both go offline. On the laptop the title is changed to Frontend Architecture; on the phone, to Offline Architecture. Each device accepted a legitimate edit, and neither knows about the other. When both reconnect, they collide:
Laptop Phone
| |
v v
"Frontend App" "Offline App"
| |
+--------+----------+
|
v
Conflict
There is no universally correct resolution. Should the latest change win? Should the server's version take precedence? Can the fields be merged? Should the user choose? Should different fields follow different policies?
The key insight is that conflict resolution is a product decision that looks like a technical one. Last-write-wins can be entirely acceptable for, say, a UI preference. For collaborative documents, financial data, inventory counts or important business records it may be unacceptable, because silently discarding one side loses real work or produces incorrect totals. The architecture has to encode what "correct" means for each kind of data, which is why product owners need to be part of this conversation rather than leaving it to whoever writes the sync code. Also be aware that "latest" is itself slippery: device clocks drift, so policies based on client timestamps can pick the wrong winner unless the ordering comes from something more reliable, such as server-assigned versions.
Remote changes need the same entry point
Offline-first does not mean syncing only when the app opens. Other users and other devices modify the same data, and the server keeps receiving changes from elsewhere. A real-time channel therefore adds a second direction of flow:
Local Changes
↓
Sync
↓
Server
↓
New Changes
↓
Local Store
↓
UI
This leads to an important rule: changes arriving from the server should end up in the same local data model the UI already reads. If local edits take one path and remote updates take another, React components end up with complicated logic trying to reconcile the two. Making the local store the single meeting point keeps rendering simple: the UI does not care whether a change originated on this device or on another one.
Assume the browser itself will fail
There is another failure mode that is easy to overlook: the browser may go away before the network comes back. The tab is closed, the browser crashes, the operating system kills the process, the laptop battery dies, or the process disappears in the middle of a sync. Any pending work that exists only in memory is lost with it.
That is why synchronization state must be durable. A serious offline-first app should treat the browser as an unreliable node, and its local database must hold enough information to reconstruct outstanding work when the app starts again: which operations are pending, which were in flight, and what the last known server state was. A useful test is to kill the tab in the middle of a sync during development and confirm that nothing is lost or duplicated on the next launch.
Service workers are one piece, not the architecture
Service workers are valuable, particularly for caching application assets and intercepting network requests; if you need a refresher on the mechanics, see enabling offline support with service workers. But registering one does not make an app offline-first:
- A registered worker does nothing to prevent losing a user's unsaved edits.
- Cached bundles let the app boot offline, yet say nothing about where offline writes go.
- Serving stale API responses from cache is not a conflict policy.
Offline-first is a property of the architecture, not a browser feature you switch on. What actually makes it work is a local data layer, mutations that persist, a sync process, explicit failure handling, a policy for conflicts, and a UI that accepts that remote sync may take a while.
Complexity moves; it does not disappear
The uncomfortable reality is that offline-first software is not simpler. The complexity is relocated. A conventional app can get away with sending a request and awaiting the reply. An offline-first one has to work through a chain of harder questions:
- What if the server cannot be reached?
- What if the browser closes before sync finishes?
- What if the request arrived but the response did not?
- What if the same record changes on another device?
- What if both changes are valid?
- Which outcome counts as correct for this product?
Those are classic distributed-systems problems, and having a browser as one of the participants does not make them any easier. Before committing to offline-first, it is worth asking whether the product really needs offline writes. For a dashboard that users only read, cached reads plus a clear offline message may deliver most of the value at a fraction of the cost.
Local data deserves server-grade security thinking
Data stored on a device is still data, with all the responsibilities that implies. If sensitive information lives in the browser, you must consider what happens if the device is compromised, if several people share it, or if one account logs out and another person picks it up. Local persistence should follow the same security reasoning as backend storage:
- Store only what the client genuinely needs.
- Define expiration rules and what happens on logout.
- Do not treat browser storage as a secure vault.
- Decide deliberately which records may exist on the device at all.
Offline-first improves availability, but that gain should never come at the price of exposing information that should not have been stored locally in the first place.
Designing around failure instead of the happy path
A striking feature of offline-first work is that the architecture becomes clearer once you stop designing for the happy path. The questions shift:
- Instead of "how do I fetch this data?", ask where the data should live when the network is gone.
- Instead of "how do I retry this request?", ask how the client can know whether the server has already applied it.
- Instead of wondering how to push data into React state, ask which part of the system actually owns the data.
- Instead of treating clashing requests as a bug to patch, ask what correctness means for this product.
Answering the second version of each question consistently produces a sturdier design.
The goal is an experience users barely notice
Users have no interest in sync engines, database transactions, backoff policies, merge algorithms or replication. They want to keep working. They expect a draft to still be there after the connection drops, a task completed on a train to stay completed, and an app that does not become useless the moment the Wi-Fi icon changes. The machinery underneath can be intricate; the experience on top should not be.
Key takeaways
The single idea that ties the design together is this: an offline-first frontend is a distributed system in which the browser hosts a second replica of the data. Seen through that lens, the individual pieces take on clearer roles:
- IndexedDB is not just browser storage; it is part of the application's data layer.
- The mutation queue is not a retry helper; it is a durable record of user intent, and each entry needs a stable ID for idempotent retries.
- Optimistic UI is not a speed trick; it keeps the product usable during the time the backend needs to converge.
- Conflict handling is not an edge case; it is part of how the product defines correctness.
- Synchronization is not something to bolt on at the end; it is the architecture.
Networks fail, servers lag, devices briefly disagree and browsers vanish without warning. A well-built offline-first frontend makes sure the user's work survives all of that, keeping its promise even when the connection cannot.