Skip to content

Build1 publisher2 min readPublished

Persisting a paused mutation saves its variables and leaves the mutationFn behind

A dev.to walkthrough pairs TanStack Query with Dexie and shows why a restart cannot resume a paused write unless the mutation function was registered again before replay, plus what the outbox row has to store.

The Engineer · Build desk

What happened

  • A dev.to walkthrough reduces durable offline writes in React to three steps: persist the reactive cache to IndexedDB, register mutation defaults for restart, and replay a UUID-stamped outbox.
  • Persisting the query client stores both queries and paused mutations, so the app can restore a consistent local view on launch and replay work that never left the device.
  • Step one treats TanStack Query as a reactive cache over the local database, wired through the react-query-persist-client package with an async persister backed by Dexie or idb-keyval.
  • The recipe also asks for a visible per-write sync state in the interface, showing queued, syncing or failed.
  • IndexedDB is chosen because it holds larger payloads, runs off the main thread in well-implemented adapters, and survives quotas that typical web storage cannot.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • constraint Persistence and garbage collection become coupled. The persister's maxAge sets a floor under gcTime, so cache retention is decided by how long you want the offline window to be, not by memory pressure.
  • decision shouldDehydrateMutation is where a team decides which writes it is willing to owe a boot-time registration to. Every persisted mutation type needs a default that can still accept yesterday's variables.
  • exposure Import order becomes a data-loss path. Resume before the defaults module has run and the queued write is stuck, after the interface has already told the user it landed.
  • capability Because the id exists before the request does, a retry after an ambiguous network failure sends the same row again instead of creating a second one on the client.

Functions do not serialize. When the persister writes a paused mutation to IndexedDB, the variables and the metadata land there. The closure implementing mutationFn does not [11]. On the next launch, TanStack Query rehydrates the pause and tries to resume it. The only code it can find is whatever was registered against that mutation key through queryClient.setMutationDefaults [12]. So keys have to be stable and declarative, arrays or strings like ["items", "create"] [13].

Module import order is now on the correctness path. The sample wiring calls queryClient.resumePausedMutations() from the onSuccess callback of PersistQueryClientProvider [8]. The module that registers the defaults has to be imported at boot, before that callback fires [15]. "If you call resumePausedMutations before the defaults are registered, rehydrated pauses become unreplayable zombies," the article says [14]. A side-effect-only import is easy to lose in a refactor.

Two settings have to agree. The sample persistOptions set maxAge to 1000*60*60*24 [8]. That is 86,400,000 milliseconds, or 24 hours [9]. gcTime on the QueryClient should be at least the persister maxAge, the article says [6], so the example commits the app to holding cache entries for at least 86,400,000 ms [10]. On the other side, shouldDehydrateMutation filters what gets written out: persist only the mutations you know how to rebuild [7]. The persister itself is anything with getItem, setItem and removeItem [5].

The outbox does not depend on TanStack Query. onMutate stamps a client-generated UUID into the new item, from crypto.randomUUID() or uuidv4 [16]. The optimistic change goes straight into the Dexie tables, which the article treats as the single source of truth [17]. A durable outbox row records id, action, variables, mutationKey, metadata and createdAt [18]. The sketch indexes that table on id and createdAt [20]. Replay runs on reconnect or app launch, in order or per key sequentially, and rows are removed as they succeed [19].

"Idempotency and ordering are the two hardest problems in offline writes," the article says [23]. It does not describe what the server does when the same client UUID arrives twice.

The article opens on an unattributed user sentence: "I added an item in aisle 5, closed the app, and it synced automatically on the bus ride home" [24]. It calls the recipe practical and production-tested [25]. For the pattern to transfer, the client has to be allowed to own the primary key. The items table in the sketch is keyed on id [20], and that id is the UUID minted in onMutate [16]. Where the server issues ids, the outbox row is not idempotent by itself and needs a request key the server honours.

What to watch

  • The published listing stops inside the Dexie class constructor; a completed replay loop would show whether ordering comes from createdAt or from a per-key queue.
  • If browsers tighten IndexedDB eviction under storage pressure, the quota headroom that justifies step one gets weaker.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories