Batch API Calls With Promise.allSettled
Send many requests, keep the successes, and report the failures instead of losing everything to one rejected promise. Plus limiting how many run at once.
You need to send 200 order updates to an API. You use Promise.all, update 7 fails, and your code jumps to catch. The other 199 results are gone, and you don't know which ones went through. For a batch where each item can fail on its own, use Promise.allSettled. It waits for every promise and tells you what happened to each one.
The problem with Promise.all here
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
async function sendUpdate(id) {
await wait(10);
if (id % 4 === 0) throw new Error(`Order ${id}: HTTP 409 conflict`);
return `Order ${id}: saved`;
}
const ids = [1, 2, 3, 4, 5, 6, 7, 8];
try {
const results = await Promise.all(ids.map(sendUpdate));
console.log(results);
} catch (error) {
console.log("Batch failed:", error.message);
}
Batch failed: Order 4: HTTP 409 conflict
(Save the examples as .mjs, or run them in a project with "type": "module", because they use top-level await.)
Six orders were saved. The code only knows that one failed. Worse, Promise.all stopped waiting at the first rejection, and order 8 also failed without anyone hearing about it.
allSettled keeps everything
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
async function sendUpdate(id) {
await wait(10);
if (id % 4 === 0) throw new Error(`Order ${id}: HTTP 409 conflict`);
return `Order ${id}: saved`;
}
const ids = [1, 2, 3, 4, 5, 6, 7, 8];
const results = await Promise.allSettled(ids.map(sendUpdate));
console.log(results[0]);
console.log(results[3].status, "-", results[3].reason.message);
const saved = results.filter(r => r.status === "fulfilled").map(r => r.value);
const failed = results
.map((r, i) => ({ id: ids[i], result: r }))
.filter(({ result }) => result.status === "rejected")
.map(({ id, result }) => ({ id, reason: result.reason.message }));
console.log(`${saved.length} saved, ${failed.length} failed`);
console.log(failed);
{ status: 'fulfilled', value: 'Order 1: saved' }
rejected - Order 4: HTTP 409 conflict
6 saved, 2 failed
[
{ id: 4, reason: 'Order 4: HTTP 409 conflict' },
{ id: 8, reason: 'Order 8: HTTP 409 conflict' }
]
allSettled never rejects. Each result is { status: "fulfilled", value } or { status: "rejected", reason }, where reason is the error itself, stack trace included, in the same order as the input. That's why mapping by index back to ids works.
Retry only the failures
Some failures are temporary (a timeout, a 503). Retry those, not the whole batch:
const retryIds = failed.filter(f => !f.reason.includes("409")).map(f => f.id);
const retryResults = await Promise.allSettled(retryIds.map(sendUpdate));
⚠ Warning: Only retry calls that are safe to repeat. "Set status to shipped" can be sent twice. "Charge the card" can't, unless the API supports an idempotency key. Check before you add a retry.
Don't send 5,000 at once
ids.map(sendUpdate) starts every request immediately. For 8 that's fine. For 5,000, you'll hit the API's rate limit and get a batch full of 429 errors. Send in chunks:
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
let running = 0;
let maxRunning = 0;
async function sendUpdate(id) {
running++;
maxRunning = Math.max(maxRunning, running);
await wait(10);
running--;
return id;
}
async function inBatches(items, size, worker) {
const all = [];
for (let i = 0; i < items.length; i += size) {
const chunk = items.slice(i, i + size);
all.push(...(await Promise.allSettled(chunk.map(worker))));
}
return all;
}
const results = await inBatches(Array.from({ length: 23 }, (_, i) => i + 1), 5, sendUpdate);
console.log(results.length, "results, at most", maxRunning, "at once");
23 results, at most 5 at once
Each chunk of 5 waits for all of its own requests before the next chunk starts. It's simple and easy to explain. A pool (like the p-limit package) is a bit faster, because it starts the next request as soon as any one finishes instead of waiting for the slowest in the chunk.
Report it
At the end of a batch job, log or show three things: how many succeeded, how many failed, and which ones failed with why. "Batch failed" helps nobody. "198 saved, 2 failed: 4 (409 conflict), 8 (409 conflict)" tells the next person exactly what to fix.
If one failed update meant the whole batch was wrong (say, a set of journal entries that must all post or none), would allSettled still be the right choice?
About
Use Promise.allSettled to collect every result, split successes from failures, retry only the failures, and send in batches. After this, one bad record no longer fails the whole job.
- Responsible
- Mary Q
- Last Update
- 09/08/2026
- Reading Time
- 4 min
Reviews
Sign in to write a reviewComments (0)
Sign in to commentNo comments yet.