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.
// 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.
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 theawait, so a plaintry/catchworks — and crucially, it also catches synchronous throws in the same block. - With a Promise chain, there is no
throwto catch synchronously; you attach.catch()to the end of the chain, which handles a rejection from any.thenbefore 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));
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: timeoutThe 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 for | When | Catches / does |
|---|---|---|
try/catch + await | you're in an async function | rejections 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 failure | runs regardless; doesn't change the outcome |
Promise.all | independent work, all must succeed | resolves to all results; rejects on the first failure |
Promise.allSettled | independent work, want every outcome | never rejects; array of {status, value/reason} |
Promise.race | first to settle wins (timeouts) | settles with the first settled promise (fulfilled OR rejected) |
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 done02 Knowledge check
- 01easy
try/catch around `await somePromise()` will catch the promise's rejection.
- 02medium
A “floating promise” is dangerous because:
- 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.
-
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.catchfor the whole chain. Composition helpers:Promise.all,race,allSettled,any.async/await (ES2017): syntactic sugar over Promises.
awaitlets you write asynchronous code that *reads* synchronously, and ordinarytry/catchhandles errors. Under the hood it is still Promises and microtasks.Follow-ups they push on- Is async/await just Promises under the hood? (Yes.)
- When would you still reach for raw Promise combinators over await?
Red flag Claiming async/await makes code run on a background thread — it is the same single-threaded microtask machinery.
source: MDN — Asynchronous JavaScript ↗ -
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.catchto. 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), orreturnit, or attach.catch. Lint rules like@typescript-eslint/no-floating-promises` catch these.Follow-ups they push on- What does Node do by default on an unhandled rejection in current LTS?
- How does the `no-floating-promises` lint rule help?
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' ↗ -
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/catchonly guards the synchronous execution of its block. By the time thesetTimeoutcallback actually runs (a later event-loop tick), thetryblock has long since returned and its stack frame is gone. The thrown error has no surrounding catch, so it becomes anuncaughtException.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);Follow-ups they push on- Why does try/catch around an `await`ed Promise work, but not around a bare callback?
- What is the last-resort safety net for uncaught exceptions, and why shouldn't you keep running after one?
Red flag Believing a synchronous try/catch can catch errors thrown from a later callback.
source: Node.js docs — process 'uncaughtException' ↗ -
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 anAggregateError). Use for redundancy: first successful mirror/replica wins.Follow-ups they push on- How do you implement a timeout with Promise.race?
- With Promise.all, do the other promises stop running when one rejects? (No — they keep going.)
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 ↗ -
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.allversion takes ~1000ms.In the first snippet each
await*pauses* until that promise settles before the next call even starts — the twoslow(1000)calls run back-to-back. In the second, bothslow(1000)calls are invoked first (kicking off concurrently), andawait Promise.allwaits for both — so they overlap.The lesson:
awaitin a sequence serializes independent work. If tasks do not depend on each other, start them together and await the aggregate.Follow-ups they push on- How would you write this so b's input depends on a's result? (Then sequential is correct.)
- What's the bug in `for (const url of urls) await fetch(url)` when order doesn't matter?
Red flag Awaiting independent operations one-by-one in a loop, turning parallelizable work into serial latency.
source: MDN — Using Promises ↗ -
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; thecatchreceives 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.
Follow-ups they push on- Why is it unsafe to keep the process alive after an uncaughtException?
- Where should the single catch-all error handler live in an Express app?
Red flag Using process.on('uncaughtException') to swallow errors and keep running — that hides corruption and leaks.
source: Node.js docs — process events ↗ -
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 plainfor...ofwithawait` works but is slow.Follow-ups they push on- How would you also add retry-with-backoff for the rate-limit 429s?
- Why is allSettled sometimes better than all here?
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 ↗ -
What does Node do by default when a promise rejects with no handler? Has this changed across versions?
In current Node (the
--unhandled-rejections=throwdefault 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
UnhandledPromiseRejectionWarningand 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 toawait/.catchthe promise. Treat a crash here as a real bug, not noise.What a strong answer coversCurrent 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.catchthe promise.
Quick self-checkBy default in current Node, an unhandled promise rejection will:
-
Wrong: that was the pre-v15 behavior; it's no longer the default.
-
Correct — the default mode is `throw`, treating it like an uncaught exception.
-
Wrong: Node never silently ignores rejections; at minimum it warns, and now it crashes.
-
Wrong: Node has no retry mechanism for rejected promises.
Follow-ups they push on- Why was 'log and keep running' considered dangerous enough to change the default?
- What's the difference between the unhandledRejection and rejectionHandled events?
Red flag Assuming an unhandled rejection just logs a warning — in modern Node it terminates the process.
source: Node.js docs — --unhandled-rejections=mode ↗ -
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 witherrif it's truthy, otherwise resolves withresult.It works *only* because the callback shape is standardized: error first, single result second.
promisifyknows 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) needpromisify.customor 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 coversConverts an error-first callback API into one that returns a Promise.
Rejects on truthy
err, resolves on the singleresult— exactly the error-first shape.Non-standard callback shapes need
util.promisify.custom.Often unnecessary today:
fs.promises,dns.promises,timers/promisesexist.
Follow-ups they push on- How would you promisify a callback that returns multiple result values?
- When would you still use util.promisify instead of the promise-native API?
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 ↗ -
What prints, and does the program crash? Promise.reject(new Error("boom")).catch(() => console.log("caught")); console.log("sync")
syncthencaught, and it does not crash.The
.catchis 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 synchronousconsole.log("sync"). So order issync, thencaught.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 firesunhandledRejection.What a strong answer covers.catchis attached in the same expression, so the rejection is handled — no crash.The catch handler runs as a microtask, after synchronous code:
syncthencaught.A rejection is 'unhandled' only if no handler is attached by the end of the tick.
Attaching
.catcheven a few lines later (same tick) still counts as handled.
Quick self-checkWhat is the output, and does it crash?
-
Wrong order: the .catch handler is a microtask, so it runs after synchronous `sync`.
-
Correct — sync runs first, the catch microtask runs after, and the rejection is handled.
-
Wrong: a .catch is attached, so the rejection is handled and there's no crash.
-
Wrong: the synchronous console.log('sync') always runs.
Follow-ups they push on- What would change if you removed the .catch entirely?
- Does attaching .catch in a later setTimeout still prevent unhandledRejection?
Red flag Thinking a synchronously-caught rejection crashes — it's handled, and the handler is just a microtask.
source: MDN — Promise.prototype.catch ↗ -
Why is `[1,2,3].forEach(async (x) => { await save(x); })` a trap? What happens to errors and ordering?
forEachignores the return value of its callback. Your callback returns a Promise, butforEachdiscards it — so nothingawaits the saves. The result:- No waiting: code after the
forEachruns *before* anysavefinishes; you can't sequence anything after it.
- Lost errors: each callback's promise floats; a rejection becomes anunhandledRejectionrather than something you can catch.
- No ordering guarantee relative to the surrounding code.Use
for...ofwithawaitfor sequential, orawait 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))); // concurrentWhat a strong answer coversforEachdiscards 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) orPromise.all(map(...))(concurrent).
Follow-ups they push on- Which replacement gives sequential vs concurrent execution?
- Do .map and .filter have the same async problem as forEach?
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) ↗ -
Why is it unsafe to keep the process alive after an 'uncaughtException'? What's the correct response?
An
uncaughtExceptionmeans 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;
uncaughtExceptionis the last-resort net, not a control-flow mechanism.What a strong answer coversAfter 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.
Follow-ups they push on- What process-level supervisor would restart the exited process in a container?
- How does the domain module / AsyncLocalStorage relate to error isolation?
Red flag Using process.on('uncaughtException') to swallow and continue — it masks corruption and leaks.
source: Node.js docs — Warning: Using 'uncaughtException' correctly ↗ -
What does async/await actually compile to, and why does that mean two awaits in a row are slower than Promise.all?
await expris syntactic sugar for taking the promiseexprresolves to and suspending the function until it settles, scheduling the continuation as a microtask — roughlyPromise.resolve(expr).then(continuation). The function literally pauses at eachawaitand resumes only after that promise settles.So
const a = await f(); const b = await g();cannot startg()untilf()has fully settled — they are serialized, total time ≈ time(f) + time(g). WithPromise.all([f(), g()]), bothf()andg()are *invoked synchronously first* (kicking off concurrently), and you await the aggregate — total ≈ max(f, g).The mental model:
awaitis a pause point, not a parallelizer. Start independent work before you await it.What a strong answer coversawait≈ 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.allinvokes all the calls first, then awaits the aggregate → overlap.Use sequential awaits only when later work *depends* on the earlier result.
Follow-ups they push on- How would you start two awaits concurrently without Promise.all? (Assign the promises first, await later.)
- Does code before the first await run synchronously? (Yes.)
Red flag Treating await as 'fire concurrently' — it's a suspension point; independent awaits run one after another.
source: MDN — await ↗ -
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.racebetween 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 leftoversetTimeoutkeeps the loop alive unless youclearTimeoutit. So you can leak timers and in-flight requests.The better tool when the API supports it is AbortController
: pass controller.signaltofetch/streams/etc. and callcontroller.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 coversPromise.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.
Follow-ups they push on- Why does the work keep running after Promise.race rejects on timeout?
- What does AbortController give you that Promise.race can't?
Red flag Assuming Promise.race cancels the slow operation — it only stops awaiting it; the work and timer can leak.
source: MDN — AbortController ↗