Async / Await
async functions
Section titled “async functions”The async keyword before a function declaration makes it an async function. Async functions always return a Promise — even if you return a plain value, it is automatically wrapped in Promise.resolve(value).
async function greet() { return 'hello';}
greet().then(function(v) { console.log(v); }); // helloInside an async function, await pauses execution until the Promise settles, then resumes with the resolved value. The rest of the program keeps running while awaiting.
async function loadUser() { var user = await fetchUser(42); // pauses here until fetchUser resolves var posts = await fetchPosts(user.id); return posts;}Error handling
Section titled “Error handling”Wrap await expressions in try/catch to handle rejections:
async function run() { try { var data = await fetchData(); console.log(data); } catch (err) { console.error('Failed:', err.message); }}Sequential vs concurrent
Section titled “Sequential vs concurrent”Two sequential await calls run one after the other — the second starts only after the first completes:
function fetchA() { return Promise.resolve('A'); }function fetchB() { return Promise.resolve('B'); }
var a = await fetchA(); // waitsvar b = await fetchB(); // then waits againTo run both concurrently, start the Promises first and await them together:
function fetchA() { return Promise.resolve('A'); }function fetchB() { return Promise.resolve('B'); }
var [a, b] = await Promise.all([fetchA(), fetchB()]);forEach(async …) does not do what it looks like it does
Section titled “forEach(async …) does not do what it looks like it does”Array.prototype.forEach was designed for synchronous callbacks — it does not look at what the callback returns, and it does not wait for anything. Passing it an async function does not change that: forEach fires all the callbacks, ignores the Promise each one returns, and moves on immediately.
The “finished” line prints before any forEach saw: line — forEach itself returns synchronously once it has fired all three callbacks, long before any of their awaits settle. Now watch what happens when one of those ignored Promises rejects:
Click Run: in this sandboxed browser tab, the rejection is swallowed — you see saved a, then nothing for bad, and the tab keeps running (a real browser would fire a silent unhandledrejection event you would only see by opening devtools). Run the exact same code as a plain Node script and it is far less forgiving: an unhandled rejection crashes the process by default before item === 'c' even gets a chance to run — one bad item takes down the whole batch, not just its own iteration.
For comparison, for...of with await waits for each step (sequential, and errors propagate through a normal try/catch), and Promise.all(items.map(fn)) runs everything concurrently and genuinely waits for all of it:
Both for...of and Promise.all(map) genuinely wait — the “really finished” line only prints after every item is done, and a rejection inside either one is a normal, catchable rejection instead of a silently ignored one.
Carrying context across awaits: AsyncLocalStorage
Section titled “Carrying context across awaits: AsyncLocalStorage”A request ID set at the top of a handler is gone by the time a nested await resumes — unless you keep it in AsyncLocalStorage. Node 24 rebuilt it on AsyncContextFrame, which propagates the store through every await, timer, and callback with far less overhead than the old async-hooks implementation. The demo below runs two concurrent “requests” with different IDs and different delays, and confirms neither one ever sees the other’s context:
Output (verified on Node 24)
[req-1] start
[req-2] start
[req-2] after await, still has the right id
[req-1] after await, still has the right id
all requests finishedEven though req-2’s callback resumes first (its 5 ms timer fires before req-1’s 20 ms one), each request’s getStore() always returns its own { id } — the context follows the await chain it started in, not the order callbacks happen to fire in.
Runnable demo
Section titled “Runnable demo”The snippet below uses a fakeFetch helper (setTimeout-based) to simulate async calls. It first fetches sequentially, then fetches both in parallel with Promise.all.
Output: Sequential: A then B, then Parallel: A and B.
Mental Model
Section titled “Mental Model”async/await is sugar over Promises, not a different mechanism — await expr is “pause this function here, resume with expr’s resolved value once it settles, and let everything else run meanwhile.” Because it is still Promises underneath, anything that expects a Promise callback (.then, Array.prototype.forEach, .map without a following Promise.all) still receives a Promise from an async function and will not automatically wait for it — await only pauses the function that contains it, never the caller of a function it forgot to hand a Promise back to.
Trade-offs
Section titled “Trade-offs”async/await makes async code read top-to-bottom like synchronous code, which is exactly why it hides two things you have to reintroduce deliberately: concurrency (sequential awaits are simple to read but slow — you must opt into Promise.all for speed) and iteration shape (a for...of loop reads identically whether or not the body is async, so nothing visually warns you that items.forEach(async fn) silently breaks that assumption). The readability win is real; it just moves the burden of “does this actually wait?” onto you reading the iteration method, not the await keyword.
Common Misconceptions
Section titled “Common Misconceptions”- “If a function is async, calling it always waits for it to finish.” Only if the caller
awaits the call (or chains.then). Calling an async function withoutawaitjust starts it and immediately continues — exactly like calling any other function that returns a Promise. - “
forEachis just a for loop, soawaitinside it works the same way.” It does not —forEachnever looks at what its callback returns, so anasynccallback’s Promise (and any rejection inside it) is completely disconnected from theforEachcall itself. - “AsyncLocalStorage is global state, so concurrent requests can leak into each other’s context.” The opposite is true by design — each
als.run(store, fn)call creates an isolated context that follows only its ownawaitchain, which is exactly what the demo above proves for two requests running at once.
Reading AI-generated code
Section titled “Reading AI-generated code”The single most common bug in AI-generated async code is a missing await on a call that returns a Promise — the code still runs (nothing throws at the call site), it just does not wait for the result, so bugs show up later as “the data wasn’t saved yet” or “the response came back empty,” far from the missing keyword itself.
🐛 Spot the bug
async function saveUser(user) {validateUser(user); // synchronous, finedb.save(user); // BUG: db.save returns a Promise — this call is not awaitedreturn { ok: true };}Show the answer
db.save(user) returns a Promise, but nothing awaits it or returns it. saveUser returns { ok: true } immediately — possibly before the save has even reached the database, and definitely before you would know if it failed, since a rejection from db.save becomes an unhandled rejection instead of a caught error. The fix is await db.save(user); (or return db.save(user).then(() => ({ ok: true })) if you need to keep it non-async) — anything that returns a Promise and matters for correctness must be awaited or returned.