skip to content

You start two async calls without awaiting them, then await them one after the other: `const a = fetchA(); const b = fetchB(); const ra = await a; const rb = await b;`. Why can this produce an unhandled rejection that kills a Node process, even though both promises are eventually awaited?

level: seniorimportance: should knowfreq 42%

answer

  1. starting is not the same as subscribing
  2. one await parks, the other has not run
  3. a gap where nobody owns the promise
  4. the loser rejects during the wait
  5. combinators subscribe to all inputs at once

basics

~20 s

Between the two awaits, promise b has no handler attached. If b rejects while execution is parked on await a, the runtime sees a handler-less rejection and reports it — in Node that terminates the process before the await b line ever runs.

solid answer

~40 s

`await a` attaches a handler to `a` only. `b` is running but unclaimed until control reaches the `await b` line, and that line may never be reached in time. If `b` rejects during the wait, the runtime's unhandled-rejection check finds no handler on it and reports it: the browser fires `unhandledrejection`, and Node by default raises an uncaught exception and exits. There is a second version of the same bug: if `a` rejects, `await a` throws, the function unwinds, and `b` is abandoned with no handler at all. The fix is to attach handlers to every promise at the moment you start it — `const [ra, rb] = await Promise.all([a, b])` does exactly that, subscribing to both synchronously, which is why the parallel form is safer as well as faster.

code

javascript · 20 lines
javascript
const slow = () => new Promise((res) => setTimeout(res, 500, 'a'));
const fastFail = () => new Promise((_, rej) =>
  setTimeout(rej, 10, new Error('b failed')));

async function broken() {
  const a = slow();
  const b = fastFail();      // unowned for the next 500 ms
  try {
    const ra = await a;      // parked here when b rejects
    const rb = await b;      // may never be reached
    return [ra, rb];
  } catch (err) {
    return ['handled', err.message]; // too late to prevent the report
  }
}

async function fixed() {
  // Promise.all subscribes to both inputs synchronously.
  return Promise.all([slow(), fastFail()]);
}

go deeper

for a junior

Remember that starting a promise and awaiting it are separate steps, and a promise that has been started but not yet awaited has nobody listening if it fails.

for a middle

Explain the window precisely: await a subscribes only to a, so b is handler-less until control reaches its own await, and describe how Promise.all closes the window by subscribing to every input synchronously.

for a senior

Diagnose it from symptoms — an unhandled rejection whose reason your code visibly catches — and know the mirror-image leak where a rejecting first await abandons the second promise entirely.

for a principal

Set the convention that removes the whole class: start and subscribe in the same breath, standardise on combinators for concurrent work, and treat any gap between creating a promise and claiming it as a reviewable defect.

## The window where b is unowned Starting a promise and awaiting it are two separate acts. `fetchB()` begins the work immediately; the `await b` expression is what subscribes to its outcome. In between those two moments — a window that lasts as long as `a` takes — `b` is a running operation with no handler attached to its rejection path. The unhandled-rejection check does not wait for your intentions. Once the turn in which `b` rejected has finished unwinding, the host is told that a promise rejected with nobody listening. It cannot know that four lines further down you were going to `await` it. ```js const a = slow(); // 500 ms, fulfils const b = fast(); // 10 ms, rejects const ra = await a; // parked here when b rejects -> reported unhandled const rb = await b; // in Node, this line may never run ``` In a browser this shows up as a spurious `unhandledrejection` in your error reporter for a failure your code actually does handle — noisy, but survivable. In Node with the default policy it is fatal: the rejection is raised as an uncaught exception and the process exits, so the `await b` that would have caught it never executes. ## The mirror-image bug Even when the timing does not trip the detector, the sequential form leaks: ```js const a = fetchA(); const b = fetchB(); const ra = await a; // throws const rb = await b; // never evaluated ``` If `a` rejects, the `await` throws, the async function unwinds to its caller's `catch`, and `b` is left floating forever. When `b` eventually rejects, you get an unhandled rejection attributable to a request that already failed and was already reported — a confusing second alarm, or another crash. ## Why Promise.all is the fix `Promise.all` attaches its internal handlers to every input promise synchronously, before it returns. From that instant every promise in the array has an owner: ```js const [ra, rb] = await Promise.all([fetchA(), fetchB()]); ``` If `b` rejects first, the aggregate promise rejects with that reason, `a`'s eventual outcome is already subscribed to, and there is no handler-less window anywhere. The same is true of `Promise.allSettled`, `Promise.race` and `Promise.any` — all of them subscribe to every input immediately, which is why they never produce this class of spurious report. It is worth being precise about what `Promise.all` does *not* fix: if both reject, only the first reason is delivered to you. The second rejection is still consumed — `Promise.all` attached a handler to it — so nothing is reported as unhandled, but the reason is discarded. If you need every failure, `Promise.allSettled` is the combinator that gives you all of them. ## When you genuinely want to start early and await later Sometimes overlapping the work is the point and you cannot restructure into a single `Promise.all` — for example a value you start warming at module load and consume much later. The rule is the same: claim the promise at creation time. ```js const warmed = loadCatalogue(); warmed.catch(() => {}); // claim it; consumers still see the rejection async function handler() { const data = await warmed; // still throws here if it failed } ``` The empty handler subscribes to `warmed` so it is never handler-less across a turn boundary; the promise it returns is discarded, and every real consumer still observes the rejection when they `await warmed`. Used anywhere else this idiom would be error-swallowing, which is why it deserves a comment saying exactly what it is for. ## Diagnosing it in the wild The signature in production is an unhandled-rejection report — or a process exit — whose reason is an error your code *does* have a `catch` for. That contradiction is the tell: the handler exists, it just was not attached yet. Look for pairs of `await`s on promises that were started earlier, especially in code that was "optimised" from sequential to concurrent by hoisting the calls above the awaits without switching to a combinator. The general rule to state: **start and subscribe in the same breath.** Either `await` immediately, or hand the promise to a combinator that subscribes for you, or attach a `.catch()` at the point of creation. Any gap between creation and subscription is a window in which a rejection escapes.

  • If Promise.all rejects because both inputs failed, is the second rejection reported as unhandled?
    No. `Promise.all` attached a handler to every input the moment it was called, so both rejections are consumed. Only the first reason reaches you; the second is discarded silently. That is a data-loss tradeoff, not a safety one — if you need every failure, use `Promise.allSettled`, which reports the outcome of each input individually.
  • How would you recognise this bug from a production error report alone?
    Look for an unhandled rejection whose reason is an error your code demonstrably catches, or a Node process exit with a stack pointing at work that sits inside a try/catch. That contradiction means the handler exists but was not attached yet, which points straight at promises started above the awaits that consume them.
  • Is there a safe way to start work early and await it much later?
    Yes — claim it at creation. Attach a no-op `.catch(() => {})` to the promise you store, and let consumers await the original; they still see the rejection, but the promise is never handler-less across a turn boundary. Keep the idiom rare and commented, because in any other position an empty catch really is error swallowing.

saying these in an interview costs you the question

  • Says both awaits are present so the rejection is handled
  • Thinks awaiting one promise subscribes to the others too
  • Claims the try/catch around both awaits prevents the report
  • Believes Promise.all and sequential awaits behave identically
  • Assumes only the slowest promise's failure matters

context