Skip to main content

Parallel Mutations Strategy: Multiple Async Ops

When independent operations can run simultaneously, parallelizing them reduces total latency from the sum of individual times to the maximum of the longest. React Query supports unlimited concurrent mutations; the challenge is coordinating their results and state. This article covers patterns for executing multiple mutations in parallel, combining their results, and handling partial failures gracefully.

Running Mutations in Parallel

Use Promise.all to fire multiple mutateAsync calls at once. They execute concurrently, and the function resumes when all have settled.

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

export default function BulkCreateUsers() {
const queryClient = useQueryClient();

const createUserMutation = useMutation({
mutationFn: async (email) => {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
if (!res.ok) throw new Error(`User ${email} creation failed`);
return res.json();
},
});

const handleBulkCreate = async (emails) => {
try {
// Fire all mutations at once; Promise.all waits for all to complete
const results = await Promise.all(
emails.map((email) => createUserMutation.mutateAsync(email))
);

console.log('All users created:', results);
queryClient.invalidateQueries({ queryKey: ['users'] });
} catch (error) {
console.error('Some users failed:', error);
}
};

return (
<button onClick={() => handleBulkCreate(['[email protected]', '[email protected]', '[email protected]'])}>
Create Users in Parallel
</button>
);
}

Handling Partial Failures with Promise.allSettled

Promise.allSettled waits for all promises, regardless of success or failure. It returns an array of results and errors, letting you decide whether to proceed or rollback.

const handleBulkCreate = async (emails) => {
const outcomes = await Promise.allSettled(
emails.map((email) => createUserMutation.mutateAsync(email))
);

const successful = [];
const failed = [];

outcomes.forEach((outcome, index) => {
if (outcome.status === 'fulfilled') {
successful.push({ email: emails[index], user: outcome.value });
} else {
failed.push({ email: emails[index], error: outcome.reason });
}
});

console.log(`Created ${successful.length}, failed ${failed.length}`);

if (failed.length > 0) {
console.log('Failed emails:', failed.map((f) => f.email));
// Optionally show a warning and allow user to retry failed ones
}

if (successful.length > 0) {
queryClient.invalidateQueries({ queryKey: ['users'] });
}
};

Combining Multiple Mutation Types

Simultaneously update different resources (e.g., create a post and upload images).

const createPostWithImages = async (postData, imageFiles) => {
try {
// Step 1: Create the post
const post = await createPostMutation.mutateAsync(postData);

// Step 2: Upload all images in parallel
const imageUploads = imageFiles.map((file) =>
uploadImageMutation.mutateAsync({
postId: post.id,
file,
})
);

const images = await Promise.all(imageUploads);
console.log('Post created with images:', { post, images });

// Step 3: Invalidate to refresh
queryClient.invalidateQueries({ queryKey: ['posts'] });

return { post, images };
} catch (error) {
console.error('Post creation failed:', error);
throw error;
}
};

Tracking Progress for Parallel Operations

When bulk operations may take time, show progress to the user.

function useParallelMutations() {
const [progress, setProgress] = React.useState({ total: 0, completed: 0, failed: 0 });

const mutation = useMutation({
mutationFn: async (items) => {
setProgress({ total: items.length, completed: 0, failed: 0 });

const outcomes = await Promise.allSettled(
items.map((item) =>
fetch('/api/process', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item),
}).then((res) => {
if (!res.ok) throw new Error('Process failed');
return res.json();
})
)
);

let completed = 0;
let failed = 0;
outcomes.forEach((outcome) => {
if (outcome.status === 'fulfilled') completed++;
else failed++;
});

setProgress({ total: items.length, completed, failed });
return outcomes;
},
});

return { ...mutation, progress };
}

// Usage
export default function BulkProcessor() {
const { mutate, progress, isPending } = useParallelMutations();

return (
<>
<button onClick={() => mutate([...items])} disabled={isPending}>
Process Items
</button>
{isPending && (
<p>
{progress.completed} / {progress.total} done
{progress.failed > 0 && ` (${progress.failed} failed)`}
</p>
)}
</>
);
}

Limiting Concurrent Mutations

Large bulk operations may overwhelm the server or exhaust browser resources. Use a concurrency limiter.

async function withConcurrencyLimit(items, limit, mutationFn) {
const results = [];
const executing = [];

for (const item of items) {
const promise = Promise.resolve(item)
.then((x) => mutationFn(x))
.then((result) => {
results.push(result);
const index = executing.indexOf(promise);
if (index > -1) executing.splice(index, 1);
});

executing.push(promise);

if (executing.length >= limit) {
await Promise.race(executing);
}
}

await Promise.all(executing);
return results;
}

// Usage: create users, but limit to 3 concurrent requests
const emails = ['[email protected]', '[email protected]', '[email protected]', '[email protected]'];
const results = await withConcurrencyLimit(emails, 3, (email) =>
createUserMutation.mutateAsync(email)
);

Atomic Parallel Updates with Optimistic UI

Combine parallel mutations with optimistic updates for instant feedback.

const bulkUpdatePostsMutation = useMutation({
mutationFn: async (updates) => {
const outcomes = await Promise.allSettled(
updates.map(({ postId, data }) =>
fetch(`/api/posts/${postId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}).then((res) => {
if (!res.ok) throw new Error('Update failed');
return res.json();
})
)
);
return outcomes;
},
onMutate: async (updates) => {
// Cancel pending queries
await queryClient.cancelQueries({ queryKey: ['posts'] });

// Snapshot current cache
const previousPosts = queryClient.getQueryData(['posts']);

// Optimistically update all posts
updates.forEach(({ postId, data }) => {
queryClient.setQueryData(['posts', postId], (old) => ({
...old,
...data,
}));
});

return { previousPosts };
},
onError: (err, variables, context) => {
// Rollback all updates on any error
if (context?.previousPosts) {
queryClient.setQueryData(['posts'], context.previousPosts);
}
},
onSuccess: () => {
// Refetch to confirm
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});

const handleBulkUpdate = async (updates) => {
bulkUpdatePostsMutation.mutate(updates);
};

Key Takeaways

  • Use Promise.all to run independent mutations concurrently; execution time = max(individual times), not sum.
  • Use Promise.allSettled to handle partial failures gracefully without aborting the entire batch.
  • For large bulk operations, limit concurrency to avoid overwhelming the server or browser (typically 3–5 concurrent per browser instance).
  • Track progress and show it to users for visibility into long-running batch operations.
  • Combine parallel mutations with optimistic updates for instant feedback; rollback all changes if any mutation fails.
  • Group related mutations (all POST ops, all DELETE ops) to reduce cognitive load and simplify error handling.

Frequently Asked Questions

Should I use Promise.all or Promise.allSettled?

Use Promise.all if you need all to succeed; fail fast on the first error. Use Promise.allSettled if partial success is acceptable and you want to track individual failures.

What's the right concurrency limit?

Start with 3–5 concurrent requests. Too low = slow; too high = server overload and network contention. Monitor your server and adjust based on latency and error rates.

How do I cancel parallel mutations if the user navigates away?

Use AbortController. Pass an AbortSignal to each mutation and listen for abort events to cleanup. Note: React Query v5+ has built-in abort support via the signal option.

Can I retry failed mutations in a batch without restarting?

Yes, collect failed items and call the batch function again with just those items. Build a UI to show retries.

Is it safe to parallel-update the same entity twice?

No. Two parallel mutations on the same resource (e.g., two PUTs to the same post) may conflict. Serialize writes to the same entity, or use conditional writes (ETags, version fields).

Further Reading