DEV Community

Nainik Mehta
Nainik Mehta

Posted on

Offline-First React: TanStack Query + IndexedDB Patterns

Introduction

“I added an item in aisle 5, closed the app, and it synced automatically on the bus ride home.” That one sentence describes the behavior users notice and remember: writes that survive crashes, restarts, and flaky networks.

This article shows a practical, production-tested 3-step recipe to get durable, replayable optimistic writes using TanStack Query (React Query), Dexie (IndexedDB), and a small outbox. The pattern centers on treating TanStack Query as a reactive cache over your local DB and making writes durable before they leave the device.

Primary keyword: tanstack query offline persistence

The big idea in three pieces

1) Persist the reactive cache to IndexedDB (Dexie)
2) Register mutation defaults so paused mutations can be rebuilt after a restart
3) Use client-generated UUIDs + an outbox replay queue for idempotency and ordering

We'll walk each piece and end with concrete code showing how they fit together.

1) Persist the reactive cache to IndexedDB

Treat TanStack Query as a reactive cache that mirrors your local DB. Persisting the query client (queries + paused mutations) to durable storage lets your app restore a consistent local view on launch and replay paused work.

Use the PersistQueryClientProvider or persistQueryClient utilities from @tanstack/react-query-persist-client and an async persister backed by IndexedDB/Dexie (or idb-keyval). Important options:

  • Provide a persister that implements getItem/setItem/removeItem (or use the provided helpers).
  • Set gcTime on your QueryClient to at least the persister maxAge.
  • Use shouldDehydrateMutation to only persist paused mutations you know how to resume.

Example wiring (conceptual):

// app/query-persist.ts
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'
// or implement AsyncStorage using Dexie get/set/remove

const persister = createAsyncStoragePersister({ storage: myDexieStorage })

// In your App root
<PersistQueryClientProvider
  client={queryClient}
  persistOptions={{ persister, maxAge: 1000*60*60*24 }}
  onSuccess={() => queryClient.resumePausedMutations()}
>
  <App />
</PersistQueryClientProvider>
Enter fullscreen mode Exit fullscreen mode

Why IndexedDB? It stores larger payloads, runs off the main thread in well-implemented adapters, and survives quotas typical web storage can't. Dexie is an excellent high-level IndexedDB wrapper and makes outbox tables and reads simple.

2) Register mutation defaults so paused mutations can be rebuilt

A paused mutation persists variables and metadata, but not the closure that implements mutationFn. When your app restarts, TanStack Query attempts to resume paused mutations by looking up a default mutationFn registered via queryClient.setMutationDefaults(mutationKey, { mutationFn, ... }).

Best practices:

  • Use stable, declarative mutation keys (arrays or strings) — e.g. ["items", "create"].
  • Register defaults at module load so they exist before persisted mutations are resumed. If you call resumePausedMutations before the defaults are registered, rehydrated pauses become unreplayable zombies.
  • Persist only mutations you know how to rebuild (use shouldDehydrateMutation to filter).

Example:

// lib/item-mutations.ts — side-effect import at app boot
export const MK = { createItem: ['items', 'create'] as const }

queryClient.setMutationDefaults(MK.createItem, {
  mutationFn: async (vars: { id: string; title: string}) =>
    api.items.create(vars),
  onMutate: ({ id, title }) => {
    // optimistic update applied to local DB/query cache
  },
});
Enter fullscreen mode Exit fullscreen mode

Ensure this module is imported before PersistQueryClientProvider calls resumePausedMutations.

3) Use client-generated UUIDs + a Dexie outbox replay queue

Idempotency and ordering are the two hardest problems in offline writes. A simple and reliable approach:

  • In onMutate stamp a client-generated UUID (crypto.randomUUID() or uuidv4) into the new item.
  • Persist the optimistic change into your Dexie tables immediately (this makes your local DB the single source of truth).
  • Enqueue a durable outbox record in Dexie that contains: id (uuid), action (create/update/delete), variables, mutationKey, metadata, createdAt.
  • Surface a visible sync state in the UI (queued / syncing / failed).
  • On reconnect or app launch, replay outbox entries in order (or per-key sequentially), call the server, and remove succeeded items from the outbox.

Minimal Dexie sketch + mutation flow:

import Dexie from 'dexie'
import { v4 as uuidv4 } from 'uuid'

class AppDB extends Dexie {
  items: Dexie.Table<any, string>
  outbox: Dexie.Table<any, string>
  constructor() {
    super('appDB')
    this.version(1).stores({ items: 'id, listId, title', outbox: 'id, createdAt' })
  }
}
const db = new AppDB()

// user action: add item
const id = uuidv4()
await db.items.add({ id, title })
await db.outbox.add({ id, action: 'create', mutationKey: ['items','create'], variables: { id, title }, createdAt: Date.now() })
queryClient.invalidateQueries(['items'])
Enter fullscreen mode Exit fullscreen mode

Outbox replay worker (run at launch, on online, or via Background Sync if available):

async function drainOutbox() {
  const pending = await db.outbox.toArray()
  for (const op of pending) {
    try {
      // Either call queryClient.mutate with defaults already registered
      await apiCallFor(op) // send idempotency header: Idempotency-Key: op.id
      await db.outbox.delete(op.id)
    } catch (err) {
      // handle retry/backoff, or classify as non-retriable and surface error
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Server-side: honor Idempotency-Key or sequence numbers so a request replayed twice won't create duplicates.

Practical tips and traps

  • Use Dexie as the single source of truth: writes go to Dexie first; TanStack Query reacts to DB changes. This keeps the UI consistent across reloads.
  • Persist only resumable paused mutations. Persisting everything without defaults or handlers resurrects non-actionable zombies.
  • Ordering: give related mutations a shared scope/id or process outbox in-order for the same entity so a create arrives before its updates.
  • Background sync: Chrome supports the Background Sync API and service workers, but iOS Safari does not. Trigger replay on online/visibility and at app launch for broad coverage.
  • Retry/backoff and non-retriable errors: implement exponential backoff with jitter for transient failures and remove failing transactions classified as permanent (validation errors, 4xx).
  • Leader election: if you support multi-tab, use a single-leader approach so only one tab processes the outbox (BroadcastChannel/Web Locks). Dexie + BroadcastChannel are common.

Why this works

The pattern combines three durable guarantees:

  • Persistence: the optimistic state and outbox survive restarts because they’re in IndexedDB (Dexie).
  • Rebuildability: register mutation defaults so persisted paused mutations can be rehydrated and replayed.
  • Idempotency: client UUIDs + server dedupe prevent duplicate-side effects during retries.

Taken together, flaky networks become a feature: users keep working, see visible sync state, and their writes land eventually — even if the app was backgrounded, killed, or the network bounces.

Conclusion

If you build offline-capable UIs, aim for writes that survive app death. Persist TanStack Query’s cache (queries + paused mutations) to IndexedDB, register stable mutation defaults, and use a durable Dexie outbox with client-generated UUIDs. That combination creates reliable, observable sync: the item is visible instantly, shows a syncing state, and gets delivered when connectivity allows.

How have you handled replay and idempotency in your apps? What edge case gave you the most grief?

Top comments (0)