Skip to content

Async / Await

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); }); // hello

Inside 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;
}

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);
}
}

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(); // waits
var b = await fetchB(); // then waits again

To 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.

JavaScript

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:

JavaScript

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:

JavaScript

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:

Node.js
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 finished

Even 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.

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.

JavaScript

Output: Sequential: A then B, then Parallel: A and B.

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.

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.

  • “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 without await just starts it and immediately continues — exactly like calling any other function that returns a Promise.
  • “forEach is just a for loop, so await inside it works the same way.” It does not — forEach never looks at what its callback returns, so an async callback’s Promise (and any rejection inside it) is completely disconnected from the forEach call 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 own await chain, which is exactly what the demo above proves for two requests running at once.

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, fine
db.save(user); // BUG: db.save returns a Promise — this call is not awaited
return { 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.

What does an async function always return?
What is the correct way to handle a rejection from an awaited Promise?
Which pattern runs two async operations concurrently rather than sequentially?
What does this print?
function fakeSave(item) {
  return new Promise(function (resolve) {
    setTimeout(function () { resolve(item); }, 0);
  });
}
console.log('start');
['a', 'b'].forEach(async function (item) {
  var r = await fakeSave(item);
  console.log('saved', r);
});
console.log('end');
A function does db.save(user); without await, then immediately return { ok: true };. If db.save eventually rejects, what happens?