Promises & Async/Await
โฑ๏ธ ~5-minute bite ยท solve the sandbox to master
5-Year-Old Metaphor
โ The physical, real-world picture. No jargon.๐ฝ๏ธ A Promise is a catering order slip โ you hand the kitchen a request, get a slip back immediately, and attach handlers for when the food arrives (or gets 86'd).
The catering analogy
You walk up to the kitchen window and place an order. The kitchen hands you an order slip (the Promise) immediately โ you don't wait at the counter. The slip starts in pending state. When the food is ready the kitchen calls out your number (resolve). If they run out of ingredients, they send back an โ86'dโ notice (reject). You pre-attach instructions to the slip: โwhen ready, do Xโ (.then), or โif 86'd, do Yโ (.catch).
The 3 states โ immutable once settled
โณ Pending
Initial state. Mutable โ can still transition to fulfilled or rejected.
โ Fulfilled
Resolved with a value. Immutable โ cannot change again. .then handlers fire.
โ Rejected
Rejected with a reason. Immutable. .catch handlers fire.
Once a promise settles (fulfills or rejects), calling resolve() or reject() again is a no-op. The state is permanently locked.
async/await is syntactic sugar โ exact transformation
async/await (sugar)
| 1 | async function getData() { |
| 2 | const x = await somePromise(); |
| 3 | const y = await otherPromise(x); |
| 4 | return y; |
| 5 | } |
Desugared to .then()
| 1 | function getData() { |
| 2 | return somePromise() |
| 3 | .then(x => otherPromise(x)) |
| 4 | .then(y => y); |
| 5 | } |
The async keyword wraps the return value in a Promise. Each await desugars to a .then() call, with everything after it as the callback. The suspension mechanics are identical โ only the syntax differs.
Promise combinators โ choose the right tool
| Combinator | Fulfills when | Rejects when |
|---|---|---|
| Promise.all() | All fulfill | Any single one rejects (fast-fail) |
| Promise.allSettled() | All settle (fulfill or reject) | Never โ returns all outcomes |
| Promise.race() | First settled (fulfill or reject) | If first settled is rejected |
| Promise.any() | First to fulfill | All reject (AggregateError) |
Interactive Sandbox
โ Move something, see it react instantly.Pattern
Code
| 1 | new Promise(resolve => resolve(42)) |
| 2 | .then(v => console.log(v)); |
Promise state
โณ PendingMicrotask queue
Queue: empty
Console output
(no output yet)
Steps
Challenge
Explore all 5 Promise patterns, stepping through each to the final stage. Pay special attention to async/await ordering โ it surprises everyone.
Why Should I Care?
โ The exact interview question + the bug it kills.Interview questions
Q: What is Promise chaining and why does each .then return a new Promise?
| 1 | Promise.resolve(1) |
| 2 | .then(x => x + 1) // returns NEW Promise(fulfilled: 2) |
| 3 | .then(x => x * 2) // returns NEW Promise(fulfilled: 4) |
| 4 | .then(console.log); // 4 |
Each .then() returns a brand-new Promise whose settled value is whatever the handler returns. This enables chaining because each link in the chain is independent โ you can branch, catch errors mid-chain, or return a new Promise from inside a handler (flatMap semantics).
Q: What happens when you forget to return inside .then?
| 1 | Promise.resolve(1) |
| 2 | .then(x => { x + 1; }) // โ no return! returns undefined |
| 3 | .then(x => console.log(x)); // undefined |
| 4 | ย |
| 5 | // Fix: |
| 6 | Promise.resolve(1) |
| 7 | .then(x => x + 1) // implicit return |
| 8 | .then(x => console.log(x)); // 2 |
Missing return is the #1 Promise bug. The next .then receives undefined because the handler implicitly returns undefined. Lint rule: no-floating-promises + consistent-return.
Q: Difference between .catch(fn) and .then(null, fn)?
| 1 | // These are functionally identical: |
| 2 | p.catch(fn) |
| 3 | p.then(null, fn) // .catch is sugar for this |
| 4 | ย |
| 5 | // KEY DIFFERENCE โ .catch at end catches errors in .then too: |
| 6 | p.then(onFulfilled).catch(onRejected) |
| 7 | // โ catches errors thrown by onFulfilled too |
| 8 | ย |
| 9 | p.then(onFulfilled, onRejected) |
| 10 | // โ onRejected does NOT catch errors thrown by onFulfilled |
Prefer .then(onFulfilled).catch(onRejected) โ it catches errors from both the original promise AND the fulfillment handler. The two-argument form is a footgun.
Q: async function always returns a Promise โ even for plain values. Why?
| 1 | async function greet() { return "hello"; } |
| 2 | greet(); // Promise { <fulfilled: "hello"> } |
| 3 | greet() === "hello"; // false โ always a Promise |
| 4 | ย |
| 5 | // Even an error is wrapped: |
| 6 | async function boom() { throw new Error("!"); } |
| 7 | boom(); // Promise { <rejected: Error> } โ never throws sync |
This is by design. An async function is a uniform interface: callers always get a Promise back, whether the function is truly async or just returns a plain value. It prevents โsometimes asyncโ APIs which are notoriously hard to consume correctly.
Production bugs
Bug 1: Unhandled promise rejections crashing Node.js
| 1 | // โ CRASH โ missing .catch |
| 2 | async function fetchUser(id) { |
| 3 | return db.query(`SELECT * FROM users WHERE id=${id}`); |
| 4 | } |
| 5 | fetchUser(null); // Promise rejected but nobody handles it |
| 6 | ย |
| 7 | // Node.js v15+: process exits with code 1 |
| 8 | // Fix A: always .catch() or await in try/catch |
| 9 | fetchUser(null).catch(console.error); |
| 10 | ย |
| 11 | // Fix B: global handler (safety net, not primary) |
| 12 | process.on("unhandledRejection", (reason, promise) => { |
| 13 | console.error("Unhandled rejection:", reason); |
| 14 | // graceful shutdown: drain connections, then exit |
| 15 | process.exit(1); |
| 16 | }); |
Bug 2: forEach + async/await โ forEach doesn't await
| 1 | // โ WRONG โ forEach ignores returned Promises |
| 2 | const ids = [1, 2, 3]; |
| 3 | ids.forEach(async (id) => { |
| 4 | await processItem(id); // these run concurrently & unordered! |
| 5 | }); |
| 6 | console.log("done"); // prints before any processItem finishes |
| 7 | ย |
| 8 | // โ Sequential โ for...of respects await |
| 9 | for (const id of ids) { |
| 10 | await processItem(id); // truly sequential |
| 11 | } |
| 12 | ย |
| 13 | // โ Concurrent but ordered โ Promise.all + map |
| 14 | await Promise.all(ids.map(id => processItem(id))); |
Array.forEachwas designed before async/await. It doesn't await the returned Promise from the callback โ each iteration fires immediately without waiting. Use for...of for sequential or Promise.all(arr.map(...)) for concurrent.
The Deep Dive
โ Spec refs, engine internals, the minutiae.ES spec: PromiseReactionJob & microtask scheduling
Per ECMA-262 ยง27.2, when a promise is resolved, the engine enqueues a PromiseReactionJob on the microtask queue (called the PromiseJobs queue in the spec). This job, when processed, calls your .then handler with the settled value.
| 1 | // Simplified spec pseudocode for .then(): |
| 2 | Promise.prototype.then = function(onFulfilled) { |
| 3 | const resultPromise = new Promise(...); |
| 4 | // EnqueueJob("PromiseJobs", PromiseFulfillReactionJob, |
| 5 | // [{ capability: resultPromise, handler: onFulfilled }]) |
| 6 | return resultPromise; |
| 7 | }; |
| 8 | ย |
| 9 | // Order guarantee: |
| 10 | setTimeout(() => console.log("macro"), 0); |
| 11 | Promise.resolve().then(() => console.log("micro")); |
| 12 | console.log("sync"); |
| 13 | // Output: sync โ micro โ macro |
| 14 | // Microtasks drain BEFORE the next macrotask |
Microtasks (Promise callbacks, queueMicrotask, MutationObserver) always run after the current synchronous code finishes and before the event loop picks up the next macrotask (setTimeout, I/O callbacks).
Promise resolution procedure ยง27.2.1.3 โ thenables
When you resolve a promise with a thenable (any object with a .thenmethod), the spec triggers the Promise Resolution Procedure recursively โ it doesn't just adopt the value, it follows the chain.
| 1 | // Returning a Promise from .then(): |
| 2 | Promise.resolve(1) |
| 3 | .then(x => Promise.resolve(x + 1)) // โ returning a Promise |
| 4 | .then(x => console.log(x)); // 2 โ (not Promise object) |
| 5 | ย |
| 6 | // The spec calls .then() on the inner promise, |
| 7 | // adding an EXTRA microtask tick. This matters: |
| 8 | console.log("a"); |
| 9 | Promise.resolve().then(() => { |
| 10 | console.log("b"); |
| 11 | return Promise.resolve(); // adds extra tick! |
| 12 | }).then(() => console.log("c")); |
| 13 | Promise.resolve().then(() => console.log("d")); |
| 14 | // a โ b โ d โ c (not a โ b โ c โ d) |
This โextra tickโ when a .then handler returns a native Promise is mandated by the spec (ยง27.2.2.1.3.fโg) and differs from returning a plain value. It trips up ordering assumptions in concurrent code.
async/await desugaring โ generators under the hood
In the original TC39 proposal, async functions were specified as generators (function*) wrapped in a driver. Modern engines implement them as direct CPS (continuation-passing style) transforms for performance, but the semantics are identical:
| 1 | // Generator-based equivalent (conceptual): |
| 2 | function asyncRunner(genFn) { |
| 3 | return new Promise((resolve, reject) => { |
| 4 | const gen = genFn(); |
| 5 | function step(value) { |
| 6 | const { done, value: result } = gen.next(value); |
| 7 | if (done) { resolve(result); return; } |
| 8 | Promise.resolve(result).then(step, reject); |
| 9 | } |
| 10 | step(undefined); |
| 11 | }); |
| 12 | } |
| 13 | ย |
| 14 | // So this: |
| 15 | async function fn() { |
| 16 | const x = await p1(); |
| 17 | const y = await p2(x); |
| 18 | return y; |
| 19 | } |
| 20 | ย |
| 21 | // Becomes (conceptually): |
| 22 | const fn = () => asyncRunner(function*() { |
| 23 | const x = yield p1(); |
| 24 | const y = yield p2(x); |
| 25 | return y; |
| 26 | }); |
Promise.allSettled vs Promise.all โ when to use each
| 1 | // Promise.all โ stop on first failure |
| 2 | const results = await Promise.all([ |
| 3 | fetchUser(1), |
| 4 | fetchUser(2), |
| 5 | fetchUser(3), |
| 6 | ]); // throws if any fetchUser throws |
| 7 | ย |
| 8 | // Promise.allSettled โ always get all outcomes |
| 9 | const settled = await Promise.allSettled([ |
| 10 | fetchUser(1), |
| 11 | fetchUser(2), |
| 12 | fetchUser(3), |
| 13 | ]); |
| 14 | // [ |
| 15 | // { status: "fulfilled", value: User }, |
| 16 | // { status: "rejected", reason: Error }, |
| 17 | // { status: "fulfilled", value: User }, |
| 18 | // ] |
| 19 | const succeeded = settled |
| 20 | .filter(r => r.status === "fulfilled") |
| 21 | .map(r => r.value); |
| 22 | const failed = settled |
| 23 | .filter(r => r.status === "rejected"); |
Use Promise.all when all results are required to proceed and one failure should abort the whole operation (e.g. loading dependencies). Use Promise.allSettledwhen you want to handle partial success (e.g. batch operations where some may fail).
Top-level await & async iterators
| 1 | // Top-level await (ES2022, ESM only): |
| 2 | // In a .mjs file or "type": "module" package.json: |
| 3 | const config = await fetch("/config.json").then(r => r.json()); |
| 4 | export default config; // module waits for this before exporting |
| 5 | ย |
| 6 | // Async iterators โ for await...of: |
| 7 | async function* streamLines(url) { |
| 8 | const res = await fetch(url); |
| 9 | const reader = res.body.getReader(); |
| 10 | const decoder = new TextDecoder(); |
| 11 | while (true) { |
| 12 | const { done, value } = await reader.read(); |
| 13 | if (done) break; |
| 14 | yield decoder.decode(value); |
| 15 | } |
| 16 | } |
| 17 | ย |
| 18 | for await (const line of streamLines("/api/stream")) { |
| 19 | console.log(line); // each chunk as it arrives |
| 20 | } |
for await...of works with any async iterable โ an object with [Symbol.asyncIterator]() that returns an iterator of Promises. Node.js streams are async iterables natively since v12.
Patterns to know cold
promisify(fn)
Wrap a Node.js callback-style function into a Promise. node:util.promisify does this.
retry(fn, n)
Recursively await fn(), decrement n on failure. Exponential backoff via delay promise.
timeout(p, ms)
Promise.race([p, new Promise((_, r) => setTimeout(r, ms, new Error('timeout')))])
deferred()
Expose resolve/reject outside the constructor. Pattern: let res, rej; const p = new Promise((r,j)=>{res=r;rej=j})
concurrentLimit(fns, n)
Process a queue with at most N in-flight promises at a time. Use p-limit library in production.
memoizeAsync(fn)
Cache the Promise itself (not just the value) to prevent duplicate in-flight requests for the same key.
Interview Questions
โ Real questions from real interviews โ with answers.The two-arg form doesn't catch errors thrown by `onFulfilled`; `.catch` at the end does.
All async functions always return a Promise โ even when the return value is a plain scalar.
`forEach` was designed before async/await โ it ignores the Promise returned by async callbacks.
Use `Promise.all` when all results are required; use `Promise.allSettled` when you need all outcomes including partial failures.
Returning a Promise from `.then` causes an extra microtask turn before the next handler runs, which can affect interleaving with other microtasks.
Memory Game
โ Quick quiz โ lock the concept in long-term memory.What is a 'Promise memory leak' and how does it occur?