> cs·fundamentals
interview 0% 20m read
4.2 ★ core [J] 14 interview Q's

Async evolution & error handling

Callbacks → Promises → async/await, and the error-handling traps: unhandled rejections and floating promises.

Node’s async story is three layers deep: callbacks, then Promises, then async/await — each one a thinner syntax over the same event loop. The syntax got nicer, but the error-handling traps got subtler, and the worst ones fail silently.

Callbacks → Promises → async/await

The progression solves one problem: nesting. Callbacks compose by indenting, which doesn’t scale.

The same logic, three ways
// 1. Callbacks — the classic "pyramid of doom" / callback hell
getUser(id, (err, user) => {
  if (err) return done(err);
  getOrders(user, (err, orders) => {
    if (err) return done(err);
    getTotals(orders, (err, totals) => {
      if (err) return done(err);
      done(null, totals);          // error check repeated at every level
    });
  });
});

// 2. Promises — flatten the nesting, one .catch for the whole chain
getUser(id)
  .then(getOrders)
  .then(getTotals)
  .then((totals) => done(null, totals))
  .catch(done);

// 3. async/await — reads top-to-bottom like sync code
async function report(id) {
  const user   = await getUser(id);
  const orders = await getOrders(user);
  return getTotals(orders);
}

“Callback hell” was the rightward drift of #1: every async step adds a nesting level, error handling is duplicated at each, and there’s no clean way to run steps in parallel or try/catch across them. Promises flattened the chain; async/await let you write asynchronous control flow as if it were synchronous, including loops and try/catch.

callbacksgetUser(cb)if(err)getOrders(cb)if(err)getTotals(cb)if(err)nesting deepens · err checked 3×promises.then(getOrders).then(getTotals).catch(done)flat chain · ONE .catchasync/awaittry {u = await getUser()o = await getOrders(u)return getTotals(o)} catch (e) { … }straight-line · ONE try/catchless nesting · fewer error-handling sites →
FIG 1 · the evolution The same three-step async flow, three eras. Callbacks nest one level deeper per step and force an error check at EVERY level (red). Promises flatten the steps into a chain and collapse all error handling into a single trailing .catch. async/await reads as straight-line code with one try/catch wrapping the whole sequence. The flow is identical; only the shape of the code and where errors are handled changes.

try/catch vs .catch — pick the one matching the syntax

Both catch the same rejections; the rule is to match the error handler to the call style.

  • With async/await, a rejected Promise throws at the await, so a plain try/catch works — and crucially, it also catches synchronous throws in the same block.
  • With a Promise chain, there is no throw to catch synchronously; you attach .catch() to the end of the chain, which handles a rejection from any .then before it.
// async/await
try {
  const data = await fetchData();
  return process(data);
} catch (err) {
  log.error(err);            // catches fetchData() rejection AND a throw in process()
}

// equivalent Promise chain
return fetchData()
  .then(process)
  .catch((err) => log.error(err));
Predict the output: await and .then are both microtasks
console.log("1: start");

setTimeout(() => console.log("2: timeout"), 0);

Promise.resolve().then(() => console.log("3: then"));

(async () => {
  console.log("4: async sync part");
  await null;                       // suspends here
  console.log("5: after await");
})();

console.log("6: end");

Output:

1: start
4: async sync part
6: end
3: then
5: after await
2: timeout

The body of the async IIFE runs synchronously up to its await, so 4 prints in the main flow, between 1 and 6. Once the synchronous stack drains, the microtask queue runs in scheduling order: the .then (3) was queued before the await null continuation (5), so 3 precedes 5. The setTimeout (2) is a macrotask — it waits for the entire microtask queue to empty and the loop to reach the timers phase, so it prints last. Lesson: await continuations and .then callbacks share one microtask queue and both beat any timer.

Reach forWhenCatches / does
try/catch + awaityou're in an async functionrejections at each await AND sync throws in the block
.catch()you have a Promise chain (no await)a rejection from any earlier .then
.finally()cleanup that must run on success OR failureruns regardless; doesn't change the outcome
Promise.allindependent work, all must succeedresolves to all results; rejects on the first failure
Promise.allSettledindependent work, want every outcomenever rejects; array of {status, value/reason}
Promise.racefirst to settle wins (timeouts)settles with the first settled promise (fulfilled OR rejected)
Match the handler to the call style; pick the combinator by how you want failures treated.

Floating promises and unhandled rejections

A floating promise is one whose result nobody waits for. The danger is twofold: you lose ordering guarantees (the work may finish after you’ve already responded), and if it rejects, nothing handles it. That becomes an unhandled rejection.

// Floating: the route returns before saveAudit() finishes, and if it
// rejects, the rejection is unhandled.
app.post("/order", (req, res) => {
  saveAudit(req.body);            // ⚠ no await, no .catch, not returned
  res.json({ ok: true });
});

// Fixed — await it (or deliberately fire-and-forget WITH a .catch):
app.post("/order", async (req, res) => {
  await saveAudit(req.body);
  res.json({ ok: true });
});

01 Learning objectives

0 / 2 done

02 Knowledge check

knowledge check3 questions · pass ≥ 70%
  1. 01easy

    try/catch around `await somePromise()` will catch the promise's rejection.

  2. 02medium

    A “floating promise” is dangerous because:

  3. 03hard

    What is logged?

    async function f(){ throw new Error('boom'); }
    try { f(); } catch (e) { console.log('caught'); }
    console.log('after');

03 Interview questions

browse all ↗

What gets asked on this topic — tap a card for how to approach it, the follow-ups, and the trap. Company tags are best-effort & sourced.

  • Commonly asked junior concept common Trace the evolution callbacks → Promises → async/await. What problem did each step solve?

    Callbacks: the original async primitive — pass a function(err, result). The error-first convention is the norm, but nesting dependent async steps creates the deeply-indented "callback hell" / pyramid of doom, and error handling is manual at every level.

    Promises (ES2015): a first-class object representing a future value with .then/.catch. They flatten nesting into chains and give one .catch for the whole chain. Composition helpers: Promise.all, race, allSettled, any.

    async/await (ES2017): syntactic sugar over Promises. await lets you write asynchronous code that *reads* synchronously, and ordinary try/catch handles errors. Under the hood it is still Promises and microtasks.

    Red flag Claiming async/await makes code run on a background thread — it is the same single-threaded microtask machinery.

    source: MDN — Asynchronous JavaScript ↗
  • Commonly asked mid concept common What is a "floating promise," and why is it dangerous? Show a version of fetchUser() that silently loses errors.

    A floating promise is a Promise you create but never await, return, or attach .catch to. If it rejects, the rejection is unhandled — the error vanishes (and in modern Node, crashes the process).

    ``
    function handler(req, res) {
    saveToDb(req.body); // floating — no await, no .catch
    res.send("ok"); // responds 200 even if the DB write throws
    }
    `

    The client gets 200 OK while the write may have failed silently. Fixes: await saveToDb(...) (and wrap in try/catch), or return it, or attach .catch. Lint rules like @typescript-eslint/no-floating-promises` catch these.

    Red flag Assuming an un-awaited async call's errors will surface somewhere — they are lost unless explicitly handled.

    source: Node.js docs — process 'unhandledRejection' ↗
  • Commonly asked mid debug common Why doesn't this try/catch catch the error? try { setTimeout(() => { throw new Error("boom"); }, 0); } catch (e) { console.log("caught"); }

    It does not catch anything — the program crashes with an uncaught exception.

    try/catch only guards the synchronous execution of its block. By the time the setTimeout callback actually runs (a later event-loop tick), the try block has long since returned and its stack frame is gone. The thrown error has no surrounding catch, so it becomes an uncaughtException.

    To handle it, the try/catch must live inside the async callback, or use a Promise and .catch/await:

    ``
    setTimeout(() => {
    try { throw new Error("boom"); } catch (e) { console.log("caught"); }
    }, 0);
    ``

    Red flag Believing a synchronous try/catch can catch errors thrown from a later callback.

    source: Node.js docs — process 'uncaughtException' ↗
  • Commonly asked mid concept common Compare Promise.all, Promise.allSettled, Promise.race, and Promise.any. When would you pick each?

    - Promise.all — resolves with an array of all results; rejects on the first rejection (fail-fast). Use when you need *every* task to succeed (e.g. fan-out queries that all must return).
    - Promise.allSettled — never rejects; resolves with {status, value|reason} for each. Use when you want *all* results regardless of individual failures (e.g. notify N services, report which failed).
    - Promise.race — settles (resolve or reject) as soon as the first promise settles. Use for timeouts: race the work against a timer.
    - Promise.any — resolves with the first fulfilled value; rejects only if *all* reject (with an AggregateError). Use for redundancy: first successful mirror/replica wins.

    Red flag Confusing `race` (first to settle, including rejection) with `any` (first to fulfill), or assuming `all` cancels siblings on rejection.

    source: MDN — Promise.allSettled ↗
  • Commonly asked mid debug common What prints, and how long does it take? const a = await slow(1000); const b = await slow(1000); — vs — const [a, b] = await Promise.all([slow(1000), slow(1000)])

    The sequential version takes ~2000ms; the Promise.all version takes ~1000ms.

    In the first snippet each await *pauses* until that promise settles before the next call even starts — the two slow(1000) calls run back-to-back. In the second, both slow(1000) calls are invoked first (kicking off concurrently), and await Promise.all waits for both — so they overlap.

    The lesson: await in a sequence serializes independent work. If tasks do not depend on each other, start them together and await the aggregate.

    Red flag Awaiting independent operations one-by-one in a loop, turning parallelizable work into serial latency.

    source: MDN — Using Promises ↗
  • Commonly asked mid concept common How do you handle errors in async/await code, and what's the difference between unhandledRejection and uncaughtException?

    Within an async function, wrap awaited calls in try/catch; the catch receives whatever the awaited promise rejected with. For fire-and-forget chains, attach .catch. At the boundary (e.g. an Express route), funnel errors to a central error handler.

    The two process-level events:

    - unhandledRejection — a Promise rejected with no handler. Usually a bug (a floating promise). In current Node it terminates the process by default.
    - uncaughtException — a synchronous (or callback) error bubbled to the top with no try/catch.

    Both should be treated as last-resort: log, flush, and exit. The process is in an unknown state, so do not silently continue serving traffic.

    Red flag Using process.on('uncaughtException') to swallow errors and keep running — that hides corruption and leaks.

    source: Node.js docs — process events ↗
  • Commonly asked senior coding occasional You have an array of IDs and want to fetch each, but the upstream API rate-limits you. Why is `await Promise.all(ids.map(fetchOne))` risky, and what's a better pattern?

    Promise.all(ids.map(fetchOne)) fires all requests at once. With thousands of IDs you can exhaust sockets, blow memory, and trip the upstream rate limit — every request fails together.

    Better: bound the concurrency. Process in fixed-size batches, or use a concurrency-limiter (e.g. p-limit) so at most N run at a time:

    ``
    const limit = pLimit(5);
    const results = await Promise.all(
    ids.map((id) => limit(() => fetchOne(id)))
    );
    `

    This keeps Promise.all's aggregate semantics while capping in-flight requests at 5. For pure sequential needs, a plain for...of with await` works but is slow.

    Red flag Unbounded Promise.all over a large array — it looks elegant but is a classic source of overload and rate-limit failures.

    source: MDN — Promise.all ↗
  • ★ must-know Commonly asked mid concept common What does Node do by default when a promise rejects with no handler? Has this changed across versions?

    In current Node (the --unhandled-rejections=throw default since v15), an unhandled rejection is treated like an uncaught exception: Node prints the error and terminates the process with a non-zero exit code.

    This was a deliberate hardening. Older Node (≤ v14) only logged an UnhandledPromiseRejectionWarning and kept running — which let silent, half-broken state accumulate. The change forces you to handle rejections.

    You can still observe them via the process.on("unhandledRejection", ...) event (log/flush before exit), or override the mode with --unhandled-rejections=warn, but the right fix is to await/.catch the promise. Treat a crash here as a real bug, not noise.

    What a strong answer covers
    • Current default (throw, since v15): an unhandled rejection crashes the process with a non-zero code.

    • Node ≤ v14 only logged a warning and kept running — the old, dangerous behavior.

    • Hook process.on('unhandledRejection') to log/flush, but exit; don't swallow.

    • The real fix is upstream: await, return, or .catch the promise.

    Quick self-check

    By default in current Node, an unhandled promise rejection will:

    Red flag Assuming an unhandled rejection just logs a warning — in modern Node it terminates the process.

    source: Node.js docs — --unhandled-rejections=mode ↗
  • Commonly asked mid concept occasional What does util.promisify do, and why is the error-first callback convention what makes it possible?

    util.promisify(fn) wraps a function that follows Node's error-first callback convention — fn(...args, (err, result) => ...) — and returns a version that returns a Promise instead. The promise rejects with err if it's truthy, otherwise resolves with result.

    It works *only* because the callback shape is standardized: error first, single result second. promisify knows exactly where the error and value are, so it can mechanically translate callback → Promise. Functions with a different callback shape (multiple results, or callback-first) need promisify.custom or manual wrapping.

    In practice you reach for it less now because most core modules ship promise variants (fs.promises, dns.promises, timers/promises), but it's still the bridge for legacy callback APIs.

    What a strong answer covers
    • Converts an error-first callback API into one that returns a Promise.

    • Rejects on truthy err, resolves on the single result — exactly the error-first shape.

    • Non-standard callback shapes need util.promisify.custom.

    • Often unnecessary today: fs.promises, dns.promises, timers/promises exist.

    Red flag Promisifying a function whose callback isn't error-first (or callback-first) — the wrapper resolves/rejects wrongly.

    source: Node.js docs — util.promisify ↗
  • Commonly asked mid debug common What prints, and does the program crash? Promise.reject(new Error("boom")).catch(() => console.log("caught")); console.log("sync")

    sync then caught, and it does not crash.

    The .catch is attached synchronously, in the same expression — so the rejection has a handler from the start; it's never "unhandled." The handler runs as a microtask, after the synchronous console.log("sync"). So order is sync, then caught.

    Contrast with const p = Promise.reject(...); ... attach .catch later: as long as the handler is attached within the same tick, Node still treats it as handled. The danger is a rejection that reaches the end of a tick with *no* handler attached — that's what fires unhandledRejection.

    What a strong answer covers
    • .catch is attached in the same expression, so the rejection is handled — no crash.

    • The catch handler runs as a microtask, after synchronous code: sync then caught.

    • A rejection is 'unhandled' only if no handler is attached by the end of the tick.

    • Attaching .catch even a few lines later (same tick) still counts as handled.

    Quick self-check

    What is the output, and does it crash?

    Red flag Thinking a synchronously-caught rejection crashes — it's handled, and the handler is just a microtask.

    source: MDN — Promise.prototype.catch ↗
  • Commonly asked mid concept common Why is `[1,2,3].forEach(async (x) => { await save(x); })` a trap? What happens to errors and ordering?

    forEach ignores the return value of its callback. Your callback returns a Promise, but forEach discards it — so nothing awaits the saves. The result:

    - No waiting: code after the forEach runs *before* any save finishes; you can't sequence anything after it.
    - Lost errors: each callback's promise floats; a rejection becomes an unhandledRejection rather than something you can catch.
    - No ordering guarantee relative to the surrounding code.

    Use for...of with await for sequential, or await Promise.all(arr.map(fn)) for concurrent. Both actually wait and let errors propagate.

    ``
    for (const x of [1,2,3]) await save(x); // sequential
    await Promise.all([1,2,3].map((x) => save(x))); // concurrent
    ``

    What a strong answer covers
    • forEach discards the callback's returned Promise — the awaits are never awaited by the caller.

    • Code after the forEach runs before the saves complete (no sequencing).

    • Rejections float → unhandledRejection, not catchable at the call site.

    • Use for...of + await (sequential) or Promise.all(map(...)) (concurrent).

    Red flag Passing an async function to forEach and assuming the loop waits — it doesn't, and errors are lost.

    source: MDN — Array.prototype.forEach (Caveats / async) ↗
  • Commonly asked senior concept occasional Why is it unsafe to keep the process alive after an 'uncaughtException'? What's the correct response?

    An uncaughtException means an error escaped all try/catch and bubbled to the top. At that point you have no idea what state the program is in — a half-finished write, a held lock, a corrupted in-memory structure, a leaked connection. Continuing to serve traffic on top of that corruption risks silently wrong results and resource leaks.

    Node's own docs are explicit: the handler is for synchronous cleanup, not for resuming normal operation. The correct pattern is to log the error, flush logs/metrics, release critical resources, and exit with a non-zero code — then let your process manager (systemd, Kubernetes, PM2) restart a fresh, clean process.

    For graceful handling of *expected* errors, catch them where they occur; uncaughtException is the last-resort net, not a control-flow mechanism.

    What a strong answer covers
    • After uncaughtException the process state is unknown/corrupt — locks, writes, structures may be half-done.

    • Node docs: the handler is for sync cleanup, not for resuming work.

    • Correct response: log, flush, release resources, exit non-zero; let a supervisor restart.

    • Use it as a last-resort net; handle expected errors at their source.

    Red flag Using process.on('uncaughtException') to swallow and continue — it masks corruption and leaks.

    source: Node.js docs — Warning: Using 'uncaughtException' correctly ↗
  • Commonly asked senior concept occasional What does async/await actually compile to, and why does that mean two awaits in a row are slower than Promise.all?

    await expr is syntactic sugar for taking the promise expr resolves to and suspending the function until it settles, scheduling the continuation as a microtask — roughly Promise.resolve(expr).then(continuation). The function literally pauses at each await and resumes only after that promise settles.

    So const a = await f(); const b = await g(); cannot start g() until f() has fully settled — they are serialized, total time ≈ time(f) + time(g). With Promise.all([f(), g()]), both f() and g() are *invoked synchronously first* (kicking off concurrently), and you await the aggregate — total ≈ max(f, g).

    The mental model: await is a pause point, not a parallelizer. Start independent work before you await it.

    What a strong answer covers
    • await ≈ pause the function and resume its continuation as a microtask once the promise settles.

    • Sequential awaits serialize: each starts only after the previous settles.

    • Promise.all invokes all the calls first, then awaits the aggregate → overlap.

    • Use sequential awaits only when later work *depends* on the earlier result.

    Red flag Treating await as 'fire concurrently' — it's a suspension point; independent awaits run one after another.

    source: MDN — await ↗
  • Commonly asked senior coding occasional How do you add a timeout to an async operation that has no built-in timeout, and what's the catch with AbortController?

    The classic pattern is Promise.race between the work and a timer that rejects:

    ``
    function withTimeout(p, ms) {
    return Promise.race([
    p,
    new Promise((_, rej) =>
    setTimeout(() => rej(new Error("timeout")), ms)),
    ]);
    }
    `

    The catch: Promise.race only stops waiting — it does not cancel the underlying work. The original promise keeps running (the request still completes, the socket stays open), and the leftover setTimeout keeps the loop alive unless you clearTimeout it. So you can leak timers and in-flight requests.

    The better tool when the API supports it is AbortController: pass controller.signal to fetch/streams/etc. and call controller.abort() on timeout to actually cancel the work and release resources. AbortSignal.timeout(ms)` is the built-in shorthand. The catch with AbortController: it only works if the callee honors the signal — it can't cancel arbitrary code that ignores it.

    What a strong answer covers
    • Promise.race([work, timeoutReject]) is the standard timeout pattern.

    • race stops *waiting* but does not cancel the underlying work — it keeps running.

    • Clear the timer (clearTimeout) or it can keep the event loop alive / leak.

    • Prefer AbortController / AbortSignal.timeout(ms) to truly cancel — but only if the callee honors the signal.

    Red flag Assuming Promise.race cancels the slow operation — it only stops awaiting it; the work and timer can leak.

    source: MDN — AbortController ↗