Skip to Content
Batch API Calls With Promise.allSettled
Advanced
0 likes
Tutorials

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
Was this helpful?
​ 0 ​ 0

Comments (0)

Sign in to comment

No comments yet.