DEV BOX

'26/10/09 UPDATE

WEB / JAVASCRIPT / FIELD NOTES

Promise.all rejected, so why do the other requests keep running?

Three APIs were called with Promise.all; one returned a 500 and the code jumped to catch. Yet the server log shows the other two requests were handled to the end. Using a fake API on 127.0.0.1 and Node.js v24.21.0, this note runs Promise.all, Promise.allSettled and Promise.all with an AbortController to see what stops and what keeps going, and explains why abort() does not undo work the server has already done.

To draw one screen you call three APIs at once with Promise.all. One of them returns a 500, so the code jumps to catch and shows an error message. Yet the server log shows the other two requests were handled to the end. It failed, so why didn't the rest stop? After reading this you will be able to check in the logs what actually stops and what keeps going when Promise.all rejects, and choose whether to add Promise.allSettled or an AbortController for your case.

The example was run on 2026-10-08 on macOS 27.0.1 with Node.js v24.21.0. In Node.js, fetch is handled by the bundled undici 7.29.1. The server is a small fake Node.js API on the same machine (127.0.0.1). /a answers 500 after 100 ms, /b answers 200 after 300 ms and /c after 600 ms, and each finished request is logged. The paths and timings are made up for the explanation.

Promise.all is a way of waiting, not the work itself

First look at when the requests start. The moment ['/a', '/b', '/c'].map((p) => get(p)) runs, fetch is called three times and all three requests are already on the network. Promise.all then takes the three promises that were created and returns a new promise that settles "with an array of results if all succeed, or with the first rejection reason if any fails". It neither starts the requests nor stops them.

JavaScript promises have no cancel operation. A promise only stands for the result of work that is already under way, so when the code waiting for it gives up, the work itself receives no signal. All Promise.all defines is that it rejects as soon as one input rejects; there is no rule about what happens to the other inputs.

DEV NOTES

Promise.all is only a rule for waiting

Case 1 · run with Node.js v24.21.0

  1. 1 Three requests start together

    Promise.all(['/a','/b','/c'].map(get))

    • Each fetch starts when map() calls it
    • Promise.all only gathers the results
  2. 2 /a fails → rejects at once

    183 ms rejected: /a HTTP 500

    • catch gets the first rejection reason
    • It does not wait for the others
  3. 3 /b and /c run to the end

    372 ms /b done · 669 ms /c done

    • No cancel signal, so they get responses
    • The server handled all three

Times are the client.mjs output from 2026-10-08. /a, /b and /c are a fake API on 127.0.0.1 that answers after 100, 300 and 600 ms.

When Promise.all rejects, it means it stopped waiting. It does nothing to stop requests that have already started.

In a real run, requests keep going after catch has finished

The client's get() sends a request with fetch and throws if the response is not ok. fetch does not fail by itself on a 404 or 500, so you have to throw like this for Promise.all to see the failure. On success it prints done (still ran); on failure, stopped and the error name.

The first case is the usual try { await Promise.all(...) } catch. At 183 ms /a got its 500 and failed, and at the same moment Promise.all rejected: /a HTTP 500 was printed. So far, as expected. But afterwards /b received its full response at 372 ms and /c at 669 ms. The server log also shows all three requests as finished. The first request ended at 183 ms rather than about 100 ms, most likely because opening the first connection added time; from the second case on it was around 104 ms.

Output. # 1 Promise.all: at 183 ms /a stopped with Error and Promise.all rejected with /a HTTP 500. /b finished at 372 ms and /c at 669 ms (still ran). server.log: GET /a?case=1 returned 500, /b and /c returned 200; all three were handled.
Client output and server log for the first case. Client times count from the start of this case and server times from server start, so the two columns are not compared directly.

This becomes a real problem in cases like these. After the error screen is shown, the late result of /b overwrites state and the screen changes again; or the user has moved to another page and the response arrives and touches a component that no longer exists. And if /b was a save rather than a read, the user sees "it failed" while part of the data has been saved.

How to choose: what does one failure mean?

The second case is Promise.allSettled. Even when something fails, it waits until every input has ended and then returns each result as { status: 'fulfilled', value } or { status: 'rejected', reason }. In the run, /a failed at 104 ms, but the results arrived all at once at 607 ms, after /c ended: rejected, fulfilled, fulfilled. For a screen such as a dashboard, where the other cards can still be shown when one fails, this is the right choice.

The third case is when one failure makes the rest pointless. The three requests share the signal of one AbortController, and whichever fails first calls abort().

const ac = new AbortController();
await Promise.all(paths.map((p) =>
  get(p, ac.signal).catch((e) => { ac.abort(); throw e; })));

/a failed at 103 ms, and at 112 ms the fetch calls for /b and /c stopped with AbortError. If no reason is passed to abort(), the reason is a DOMException named AbortError. The two stopped requests call abort() again in their own catch, but the DOM Standard makes aborting an already aborted signal return without doing anything. In a separate check, calling abort() twice fired the abort event only once. The rejections of /b and /c after Promise.all had already rejected did not produce an unhandled rejection warning either.

DEV NOTES

Decide first what one failure should mean

Same three requests, three ways to combine them

  • Promise.all

    183 ms rejected · /b·/c still run

    • catch runs at the first failure
    • Other requests quietly keep going
  • Promise.allSettled

    607 ms rejected, fulfilled, fulfilled

    • Waits until every one ends
    • Check each success or failure
  • all + AbortController

    112 ms /b·/c AbortError

    • abort() at the first failure
    • Other fetches stop with AbortError

Values come from the same run on 2026-10-08. If partial results are still useful, use allSettled; if one failure makes the rest pointless, use Promise.all with abort.

All three send the same requests; they differ in what one failure does.
Output. # 2 Promise.allSettled: /a failed with Error at 104 ms, /b finished at 306 ms and /c at 607 ms; at 607 ms allSettled gave rejected, fulfilled, fulfilled. # 3 Promise.all + AbortController: /a failed at 103 ms, /b and /c stopped with AbortError at 112 ms, and Promise.all rejected with /a HTTP 500. server.log: the server saw the client close GET /b?case=3 and /c?case=3 and stopped the work.
The second and third cases from the same run. From the server log only the two client-close lines of the third case are shown; no values were changed.

abort() does not undo what the server has done

In the third case the server log shows server saw client close ... (work stopped). That happens only because the lab server was written that way. It listens for the response's close event and, if the connection closes before the response is sent, cancels the scheduled work with clearTimeout. What abort() does ends at making the client stop waiting and closing the connection. If the server does not listen for that, has already finished writing to the database, or has already asked another service to take a payment, that work stays done.

So when you combine requests that cannot be undone, such as saves or payments, do not treat abort() as a cancel button. Merge them into a single API call that handles them together, or design on the server what to undo when something fails. abort() is the right tool for cutting waste on requests whose result you no longer need, such as reads.

DEV NOTES

What abort() stops, and what it cannot

From the client to the server

  1. 1 Client: stops waiting

    ac.abort() → AbortError

    • fetch rejects with AbortError
    • Response handling never runs
  2. 2 Connection: closed

    res.on('close') → clearTimeout

    • The server can see the connection end
    • Its own code decides whether to stop
  3. 3 Work already done: stays

    abort ≠ rollback

    • A finished write or payment is not undone
    • Undo has to be designed separately

The lab server was written to stop work when the connection closes. That does not mean every server behaves this way.

abort() makes the client stop waiting and closes the connection. It does not cancel work the server has already done.

The takeaway

When Promise.all rejects, it only means "I will not wait any longer"; it does not mean the requests already sent were stopped. When you see Promise.all in your code, ask one question: if one fails, will I still use the other results? If yes, switch to Promise.allSettled. If no, pass the same AbortController signal to each request and call abort() on the first failure. And if requests that cannot be undone, such as saves, are in the mix, the work the server has already done needs separate handling either way.

THE ENDBack to the library
COMMENTS BOX

Add your perspective.

Share a question, another approach, or something you have tried.

Newest first

Checking sign-in…

Loading comments…