Skip to main content

Performance Tuning: Batching, Debouncing Mutations

Users type fast, click buttons multiple times, and trigger mutations at rates the server can't handle. Without optimization, your app sends redundant requests, wastes bandwidth, and frustrates users with slow responses. This article covers practical techniques: batching multiple writes into one request, debouncing rapid mutations, memoizing callbacks, and canceling in-flight requests when data changes.

Debouncing Mutations for Rapid Changes

Debouncing delays a mutation until the user stops changing a value. Perfect for auto-save features, search inputs, or real-time form updates.

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useRef, useEffect } from 'react';

function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = React.useState(value);

React.useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);

return () => clearTimeout(handler);
}, [value, delay]);

return debouncedValue;
}

export default function AutoSaveForm() {
const [title, setTitle] = React.useState('');
const debouncedTitle = useDebounce(title, 1000); // Wait 1s after typing stops

const queryClient = useQueryClient();

const saveMutation = useMutation({
mutationFn: async (newTitle) => {
const res = await fetch('/api/post/1', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: newTitle }),
});
if (!res.ok) throw new Error('Save failed');
return res.json();
},
});

// Trigger save whenever debouncedTitle changes
React.useEffect(() => {
if (debouncedTitle && debouncedTitle.length > 0) {
saveMutation.mutate(debouncedTitle);
}
}, [debouncedTitle]);

return (
<div>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Post title"
/>
{saveMutation.isPending && <p>Saving...</p>}
{saveMutation.isSuccess && <p>Saved!</p>}
{saveMutation.isError && <p style={{ color: 'red' }}>Error: {saveMutation.error.message}</p>}
</div>
);
}

The user types "Hello, World!" in rapid succession (say, 10 character events). Without debounce, you'd send 10 requests. With a 1-second debounce, you send 1 request 1 second after typing stops.

Batching Multiple Mutations into One Request

Instead of sending individual mutations, collect changes and send them in a batch.

function useBatchMutation() {
const queryClient = useQueryClient();
const batchRef = React.useRef([]);
const timerRef = React.useRef(null);

const flushBatch = React.useCallback(async () => {
if (batchRef.current.length === 0) return;

const batch = batchRef.current;
batchRef.current = [];

try {
const res = await fetch('/api/todos/batch-update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ updates: batch }),
});
if (!res.ok) throw new Error('Batch update failed');
const results = await res.json();

// Update cache with results
queryClient.invalidateQueries({ queryKey: ['todos'] });

return results;
} catch (error) {
console.error('Batch mutation failed:', error);
throw error;
}
}, [queryClient]);

const addToBatch = React.useCallback((update) => {
batchRef.current.push(update);

// Clear existing timer
if (timerRef.current) clearTimeout(timerRef.current);

// Set new timer to flush after 500ms of no new updates
timerRef.current = setTimeout(() => {
flushBatch();
}, 500);
}, [flushBatch]);

// Cleanup on unmount
React.useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);

return { addToBatch, flushBatch };
}

// Usage
export default function BatchUpdateTodos() {
const { addToBatch, flushBatch } = useBatchMutation();

const handleToggle = (id, completed) => {
addToBatch({ id, completed });
};

return (
<>
<div>
{/* Todos with toggles */}
<button onClick={flushBatch}>Flush Batch</button>
</div>
</>
);
}

When the user toggles 5 todos in quick succession, all 5 updates are sent in a single request instead of 5 separate requests.

Memoizing Mutation Handlers

Avoid recreating mutation callbacks on every render. Use useCallback to stabilize handler references.

export default function TodoList({ todos }) {
const updateMutation = useMutation({
mutationFn: async ({ id, completed }) => {
const res = await fetch(`/api/todos/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed }),
});
if (!res.ok) throw new Error('Update failed');
return res.json();
},
});

// Memoize the handler so TodoItem doesn't re-render unnecessarily
const handleToggle = React.useCallback(
(id, completed) => {
updateMutation.mutate({ id, completed });
},
[updateMutation]
);

return (
<ul>
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} onToggle={handleToggle} />
))}
</ul>
);
}

// TodoItem only re-renders if its props change
const TodoItem = React.memo(({ todo, onToggle }) => {
return (
<li>
<input
type="checkbox"
checked={todo.completed}
onChange={(e) => onToggle(todo.id, e.target.checked)}
/>
{todo.title}
</li>
);
});

Without memoization, every parent render creates a new handleToggle function, causing TodoItem to re-render even if the todo data hasn't changed.

Canceling Previous Requests on New Input

When a user changes a search input while a previous search is in-flight, cancel the old request.

function useSearchWithCancel() {
const queryClient = useQueryClient();

const searchMutation = useMutation({
mutationFn: async ({ query, signal }) => {
const res = await fetch(`/api/search?q=${query}`, { signal });
if (!res.ok) throw new Error('Search failed');
return res.json();
},
});

const search = React.useCallback(
(query) => {
// Cancel any in-flight search
if (window.searchAbortController) {
window.searchAbortController.abort();
}

// Create new abort controller for this request
const controller = new AbortController();
window.searchAbortController = controller;

searchMutation.mutate({ query, signal: controller.signal });
},
[searchMutation]
);

return { search, ...searchMutation };
}

// Usage
export default function SearchBox() {
const { search, data, isPending } = useSearchWithCancel();
const [input, setInput] = React.useState('');
const debouncedInput = useDebounce(input, 300);

React.useEffect(() => {
if (debouncedInput) {
search(debouncedInput);
}
}, [debouncedInput, search]);

return (
<>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Search..."
/>
{isPending && <p>Searching...</p>}
<ul>
{data?.results.map((item) => (
<li key={item.id}>{item.title}</li>
))}
</ul>
</>
);
}

If the user types "react" then immediately types "react query", the first search (for "react") is canceled and only the second request is sent.

Optimizing Query Key Matching to Avoid Over-Invalidation

Invalidating too many queries triggers unnecessary refetches. Use precise query keys.

// Bad: invalidates ALL queries
queryClient.invalidateQueries();

// Good: invalidate only affected queries
queryClient.invalidateQueries({ queryKey: ['todos'] });
queryClient.invalidateQueries({ queryKey: ['todos', todoId] });

// Best: use a predicate to match specific patterns
queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return key[0] === 'todos' && key[1] !== undefined; // Only specific todos
},
});

Invalidating fewer queries means fewer refetches, reducing server load and network traffic.

Measuring Mutation Performance

Use React DevTools Profiler to measure mutation impact on render time.

// Wrap expensive components in Profiler
import { Profiler } from 'react';

const onRenderCallback = (id, phase, actualDuration) => {
console.log(`${id} (${phase}) took ${actualDuration}ms`);
};

export default function TodoApp() {
return (
<Profiler id="TodoApp" onRender={onRenderCallback}>
{/* Your todo app */}
</Profiler>
);
}

Profile before and after applying optimizations to verify improvements.

Key Takeaways

  • Debounce rapid mutations (auto-save, search) to send fewer requests and reduce server load.
  • Batch independent mutations into a single request when possible (bulk updates, multi-field forms).
  • Memoize mutation callbacks with useCallback so child components don't re-render unnecessarily.
  • Cancel in-flight requests when the user provides new input to avoid stale data and wasted bandwidth.
  • Invalidate query keys precisely; avoid over-invalidation that triggers unnecessary refetches.
  • Use React DevTools Profiler to measure and verify that optimizations actually improve performance.

Frequently Asked Questions

How long should the debounce delay be?

For text input, 300–1000ms is typical. Too short (100ms) = many requests; too long (3s) = users think the app is slow. Test with real users and adjust.

Can I batch mutations with different operations (create, update, delete)?

Yes, but design your batch endpoint to handle mixed operations. For example, send { creates: [...], updates: [...], deletes: [...] } and return results for each type.

What if a batched mutation partially fails?

Return success and failure details for each item: { id: 1, success: true } { id: 2, success: false, error: '...' }. Update the cache selectively.

Is canceling requests safe?

Yes. Canceling sets an AbortController signal to "aborted", and the fetch rejects with an AbortError. Handle it gracefully in error handling.

How do I test debounced mutations?

Use jest.useFakeTimers() to mock timers in tests. Advance time with jest.runAllTimers() and verify that the mutation fires at the expected time.

Further Reading