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.