← All posts

Offline-first React: a write that survives until the server

How to read and write with no network: TanStack Query, Dexie, an operation queue, a client id, and an idempotency key. Not a cached screen — delivery of the change.

Offline-first React: a write that survives until the server
Contents

Someone taps Save in a basement, on a shop floor, or on a train. The network dies halfway. A normal React app either shows an error or pretends the write went through. Both are bad: the data was entered, it is not on the server, and after a reload it is not local either.

Offline-first is not if (!navigator.onLine). It is an architecture in which the local database is the reliable data layer and the network is the synchronization mechanism. The interface updates immediately, the change survives a closed tab, it reaches the server in the order it was made, and a retry does not create a second document.

Below is a TypeScript shape: TanStack Query as a reactive cache, Dexie on IndexedDB as the local source of truth, a queue of outgoing operations, and a server that recognizes a retry by key. The theme sits next to Nainik Mehta’s note on Dev.to; the text here is a production assembly of its own, including field work and business systems. Nearby on the client side: a React stack map, the Bulletproof React layout, and a move onto TanStack.

Key takeaways

Write locally first, deliver second. If the screen already shows a row that is not in IndexedDB, a reload will erase it.

A TanStack Query cache is not a queue. It restores the screen. You cannot store the mutation function in the database: after a process restart you still have the key, the variables, and the status, but not the code that sends them.

An entity and an operation need different ids. A client UUID lets you create an object before the server answers. A separate operation id keeps a retry from inserting a duplicate.

Order inside one entity is strict. Across entities you may go in parallel. An update must not overtake the create of the same object.

Reliable delivery and conflict resolution are different jobs. The queue guarantees that a request arrives. It does not decide whose edit wins when another device changed the same object.

How offline-first differs from “show the cache”

In an ordinary app the chain is short. React reads and writes through TanStack Query, Query calls HTTP, the server stores a row. While the network is up, that is honest: the button waits for a response, and only then is the interface sure. When the network is down, the operation simply does not run. The user sees a spinner, then an error, and the text they typed lives only in component memory.

An offline-capable app can show something with no network: the previous list, a placeholder, a saved screen. That helps reading. It is not enough for writing. An inspector, a warehouse clerk, or a clinician who filled a card and closed the laptop must find the same card when they open it again. The server must receive it later, once, in the right shape.

Offline-first reverses the arrow. The interface talks to the local database. The local database holds both the objects and the queue of what the server has not confirmed. When the network returns, the queue is replayed. The server stays authoritative for validation, access, and the final state, but it no longer has to be reachable in the second the person pressed the button.

React → TanStack Query → Dexie / IndexedDB
                              ├── objects
                              └── operation queue → API → server

Why a persisted query cache does not deliver a write

TanStack Query can survive a reload if you put the cache in durable storage. On startup the app reads IndexedDB, builds a QueryClient again, and React sees the lists and statuses that were on screen. A paused mutation can be saved too: key, variables, status, metadata.

What breaks is the function. A mutation is data plus a function. A typical handler looks like this:

mutationFn: async (data) => api.items.create(data)

You cannot serialize that function into IndexedDB. After the process restarts, the closure is dead. Storage still has the key and the arguments, and it does not have the code that knows how to send them. Until the app registers the function again, there is nothing to resume.

That is what queryClient.setMutationDefaults is for. A stable key such as ['items', 'create'] is paired with a function that is alive in this run. The restored mutation finds the function by key and continues.

Startup order is strict. If you rehydrate and immediately call resumePausedMutations before the functions are registered, the mutation cannot continue. Import the mutation rules first, then create the client, then hydrate, then resume.

The key must not be random. ['create-item', Math.random()] will not match any registered rule after a reload. The key describes the kind of operation, not a particular click.

Three layers, or the write is lost

Do not pick “cache or queue”. Stack three layers. Each one guarantees a different thing.

The first is a persistent query cache. After the tab opens, the person should see the latest lists instead of an empty screen while the network is quiet.

The second is rebuildable mutations. A TanStack Query pause plus a freshly registered function covers “the request was in flight, the tab reloaded, the function must come back”.

The third is a durable outbox. Every user write creates a local operation that must reach the server. The queue lives in IndexedDB, not in the memory of a mutation. It does not care whether the useMutation call that created it is still alive.

Retries inside one mutation are weaker. They live only while that call is running. Close the tab, kill the process, lose a late response — the retry may not survive. The queue survives reload, crash, a closed tab, a dropped network, and a temporary server error.

UI
 ↓
TanStack Query
 ↓
Dexie
 ├── data
 └── queue → sync worker → API → server

Why localStorage cannot hold the queue

localStorage is synchronous, small, and stores strings. A couple of settings fit. A list of requests, inspection photos, and an operation queue do not. Writes block the main thread, there is no index, “all unsynced operations for this entity, by time” is awkward, and there is no transaction that writes the object and the operation together.

IndexedDB is asynchronous, holds structured records and indexes, and is a normal home for a queue. Dexie is a thin typed wrapper: tables, schema versions, transactions. Not a second source of truth — a way to avoid the raw browser API on every screen.

A minimal schema for a small records app:

items
├── id            client UUID
├── title
├── updatedAt
└── syncStatus    synced | queued | syncing | failed | conflict

outbox
├── id            operation id
├── entityId
├── action        create | update | delete
├── mutationKey
├── variables
├── createdAt
├── attempts
└── status

Two tables on purpose. The object is what the person sees. The operation is what the server must receive. Folding them into one row loses the history “created, then edited, then deleted” and hides what has not arrived yet.

Where the truth sits after the click

Two temptations. First: TanStack Query is the source of truth, Dexie is only a snapshot of the cache. Second: Dexie is the local source of truth, TanStack Query is a reactive window onto it.

The first is enough for reading a cache. Writing needs the second. Otherwise the screen shows a row that is not in the database yet: the optimistic cache update landed, the transaction did not, the tab closed. On the next open the row is gone, and the person is sure the program ate their work.

The click should run in this order:

edit
  ↓
Dexie transaction: update the object and append the operation
  ↓
TanStack Query invalidates or subscribes
  ↓
UI

The server stays authoritative for rules: validation, access, idempotency, conflict detection. The local database is authoritative for what this device has already done and what is not confirmed yet. Mix those roles and you either block the button on the network or show a success that exists neither locally nor on the server.

How to persist the query cache

The packages this shape needs:

npm install @tanstack/react-query
npm install @tanstack/react-query-persist-client
npm install @tanstack/query-async-storage-persister
npm install dexie

The persister is a storage adapter with getItem, setItem, and removeItem. Point it at a Dexie table. It does not have to be localStorage. The provider wraps the app:

<PersistQueryClientProvider
  client={queryClient}
  persistOptions={{
    persister,
    maxAge: 1000 * 60 * 60 * 24,
  }}
>
  <App />
</PersistQueryClientProvider>

A day in maxAge is an example, not a rule. The link to cache garbage collection matters more. gcTime on the QueryClient must be at least as long as the persisted cache. Otherwise the persister honestly keeps the record, the client throws it away as stale right after hydration, and the screen is empty again.

Startup, looking at the whole app and not only the cache:

start
  ↓
read IndexedDB
  ↓
build QueryClient and mutation defaults
  ↓
hydrate the cache
  ↓
resume paused mutations
  ↓
replay the queue
  ↓
React

The cache restores reading. The queue restores write obligations. Both steps are required, and the second does not fall out of the first.

How a mutation comes back after reload

A query cache is data. A mutation is data plus a function reference. The reference dies with the process. So the key must be declarative: ['items', 'create'], ['items', 'update'], ['items', 'delete']. The variables carry the entity id and the body. The key carries only the kind of work.

Registration looks like this:

queryClient.setMutationDefaults(['items', 'create'], {
  mutationFn: async (variables) => api.items.create(variables),
})

Do not hide that registration inside a component that mounts “eventually”. It must run when the client is created, before hydration. Otherwise the race is stable: restore first, the module with the rules has not run, and the pause sits there with no function.

A restored mutation is still fragile if the request body lives only in TanStack Query memory. The Dexie queue duplicates the obligation on purpose. A mutation is a convenient way to hit the network when it is there. The queue is how you keep the intent when the mutation is already gone.

The operation queue

The outbox is the local list of what the server has not confirmed. For one entity it might be: create A, update A, delete B. While the network is down, clicks only append to that list and edit local objects. When the network returns, a worker reads the list and sends requests.

This is stronger than a retry inside a mutation, because a retry is tied to a live call. The queue is tied to a database row. It does not matter which component created it or whether the tab that pressed the button is still open.

A minimal replayer:

async function drainOutbox() {
  const pending = await db.outbox.orderBy('createdAt').toArray()
  for (const operation of pending) {
    await processOperation(operation)
  }
}

Success is HTTP 200 or 201: delete the operation and mark the local object synced. A temporary failure stays in the queue. Temporary means a dropped network, a timeout, 408, 429, 500, 502, 503, 504. Permanent means 400, 401, 403, and field-validation errors. 404 depends on the contract: for an update of a missing object it is often the end, for a delete it is sometimes already success. Do not copy a foreign list blindly. Classification follows your API.

Retrying 400 forever burns battery and fills the log. Retrying 503 every second looks like a homemade denial of service against your own server. Exponential delay — 1, 2, 4, 8, 16, 32 seconds — plus a random jitter, so a hundred tabs do not wake in the same millisecond. Delay is base times two to the power of the attempt, plus a random addition.

Operations on one entity go strictly by time. Create, then update, then delete. Send the update before the create and the server will correctly say the object does not exist. The queue then sticks or creates junk. Different entities may run in parallel: A has its own chain, B has its own. Do not parallelize the steps inside A.

A client id, and a retry that does not duplicate

Classic create waits for the server to issue an id. With no network that answer never comes, and the local row has nothing to hang on. The next edit, the delete, and the screen drift apart. Issue the id on the client: crypto.randomUUID(). The object, the operation, and the future request all point at it immediately.

That is not enough for a retry. Split two ids. entityId is which object. operationId is which attempt to change the world. One operation keeps one operationId across every retry.

Lost responses are ordinary. The client sent POST, the server created the row, the response never arrived. The client thinks the request did not arrive and sends it again. Without a key, the server has two objects. With Idempotency-Key: <operationId> the server recognizes the retry and does not apply a second side effect. It returns the previous result.

Even if two tabs send the same operation twice, the server is the last lock. Client locks reduce the race. They do not replace idempotency.

An atomic local write, and the create path

Create must do two steps together: insert the object and insert the operation. Write only the object, and the screen shows a row nobody will ever send. Write only the operation, and the queue tries to send something the screen does not have, or sends a body with no local row to attach a status to.

await db.transaction('rw', db.items, db.outbox, async () => {
  await db.items.add(item)
  await db.outbox.add(operation)
})

A Dexie transaction rolls both writes back if the second one fails. Only after that may the interface update. Not the other way around.

The full path:

click Create
  ↓
entity UUID and operation UUID
  ↓
transaction: object + operation
  ↓
the screen updates immediately
  ↓
network up? send with Idempotency-Key
       down? wait
  ↓
success → delete the operation, mark synced

Update and delete use the same corridor. Only the queue action and the HTTP method change: create is POST, edit is PUT or PATCH, delete is DELETE. Locally all three are visible at once. A special delete path that skips the queue opens the guarantee again.

Delete hurts more than create. Hard-erasing the local row before the server confirms leaves a hole if the send fails and you need to show the object again as “not deleted”. A soft delete — a deletedAt field — hides the row from the list and leaves a trail for the queue. Decide separately what happens if another device already changed that object: a delete does not cancel a newer foreign edit by itself.

What the person sees, and when to call the network

Without an explicit sync state, offline-first feels like a bug: the button is quiet, the row “seems saved”, and an hour later it is not on the server. Five states cover almost every conversation: synced, queued, syncing, failed, conflict. On screen that is synced, waiting to send, sending, error, conflict. The status component reads the object’s field, not a global “we are online” flag.

The online event is a reason to try the queue, not proof that the API is alive. navigator.onLine === true also happens on a network where your server does not answer. The handler must not mark everything synced. It starts a replay, and the API response decides success.

window.addEventListener('online', () => {
  void drainOutbox()
})

Try the same replay on startup, when the tab becomes visible, and, if the queue is long, on a timer with the same growing pause. Otherwise a laptop that never caught an online event will hold operations until someone refreshes by hand.

Background Sync through a service worker can push the queue after the tab is already closed. That is an acceleration, not the foundation. Browser support differs, and Safari on iOS is strict. The app must deliver operations the next time it opens, even if the background API is missing.

Two tabs, and someone else’s edit

Two tabs can read the queue at once and send one operation twice. On the client, take a lock: the Web Locks API, one leader tab, or a BroadcastChannel so the others do not start the same pass. On the server, use the idempotency key. The second is mandatory. The first is desirable. If the lock fails and there is no key, the duplicate is already in the database.

Conflict is a different axis. Two people edited one request, or this client sat offline on an old version while the server moved on. The queue cannot help: it will deliver your request. It does not know whether that request may overwrite someone else’s.

Practical strategies, and they are not one checkbox labeled “conflict resolution”:

Approach When it fits Cost
Last write wins Drafts, a low cost of overwrite A silent loss of the other edit
Version number or ETag / If-Match Business documents A 409, and the UI must react
Field merge Directories and independent fields Rules that do not fit every entity
Server-side review Regulated work, an audit trail Slower, and there is a log

Reliable delivery is not conflict resolution. If two devices edit the same thing, pick a strategy before you ship the queue, not “we will add it later”.

Field work, a warehouse, and the books

The same drawing is not only a tutorial task list. It belongs where a person cannot lose input because the link died: a warehouse, an equipment round, an inspection checklist, a mobile PWA, a CRM on the road, a shop floor with a dead zone. A local transaction, IndexedDB, a queue, a sync service, the business system’s API, PostgreSQL or another server database.

For large documents an outbox alone is not enough. You will want versions, an audit log, a server-side sequence, and optimistic locking. A photo and a signature are queue operations too, only the body is heavier: do not silently drop them when the browser runs out of room. Download directories ahead of time and keep them local for reading. Send out only the facts of the work: a request, an inspection result, a process mark.

A useful split of responsibility:

Layer It holds
Dexie Durable state, the queue, transactions
TanStack Query Reactivity, fetching, cache, mutation lifecycle
Sync worker Replay, retry, order, backoff
Server Validation, access, idempotency, conflict

This does not replace the business system, and it is not a reason to drag a whole ERP into the browser. It is a way not to lose a user’s action at the edge of the network. On the accounting side: a modern ERP platform and ERP integration. On a client that leaves the desk: React Native architecture.

How to test, and what to count

“I turned Wi-Fi off and it seemed fine” does not catch a lost response or two tabs. A minimum end-to-end script, workable in Playwright: open the app, disable the network, create an object, reload, confirm the object is still there, enable the network, wait for exactly one row on the server.

Then run the same corridor separately: close the tab, timeout, 500, 429, a repeat of the same key, broken order inside one entity, two tabs, a version conflict, a delete with no network. Until those cases are red in a test when they fail, the shape is not ready, even if the happy path is green.

Metrics here are observability of your queue, not a borrowed percentage. Watch the share of successful replays, the average and maximum age of an operation, retry count, the share of permanent failures, the share of conflicts, how many operations are pending, and whether the database is growing without bound. Counter names can be outbox.pending, outbox.failed, sync.duration, sync.retries, sync.conflicts. That is a list to install on your own system, not the result of someone else’s bench.

A file layout if you build the example from scratch:

src/
├── api/items.ts
├── db/database.ts
├── db/items.ts
├── db/outbox.ts
├── mutations/itemMutations.ts
├── sync/outboxWorker.ts
├── sync/retry.ts
├── sync/network.ts
├── query/client.ts
├── query/persister.ts
├── components/SyncStatus.tsx
└── App.tsx

Typical mistakes

Storing only the TanStack Query cache and assuming the write was saved too. Putting serious state in localStorage. Waiting for an id that only the server can issue. Skipping the idempotency key. Persisting mutations without setMutationDefaults. Calling resumePausedMutations before the functions are registered. Writing the object and the operation as two independent awaits with no transaction. Sending one entity’s chain in parallel. Retrying 400, 401, and 403 forever. Believing navigator.onLine means a live API. Hiding sync status. Forgetting the second tab.

Any one of those looks small in a demo and loses a document in the field.

FAQ

Is TanStack Query persistence enough?

No, if you need writes. Persistence restores the cache and can restore a paused mutation. It does not replace the send function or the obligation to deliver the change. Writes need a queue in IndexedDB.

Can I keep localStorage if the data is small?

For a short draft of one form, sometimes yes. As soon as you have a queue of several operations, an index by entity, or a transaction of “row plus operation”, localStorage gets in the way. Do not discover that threshold in production.

What if the server insists on issuing ids?

For offline-first the client still mints a UUID and sends it as the id or as an external key. Waiting for a server id before the next edit means the click depends on the network again.

Do I need a service worker?

Not as a required part. It can send the queue in the background. The app must do the same the next time it opens. Otherwise the scheme is silently dead on iOS.

How is delivery different from a conflict?

Delivery answers “did our request arrive?”. A conflict answers “may we apply it to what is already on the server?”. The queue solves the first. A version, an ETag, or merge rules solve the second.

How do I avoid a duplicate when the response is lost?

One operationId per operation and an Idempotency-Key header. The server remembers processed keys and, on a retry, returns the previous result without applying the effect twice.

Further reading

The client frame around this shape: React stack 2026, Bulletproof React, TanStack instead of a generated stack. Installing a web app without an extra step: the install element. The accounting edge the queue eventually calls: an ERP platform, ERP integration, React Native architecture.

The note this essay started from: Nainik Mehta on Dev.to.

Conclusion

A reliable app with no network is built around a guarantee that the local operation will be delivered, not around an “online” flag. The cache survives a screen restart. Mutation defaults bind the function to the key again. The queue survives the death of the tab. A client UUID names the object before the server answers. An idempotency key kills the duplicate. Ordering stops an update from overtaking a create. A conflict strategy decides what to do when the delivered request is no longer the only one.

Four questions for any such loop. Where is the data immediately after the edit? In the local database. How does it survive a restart? IndexedDB and the queue. How does it reach the server? A replay worker. What if the request was sent twice? An operation key and server-side deduplication. If the answer to any of those is “we will see when the network comes back”, it is not offline-first yet.

Comments

Loading comments…