Skip to main content

Offline Mutations and Retry Strategies

Users aren't always connected. Mobile networks drop, Wi-Fi cuts out, and server errors happen. Building resilience means queuing mutations offline, retrying intelligently, and syncing when connectivity returns. React Query's retry mechanism combined with offline detection creates a seamless experience where users write data confident it will reach the server—or they're informed immediately if it won't.

Detecting Network Offline State

Use the browser's navigator.onLine API combined with custom event listeners to detect connectivity changes.

function useIsOnline() {
const [isOnline, setIsOnline] = React.useState(navigator.onLine);

React.useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);

window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);

return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);

return isOnline;
}

// Usage
export default function App() {
const isOnline = useIsOnline();

return (
<>
{!isOnline && <div className="offline-banner">You are offline. Changes will sync when you reconnect.</div>}
{/* Rest of app */}
</>
);
}

Note: navigator.onLine is not 100% reliable (a connected browser might report offline due to proxy issues). Combine it with actual request attempts for production.

Configuring Retry Logic

React Query has built-in retry support via the retry option. By default, mutations retry failed requests 3 times. Customize retry behavior with a retry function or count.

import { useMutation } from '@tanstack/react-query';

const createPostMutation = useMutation({
mutationFn: async (title) => {
const res = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
if (!res.ok) throw new Error('Create failed');
return res.json();
},
retry: 3, // Retry up to 3 times
});

// Or with a custom retry function:
const advancedMutation = useMutation({
mutationFn: async (data) => {
// ...
},
retry: (failureCount, error) => {
// Don't retry on 4xx client errors (bad request, auth, etc.)
if (error.response?.status >= 400 && error.response?.status < 500) {
return false;
}
// Retry up to 5 times on server errors (5xx) or network errors
return failureCount < 5;
},
});

Exponential Backoff

Retry with delays that increase exponentially to avoid overwhelming a recovering server.

const createPostMutation = useMutation({
mutationFn: async (title) => {
const res = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
if (!res.ok) throw new Error('Create failed');
return res.json();
},
retry: 5,
retryDelay: (attemptIndex) => {
// Delay = 2^attemptIndex * 1000ms
// Attempt 0: 1s
// Attempt 1: 2s
// Attempt 2: 4s
// Attempt 3: 8s
// Attempt 4: 16s
return Math.min(1000 * 2 ** attemptIndex, 30000);
},
});

Add jitter (randomness) to avoid thundering herd when many clients retry simultaneously:

retryDelay: (attemptIndex) => {
const baseDelay = Math.min(1000 * 2 ** attemptIndex, 30000);
const jitter = Math.random() * baseDelay * 0.1; // ±10% randomness
return baseDelay + jitter;
},

Queuing Mutations While Offline

For offline-first apps, store pending mutations in local storage and replay them when connectivity returns.

function usePersistentMutation(key, mutationFn) {
const [queue, setQueue] = React.useState(() => {
const stored = localStorage.getItem(`mutation_queue_${key}`);
return stored ? JSON.parse(stored) : [];
});

const mutation = useMutation({
mutationFn,
});

const mutate = React.useCallback(
async (variables) => {
// Add to queue immediately
const item = { id: Date.now(), variables, status: 'pending' };
const newQueue = [...queue, item];
setQueue(newQueue);
localStorage.setItem(`mutation_queue_${key}`, JSON.stringify(newQueue));

// Attempt to execute (will fail silently if offline)
try {
const result = await mutation.mutateAsync(variables);
// Success: remove from queue
const filtered = newQueue.filter((q) => q.id !== item.id);
setQueue(filtered);
localStorage.setItem(`mutation_queue_${key}`, JSON.stringify(filtered));
return result;
} catch (error) {
// Failed; remains in queue for retry on reconnect
console.error('Mutation queued for retry:', item);
throw error;
}
},
[queue, mutation]
);

// Retry queue when online
React.useEffect(() => {
const handleOnline = async () => {
console.log('Back online; retrying queued mutations...');
for (const item of queue) {
try {
await mutation.mutateAsync(item.variables);
// Remove successfully executed item
setQueue((q) => q.filter((i) => i.id !== item.id));
} catch (error) {
console.error('Retry failed for item:', item);
}
}
};

window.addEventListener('online', handleOnline);
return () => window.removeEventListener('online', handleOnline);
}, [queue, mutation]);

return { mutate, queue };
}

// Usage
export default function PostForm() {
const { mutate, queue } = usePersistentMutation('createPost', async (title) => {
const res = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
if (!res.ok) throw new Error('Create failed');
return res.json();
});

const handleSubmit = (title) => {
mutate(title).catch(() => {
// Mutation queued; user will see a banner or notification
});
};

return (
<>
<button onClick={() => handleSubmit('My Post')}>Create Post</button>
{queue.length > 0 && <p>You have {queue.length} pending mutations. They will sync when you're online.</p>}
</>
);
}

Handling Stale-While-Revalidate Pattern

Serve stale cached data immediately while refetching in the background.

import { useQuery } from '@tanstack/react-query';

const postsQuery = useQuery({
queryKey: ['posts'],
queryFn: async () => {
const res = await fetch('/api/posts');
if (!res.ok) throw new Error('Fetch failed');
return res.json();
},
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes (previously cacheTime)
});

With staleTime, React Query serves cached data immediately and refetches in the background. If the refetch fails, the stale data remains visible. This is ideal for offline scenarios: show recent data even if you can't verify freshness right now.

Displaying Retry Progress

Show users that their mutation is retrying.

export default function CreatePostWithRetry() {
const [retryCount, setRetryCount] = React.useState(0);

const mutation = useMutation({
mutationFn: async (title) => {
const res = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
if (!res.ok) throw new Error('Create failed');
return res.json();
},
retry: 5,
retryDelay: (attemptIndex) => {
setRetryCount(attemptIndex + 1);
return Math.min(1000 * 2 ** attemptIndex, 30000);
},
});

return (
<>
<button onClick={() => mutation.mutate({ title: 'My Post' })}>Create</button>
{mutation.isPending && (
<p>
Posting... {retryCount > 0 && `(Retry attempt ${retryCount})`}
</p>
)}
{mutation.isError && (
<p style={{ color: 'red' }}>Failed after retries. Please check your connection and try again.</p>
)}
</>
);
}

Key Takeaways

  • Use navigator.onLine and event listeners to detect connectivity changes and inform users.
  • Configure mutation retry behavior with the retry option; distinguish client errors (don't retry) from server/network errors (retry).
  • Implement exponential backoff with jitter to avoid overwhelming a recovering server.
  • For offline-first apps, queue mutations in localStorage and replay on reconnect.
  • Use stale-while-revalidate to serve cached data immediately while refetching in the background.
  • Display retry progress to users so they understand that your app is working, not frozen.

Frequently Asked Questions

Is navigator.onLine reliable?

No. It only reflects the device's network adapter state, not actual internet connectivity. Combine it with request timeouts and fallback error handling to detect real connectivity issues.

How many retries is too many?

Typically, 3–5 retries with exponential backoff is enough. After 5 retries (16+ seconds total), it's usually a persistent issue. Give up, queue for later, or show an error.

Should I retry idempotent requests (GET) differently than mutations?

Yes. GET requests are safe to retry immediately. Mutations should only retry if you can ensure idempotency (e.g., POST with a unique request ID). Unsafe mutations should retry less aggressively.

How do I know if a failure is temporary (worth retrying) vs permanent?

Check the HTTP status and error type: 5xx (server error) = retry. 4xx (client error) = don't retry. Network timeout = retry. Auth failure = don't retry, ask user to re-login.

Can I persist mutations longer than a browser session?

Yes, but you need a real offline-first database (e.g., IndexedDB with a library like Dexie.js). localStorage has size limits (~5MB) and is less reliable for structured data.

Further Reading