Syncing Offline Mutations on Reconnect
When the network drops, a mutation should not fail outright — it should wait. TanStack Query v5 models this with paused mutations: a mutation fired while offline is queued in the mutation cache rather than rejected, then replayed the moment connectivity returns. Done wrong, this produces lost writes on reload, duplicate submits, or replays that arrive out of order and violate server-side invariants. This page is a concrete recipe within Mutation Sync & Rollback, and it covers networkMode, mutation persistence, and resumePausedMutations end to end. Where a mutation genuinely rejects rather than pausing, the sibling pattern in Rolling Back Optimistic Updates on Error restores the pre-mutation snapshot instead of queueing.
The root cause of most offline-sync bugs is treating the mutation as ephemeral. A paused mutation only lives in memory, so a page reload discards the queue unless you persist the mutation cache — a concern shared with Offline Cache Persistence for query data.
Diagnostic Checklist
Before writing any code, confirm your symptoms match this failure mode:
- A mutation fired offline rejects immediately instead of waiting, because
networkModeis left at a value that fails fast without connectivity. - Mutations queued offline vanish after a page reload, because the mutation cache was never persisted.
- On reconnect, several writes hit the server simultaneously and a dependent write lands before its prerequisite.
- The same write reaches the server twice after a flaky reconnect, because the client replayed a mutation the server had already accepted.
resumePausedMutationsis never called, so paused mutations sit in the cache indefinitely after the browser comes back online.
Step-by-Step Implementation
Step 1 — Keep offline mutations paused with networkMode
TanStack Query’s networkMode decides what a mutation does with no connection. The default 'online' pauses the mutation (it enters isPaused state) instead of rejecting, which is exactly what you want for offline sync. Make the intent explicit at the mutation level.
// hooks/use-add-todo.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface Todo {
id: string;
text: string;
done: boolean;
}
export function useAddTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationKey: ['todos', 'add'],
// 'online' (default) pauses when offline instead of failing fast
networkMode: 'online',
async onMutate(text: string) {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previous = queryClient.getQueryData<Todo[]>(['todos']);
queryClient.setQueryData<Todo[]>(['todos'], (old = []) => [
...old,
{ id: `__optimistic__${Date.now()}`, text, done: false },
]);
return { previous };
},
});
}
Cache Behavior Analysis. With networkMode: 'online', calling mutate while offline runs onMutate immediately — so the optimistic write still lands — but defers the actual mutationFn execution, marking the mutation isPaused. The mutation sits in the mutation cache carrying its variables, waiting for an online transition rather than throwing. Setting networkMode: 'always' would instead run the mutationFn immediately and let the fetch itself reject, which is wrong for offline queueing.
Step 2 — Register mutation defaults with a global mutationFn
A paused mutation stores its variables, but after a reload the closure that held its mutationFn is gone. queryClient.setMutationDefaults binds a mutationFn to a mutationKey so a rehydrated mutation knows how to execute.
// query-client.ts
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: { mutations: { networkMode: 'online' } },
});
async function postTodo(text: string): Promise<Todo> {
const res = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
if (!res.ok) throw new Error('Create failed');
return res.json();
}
// Bind the mutationFn to the key so a persisted+rehydrated mutation can replay
queryClient.setMutationDefaults(['todos', 'add'], {
mutationFn: (text: string) => postTodo(text),
});
Cache Behavior Analysis. setMutationDefaults is keyed by mutationKey, so when the persister rehydrates a paused mutation TanStack Query looks up the default mutationFn by that key and reattaches it. Without this, a rehydrated mutation resumes with no mutationFn and throws. Component-level useMutation hooks can still override these defaults; the defaults exist purely so replay works without the original React tree being mounted.
Step 3 — Persist the mutation cache so the queue survives reload
Wrap the app in PersistQueryClientProvider with a storage persister. This serializes both the query cache and the mutation cache, so a mutation queued offline is not lost if the user reloads or the tab is killed before reconnect.
// app-providers.tsx
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
import { queryClient } from './query-client';
const persister = createSyncStoragePersister({ storage: window.localStorage });
export function AppProviders({ children }: { children: React.ReactNode }) {
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister }}
onSuccess={() => {
// After rehydration completes, replay anything queued while offline
queryClient.resumePausedMutations();
}}
>
{children}
</PersistQueryClientProvider>
);
}
Cache Behavior Analysis. PersistQueryClientProvider dehydrates the mutation cache on every change and rehydrates it on mount before rendering children. The onSuccess callback fires after rehydration, which is the correct moment to call resumePausedMutations — the paused mutations from the previous session now exist in the cache again and can be replayed. For large payloads, swap the sync localStorage persister for an async IndexedDB persister to avoid blocking the main thread.
Step 4 — Resume paused mutations on reconnect in order
TanStack Query automatically resumes paused mutations when the browser fires an online event, but you also call resumePausedMutations explicitly after rehydration. To guarantee ordering, give related mutations a shared scope.id so they replay serially.
useMutation({
mutationKey: ['todos', 'add'],
// Same scope id → mutations run one at a time in insertion order
scope: { id: 'todos-write-queue' },
networkMode: 'online',
mutationFn: (text: string) => postTodo(text),
});
Cache Behavior Analysis. resumePausedMutations returns a promise that replays every paused mutation. Mutations sharing a scope.id form a serial queue: the mutation cache runs the next one only after the previous promise settles, so a PATCH that depends on a prior POST cannot overtake it. Without a shared scope, resumed mutations fire in parallel and a dependent write may reach the server before its prerequisite, corrupting server state even though every individual request succeeded.
- Keep
networkMode: 'online'for write queues; use'always'only for mutations that must attempt even without connectivity. - Persist the mutation cache with a
persistersoresumePausedMutationshas something to replay after reload — a memory-only cache silently drops the queue. - Assign a shared
scope.idto ordering-sensitive mutations; independent mutations can omit it to replay in parallel and drain faster. - Register
setMutationDefaultsfor every persistedmutationKey, or rehydrated mutations resume without amutationFnand throw.
Edge Cases and Gotchas
Replay ordering across unrelated keys
resumePausedMutations preserves insertion order per scope, not globally. Two mutations with different scope.id values replay concurrently, so if a deleteTodo and an addTag on the same entity were queued in a meaningful order, they will not honor it unless they share a scope. Group everything that must be sequential under one scope id.
Duplicate submits after a flaky reconnect
If a mutation’s request times out but the server actually processed it, the client may replay it and produce a duplicate. Attach an idempotency key generated in onMutate and send it as a header so the server can deduplicate:
onMutate() {
const idempotencyKey = crypto.randomUUID();
return { idempotencyKey };
}
Send context.idempotencyKey as an Idempotency-Key header from the mutationFn; the server returns the original result for a repeated key instead of creating a second record.
Persisting a mutation cache mid-flight
If the tab closes while a mutation is running (not paused), it is persisted in a pending state. On rehydration it is treated as paused and resumed — which is usually correct — but combined with a non-idempotent server this is another duplicate-submit vector. Pair persistence with server-side idempotency rather than relying on client state alone.
Common Pitfalls and Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| Mutation fired offline rejects immediately | networkMode set to 'always', so the mutationFn runs and the fetch fails without connectivity |
Use networkMode: 'online' (the default) so the mutation pauses into the cache instead of rejecting |
| Queued mutations disappear after a page reload | The mutation cache lives only in memory; no persister is configured | Wrap the app in PersistQueryClientProvider with a storage persister and call resumePausedMutations in its onSuccess |
| Dependent writes land out of order on reconnect | Resumed mutations run in parallel because they do not share a scope.id |
Assign one scope.id to all ordering-sensitive mutations so the cache replays them serially |
| Same write reaches the server twice after reconnect | A timed-out request was replayed even though the server had processed it | Generate an idempotency key in onMutate, send it as a header, and dedupe server-side |
Frequently Asked Questions
Why do my offline mutations replay out of order on reconnect?
resumePausedMutations replays paused mutations in the order they were added, but only when each one awaits the previous. If your mutations do not share a scope.id, TanStack Query resumes them in parallel, so a dependent write (a PATCH) can reach the server before the write it depends on (its POST). Give every mutation that must stay sequential a single shared scope.id — the mutation cache then treats them as a serial queue and runs the next only after the previous promise settles.
How do I stop a reload from losing mutations queued while offline?
A paused mutation lives only in the in-memory mutation cache unless you persist it. Wrap the app in PersistQueryClientProvider (or call persistQueryClient) with a storage persister so the mutation cache is dehydrated to localStorage or IndexedDB on every change and rehydrated on mount. You must also register setMutationDefaults for each mutationKey so the rehydrated mutation knows which mutationFn to run — the original closure is gone after reload. See Offline Cache Persistence for the query-data side of the same problem.
How do I prevent duplicate submits when a paused mutation resumes?
Generate an idempotency key in onMutate and send it as a request header so the server returns the original result for a repeated key instead of creating a second record — this covers the case where a request timed out but the server already processed it. On the client, keep resumePausedMutations as the single replay path and check isPaused before letting the user manually re-trigger a mutation that is already queued, so you never enqueue the same write twice.
Related
- Mutation Sync & Rollback — the parent reference covering snapshot strategies, atomic error boundaries, and the full mutation-synchronization surface across TanStack Query, SWR, Apollo, and RTK Query.
- Rolling Back Optimistic Updates on Error — the sibling pattern for mutations that reject rather than pause, restoring the pre-mutation snapshot via onMutate context.
- Offline Cache Persistence — persisting query data (not just mutations) to survive reloads, the read-side counterpart to the write queue on this page.
- Cache Invalidation & Server Synchronization — the foundational pillar covering invalidation timing and the server-synchronization guarantees that offline replay must eventually satisfy.