# What You’ll Build#
This guide shows how to ship an offline-friendly React app using TanStack Query, focusing on React Query offline persistence, retry and backoff, and optimistic UI that reconciles safely when the network comes back.
You’ll implement three layers that work together:
- 1Persisted query cache so users see data after reload and during offline sessions.
- 2Retry and backoff strategy that avoids hammering flaky networks and keeps the UI responsive.
- 3Optimistic mutations that feel instant, while remaining correct after reconnect.
If you’re already using TanStack Query at scale, this guide complements our deeper patterns for large apps, cache invalidation, and mutation ergonomics: React Query at scale.
# Prerequisites#
| Requirement | Version | Notes |
|---|---|---|
| React | 18+ | Any router is fine |
| TanStack Query | 5+ | This guide assumes v5 APIs |
| TypeScript | Recommended | Examples use TS-friendly patterns |
| Service worker (optional) | — | Recommended for a true PWA offline shell |
| MSW | Latest | For offline and flaky network testing |
For broader offline product strategy, including service worker caching and installability, see our PWA guide: Progressive Web App guide.
# Offline-First vs Offline-Friendly: Pick the Right Target#
Most product teams do not need “offline-first everything”. A practical target is offline-friendly:
- Read-only offline for core screens: last known data, lists, detail views.
- Write-queue offline for selected mutations: notes, checklist items, lightweight drafts.
- Degraded mode for everything else: show an offline banner and disable risky actions.
This matters because persisting and reconciling all data increases complexity and can create security issues if you store sensitive payloads unencrypted.
A good rule of thumb is to start with top 3 user journeys. If those work offline (or degrade gracefully), you’ll see measurable gains in retention and task completion. Google’s Web.dev reports that reliable performance and resilience can meaningfully improve engagement; in practice, teams typically see fewer “rage taps”, fewer failed sessions on mobile networks, and better conversion on commuter scenarios.
Decide what “offline” means for your app#
| Feature area | Offline target | Why |
|---|---|---|
| Public catalog / content | Persist and show | Drives engagement even without login |
| Authenticated dashboard | Partial | Persist last known, but avoid secrets |
| Forms / drafts | Queue where safe | High perceived reliability |
| Payments / critical transactions | Disable offline | Avoid duplicate charges and audit issues |
🎯 Key Takeaway: Offline-friendly is a product decision first. Persist and queue only what improves real user journeys without creating security or correctness debt.
# Step 1: Set Up TanStack Query for Persistence#
TanStack Query supports persistence via the @tanstack/query-persist-client package. The idea is simple:
- On app start, rehydrate the cache from storage.
- During runtime, persist changes back to storage.
- Apply max age and buster rules so you don’t load stale data forever.
Install dependencies#
npm i @tanstack/react-query @tanstack/query-persist-clientCreate a persister#
The persister is the bridge to storage. For web you typically use localStorage or IndexedDB.
localStorageis simple, synchronous, and small.IndexedDBscales better and avoids blocking the main thread.
If you need large datasets or frequent writes, prefer IndexedDB. If you persist a small “last known state” subset, localStorage is often enough.
Below is a minimal localStorage persister. It intentionally keeps the implementation small and testable.
// persister.ts
type Persisted = string | null;
export function createLocalStoragePersister(key: string) {
return {
persistClient: async (client: unknown) => {
localStorage.setItem(key, JSON.stringify(client));
},
restoreClient: async (): Promise<unknown | undefined> => {
const raw: Persisted = localStorage.getItem(key);
return raw ? JSON.parse(raw) : undefined;
},
removeClient: async () => {
localStorage.removeItem(key);
},
};
}Wire up persistence in your app root#
Use PersistQueryClientProvider to ensure rehydration happens before your app depends on cached data.
// queryClient.ts
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
gcTime: 1000 * 60 * 60 * 24,
refetchOnWindowFocus: false,
},
},
});// AppProviders.tsx
import React from 'react';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { queryClient } from './queryClient';
import { createLocalStoragePersister } from './persister';
const persister = createLocalStoragePersister('rq-cache-v1');
export function AppProviders(props: { children: React.ReactNode }) {
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister,
maxAge: 1000 * 60 * 60 * 24,
buster: '2026-08-31',
}}
onSuccess={() => {
queryClient.resumePausedMutations();
queryClient.invalidateQueries();
}}
>
{props.children}
</PersistQueryClientProvider>
);
}What this gives you:
- Cache survives refresh and browser restarts.
- Paused mutations (when offline) can be resumed after reload.
- Invalidate on startup ensures you reconcile with the server when online.
ℹ️ Note:
invalidateQueries()on hydration is a safe baseline, but can be expensive for large apps. A better approach is to invalidate only “freshness-critical” query keys, and keep others purely offline-read.
Persist only what you need#
Blindly persisting everything can:
- Leak sensitive data into device storage.
- Increase startup time and storage usage.
- Cause “stale but looks correct” bugs.
Use dehydration filters to persist specific queries. A common approach is: persist only successful queries, only specific query keys, and only those that are not user-sensitive.
Example strategy:
- Persist “catalog”, “projects list”, “profile display fields”.
- Do not persist “access tokens”, “PII-heavy detail views”, “admin screens”.
You can implement filtering using dehydrateOptions depending on your persister setup and TanStack Query version. If you can’t filter easily, a pragmatic fallback is to namespace offline-safe queries and only hydrate those in a dedicated client used for offline screens.
⚠️ Warning: Persisting authenticated user data in
localStoragecan violate internal security requirements. If you must persist sensitive data, use IndexedDB plus encryption, and still keep a strict max age.
# Step 2: Build an Offline Signal and Partial Offline Mode#
Offline UX breaks when the app does not clearly communicate state. You want a single “network status” source used by:
- the banner,
- disabling actions,
- adjusting retries,
- deciding whether to refetch.
Minimal network status hook#
// useNetworkStatus.ts
import { useEffect, useState } from 'react';
export function useNetworkStatus() {
const [online, setOnline] = useState(() => navigator.onLine);
useEffect(() => {
const on = () => setOnline(true);
const off = () => setOnline(false);
window.addEventListener('online', on);
window.addEventListener('offline', off);
return () => {
window.removeEventListener('online', on);
window.removeEventListener('offline', off);
};
}, []);
return { online };
}Partial offline pattern: “Read cached, block risky writes”#
A practical pattern is:
- Queries render from cache.
- Mutations either:
- queue and resume later if safe, or
- are disabled with a clear reason.
You can express that decision centrally:
| Operation type | Offline behavior | Example |
|---|---|---|
| Idempotent writes | Queue | Toggle “starred”, update local draft |
| Non-idempotent writes | Block | Payment, creating invoices, sending emails |
| Reads | Serve from cache | Project list, previously opened details |
💡 Tip: Put the offline policy next to your API client, not in random UI buttons. Consistency is what prevents “some actions work offline, some don’t” confusion.
# Step 3: Retries and Backoff That Don’t Punish Users#
TanStack Query retries are great for flaky networks, but default retry behavior can feel broken offline. If a phone is in airplane mode, you don’t want:
- repeated spinners,
- battery drain,
- or dozens of queued requests.
Recommended retry strategy#
- If offline, do not retry.
- If online, retry only on transient errors.
- Use exponential backoff with a cap.
- Do not retry on 400-level validation errors.
Here’s a reusable retry and retryDelay setup:
// queryDefaults.ts
type AnyError = unknown;
function isRetryableStatus(status?: number) {
return status === 408 || status === 429 || (status !== undefined && status >= 500);
}
export function createRetryOptions(isOnline: () => boolean) {
return {
retry: (failureCount: number, error: AnyError) => {
if (!isOnline()) return false;
const status = (error as any)?.status ?? (error as any)?.response?.status;
if (status && !isRetryableStatus(status)) return false;
return failureCount < 3;
},
retryDelay: (attemptIndex: number) => {
const base = 1000;
const delay = base * 2 ** attemptIndex;
return Math.min(delay, 30_000);
},
};
}Then apply it:
// queryClient.ts
import { QueryClient } from '@tanstack/react-query';
import { createRetryOptions } from './queryDefaults';
const isOnline = () => navigator.onLine;
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
...createRetryOptions(isOnline),
staleTime: 30_000,
refetchOnReconnect: true,
refetchOnWindowFocus: false,
},
mutations: {
...createRetryOptions(isOnline),
},
},
});Why this matters:
- You avoid wasting time on guaranteed failures.
- Users see stable UI states rather than endless loading.
- Server load is reduced during outages.
Backoff and UX states#
Do not hide backoff behind a spinner. Show:
- “Retrying in 4s…” for critical screens, or
- a non-blocking toast, or
- a “Tap to retry” button after the last attempt.
This also improves supportability because users can tell whether the app is stuck or intentionally waiting.
# Step 4: Optimistic Updates That Reconcile Safely#
Optimistic UI is what makes offline-friendly apps feel fast. The trap is correctness: optimistic updates must be reversible and reconcilable with the server.
A safe optimistic mutation typically has these steps:
- 1Cancel outgoing refetches for affected queries.
- 2Snapshot the previous cache value.
- 3Apply a patch update to cache.
- 4Attempt the mutation.
- 5On error, rollback using the snapshot.
- 6On success, reconcile using server response.
- 7On settled, refetch or invalidate to ensure canonical state.
Example: optimistic toggle with rollback#
Assume you have a list of tasks and a toggle mutation. This example updates a single item in a cached list.
// useToggleTask.ts
import { useMutation } from '@tanstack/react-query';
import { queryClient } from './queryClient';
type Task = { id: string; done: boolean; updatedAt: string };
type Tasks = Task[];
async function apiToggleTask(id: string, done: boolean): Promise<Task> {
const res = await fetch(`/api/tasks/${id}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ done }),
});
if (!res.ok) throw Object.assign(new Error('Request failed'), { status: res.status });
return res.json();
}
export function useToggleTask() {
return useMutation({
mutationFn: async (vars: { id: string; done: boolean }) =>
apiToggleTask(vars.id, vars.done),
onMutate: async (vars) => {
await queryClient.cancelQueries({ queryKey: ['tasks'] });
const previous = queryClient.getQueryData<Tasks>(['tasks']);
queryClient.setQueryData<Tasks>(['tasks'], (current) => {
if (!current) return current;
return current.map((t) => (t.id === vars.id ? { ...t, done: vars.done } : t));
});
return { previous };
},
onError: (_err, _vars, ctx) => {
if (ctx?.previous) queryClient.setQueryData(['tasks'], ctx.previous);
},
onSuccess: (serverTask) => {
queryClient.setQueryData<Tasks>(['tasks'], (current) => {
if (!current) return current;
return current.map((t) => (t.id === serverTask.id ? serverTask : t));
});
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
},
});
}This is safe because:
- Rollback is deterministic.
- Server response becomes canonical when available.
- A refetch after settle cleans up edge cases.
Designing optimistic updates for offline queueing#
If you want mutations to work offline, you need an extra constraint: the optimistic state must include enough information to merge later.
Practical patterns:
| Pattern | Works offline | When to use |
|---|---|---|
| Replace with server response | Partially | Only when you expect quick success |
| Patch-based update with rollback | Yes | Simple updates like toggles |
| Local “pending” flag per entity | Yes | Lists where ordering can change |
| Client-generated IDs | Yes | Creating items offline, syncing later |
A robust create flow needs client IDs to avoid duplicates:
- Create locally with
id = client-uuid. - Display immediately with
status = pending. - On success, map to server ID and update references.
- On failure, keep it and show “Tap to retry” or rollback.
⚠️ Warning: Optimistic “create” without client IDs causes duplicate rows after reconnect because the UI can’t correlate the local item with the server-created item.
Reconciliation rules that avoid data corruption#
When reconnecting, you need to handle conflicts. Keep it simple:
- 1If the server returns a newer
updatedAt, server wins. - 2If you have pending local changes, re-apply them as patches after refetch.
- 3If the API supports it, send
If-Matchwith ETags or a version number to detect conflicts.
Even without formal conflict resolution, you can reduce issues by invalidating and refetching after mutation settle, and by scoping optimistic updates to the smallest possible cache segment.
For more mutation patterns and scaling considerations, see: cache invalidation, pagination, mutations at scale.
# Step 5: Offline Persistence Meets Optimistic UI#
Persistence changes the lifecycle:
- A pending optimistic change might still be in the cache after reload.
- Users can reopen the app offline and see pending state.
Your UI should explicitly model this:
| UI element | Online state | Offline with pending mutations |
|---|---|---|
| List item | Normal | Show “Pending” label |
| Save button | Enabled | “Will sync when online” or disabled |
| Toast / banner | Hidden | Offline banner plus queue count |
If you don’t show pending state, users will assume the action succeeded server-side and may be surprised later.
A minimal banner can read cached mutation count and show it:
- Use TanStack Query mutation cache length.
- Or store a lightweight “outbox count” in app state if you build a custom queue.
# Step 6: Testing Offline, Retries, and Optimistic UI with MSW#
Offline features that are not tested will regress. You want tests that simulate:
- network failure,
- slow network,
- reconnect,
- and server reconciliation.
MSW is ideal because it intercepts requests at the network layer. If your team needs a full testing baseline, we’ve written a broader strategy here: Vitest, React Testing Library, MSW testing strategy.
Example MSW handlers for flaky network#
// test/handlers.ts
import { http, HttpResponse } from 'msw';
let failNext = true;
export const handlers = [
http.get('/api/tasks', () => {
return HttpResponse.json([
{ id: '1', done: false, updatedAt: '2026-08-01T10:00:00Z' },
]);
}),
http.patch('/api/tasks/:id', async ({ request, params }) => {
if (failNext) {
failNext = false;
return new HttpResponse(null, { status: 503 });
}
const body = (await request.json()) as any;
return HttpResponse.json({
id: String(params.id),
done: Boolean(body.done),
updatedAt: '2026-08-31T12:00:00Z',
});
}),
];Test optimistic UI rollback and retry behavior#
This test asserts that:
- UI updates optimistically.
- First request fails.
- Cache rolls back or refetch corrects state.
- A second attempt succeeds and reconciles.
// test/offline-optimistic.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('optimistic toggle reconciles after transient failure', async () => {
render(/* your app wrapped with AppProviders */);
const toggle = await screen.findByRole('button', { name: /toggle task 1/i });
await userEvent.click(toggle);
expect(await screen.findByText(/done: true/i)).toBeInTheDocument();
await userEvent.click(toggle);
expect(await screen.findByText(/done: false/i)).toBeInTheDocument();
});To test offline explicitly, simulate offline status by:
- mocking
navigator.onLine, - dispatching
offlineandonlineevents, - having MSW return a network error.
In unit tests, a deterministic approach is better than relying on real browser offline toggles.
💡 Tip: Add one “offline smoke test” per critical user journey. For most apps that’s 3 to 5 tests, and they catch the majority of regressions: startup hydration, list read, one queued write, and reconnect reconciliation.
# Common Pitfalls and How to Avoid Them#
Persisting too much data#
If you persist entire responses for every query, you’ll eventually ship:
- slow hydration,
- storage bloat,
- and accidental exposure of sensitive data.
Fix: persist only the query keys that drive offline UX, and set a strict maxAge.
Retrying aggressively during outages#
If a backend is down, default retries multiplied by thousands of clients can cause a thundering herd. Users also see repeated loading states.
Fix: stop retries when offline, cap retries and delays, and fail fast on non-retryable statuses.
Optimistic updates without a reconciliation path#
Optimistic UI is not “set state and hope”. Without snapshot rollback and server reconciliation, you accumulate inconsistencies.
Fix: always implement onMutate snapshot, onError rollback, and onSuccess canonical update.
Not showing pending status#
If the UI hides queued work, users will double-submit or assume success.
Fix: surface offline banner plus pending indicators per item and per screen.
# Key Takeaways#
- Persist only offline-critical query keys and apply a strict max age to make React Query offline persistence safe and fast.
- Stop retries when offline and use capped exponential backoff for transient errors to protect both UX and backend load.
- Implement optimistic updates with snapshot rollback and server reconciliation, then invalidate to guarantee canonical state.
- Use partial offline mode: read from cache everywhere, queue only idempotent writes, and block risky actions with clear messaging.
- Test offline and flaky-network flows with MSW, including hydration, retry behavior, and optimistic reconciliation on reconnect.
# Conclusion#
Offline-friendly UX is a compound system: persistence makes data available, retry strategy prevents bad network loops, and optimistic UI keeps the product feeling instant while remaining correct.
If you want help implementing offline patterns in a production React and Next.js codebase, Samioda can audit your TanStack Query setup, define an offline policy per feature, and ship the persistence, queueing, and MSW test coverage needed to keep it stable. Reach out via our site and share your app’s key user journeys and API constraints.
FAQ
Founder & Senior Developer at Samioda. 8+ years building React, Next.js, Flutter and n8n automation solutions for clients across Europe.
More in Web Development
All →React Virtualization Guide: Windowing Large Lists and Grids with TanStack Virtual (and When Not To)
A practical 2026 guide to React virtualization with TanStack Virtual: build fast lists and grids, add infinite loading and sticky headers, profile measurable gains, and avoid common edge cases like dynamic row heights and accessibility pitfalls.
Next.js Supabase Realtime in 2026: End-to-End Blueprint for Chat, Presence, and Collaboration
Build production-ready realtime UI with Next.js App Router and Supabase Realtime: schema design, RLS, optimistic updates, presence, scaling, and troubleshooting duplicate events and permission mismatches.
Next.js Authorization in the App Router: RBAC vs ABAC with Middleware, RLS, and Policy Patterns
A practical guide to Next.js authorization in the App Router using RBAC and ABAC — with middleware checks, server component guards, database-enforced RLS, decision matrix, and copy-paste policy patterns.
Need help with your project?
We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.
Related Articles
React Testing Strategy in 2026: Vitest + React Testing Library + MSW for Confident Releases
A pragmatic React testing strategy for 2026 using Vitest, React Testing Library, and MSW. Learn a realistic test pyramid, reduce flakiness, and ship confidently with CI-ready patterns.
Modern React Frontend Architecture: Feature-Based Modules, Boundaries, and Scalability
A practical guide to React frontend architecture feature-based modules: clear boundaries, shared layers, dependency rules, and a maintainable folder strategy for React and Next.js.
The React Code Review Checklist We Use: Performance, Accessibility, and Maintainability
A practical React code review checklist focused on performance, accessibility, and maintainability, with examples, automation tips, and a copy-paste template.