JavaScript Async/Await Explained: Sequential vs Concurrent
An async function returns a promise, and await pauses it until a promise settles. Running awaits concurrently, try and catch, return await, floating promises.
- Course: JavaScript study plan
- Module: Asynchronous JavaScript
- Kind: Lesson
- Reading time: 14 min
- Runtime: Node 16
How does async/await work in JavaScript?
In JavaScript, an async function always returns a promise: return fulfils it and throw rejects it. Inside, await pauses the function until a promise settles, lets the event loop run other work, then resumes with the fulfilled value — or throws the rejection at that line. Asynchronous code therefore reads top to bottom, with ordinary try/catch, loops and early returns.
Lesson
async/await (2017) did not add a capability; it added readability. An async function returns a promise; await pauses the function until a promise settles and gives you its value — or throws its rejection — so asynchronous steps read top to bottom with try/catch, loops and early returns like any synchronous code. The cost of that readability is a handful of rules people learn by getting them wrong: await in a loop is sequential, a missing await is a silent bug, return promise inside try does not get caught, and every await yields to the event loop even when the value is already there. This lesson covers the semantics precisely, the sequential-versus-concurrent decision, error handling, and the patterns that make async code correct as well as readable.
The two keywords
async function loadDashboard(userId) { // returns a Promise, always
const user = await fetchUser(userId); // pause here until fetchUser's promise settles
const team = await fetchTeam(user.teamId); // runs after — sequential
return { user, team }; // fulfils the returned promise with this object
}
loadDashboard(1).then(render).catch(showError); // callers see a normal promise
asyncmarks a function whose return value is wrapped in a promise:return xfulfils withx(a returned promise is adopted),throwrejects. Works on declarations, expressions, arrows, methods (async method() {}), and generators (async function*).await ptakes a promise (or any value — a non-promise is wrapped, soawait 5is5one microtask later), suspends the async function, lets the event loop run, and resumes whenpsettles: the fulfilled value becomes the expression's value; a rejection is thrown at theawait. Only valid insideasyncfunctions and at the top level of ES modules.
Everything before the first await runs synchronously when the function is called; the function returns its promise at the first await. After each await the continuation runs as a microtask.
Sequential or concurrent — you decide
// sequential: ~600 ms total if each takes 200
const a = await fetchA();
const b = await fetchB();
const c = await fetchC();
// concurrent: ~200 ms — start all three, then wait
const [a2, b2, c2] = await Promise.all([fetchA(), fetchB(), fetchC()]);
// also concurrent, without all(): start first, await later
const pA = fetchA(); const pB = fetchB();
const a3 = await pA; const b3 = await pB;
await waits for one thing. Independent operations awaited one after another run one after another — the most common performance bug in async code. Start them (call the functions, holding the promises), then await. Dependent operations (fetchTeam(user.teamId)) must be sequential by nature.
The same applies to loops: for (const id of ids) results.push(await load(id)) loads one at a time; await Promise.all(ids.map((id) => load(id))) loads all at once. Choose sequential when order or rate matters (writing in order, a rate-limited API), concurrent when it does not; bound the concurrency for large lists (next lesson). forEach with an async callback awaits nothing — the loop finishes immediately and errors are lost; use for…of or map + Promise.all.
Error handling
async function readConfig(path) {
try {
const text = await fs.promises.readFile(path, "utf8");
return JSON.parse(text);
} catch (err) {
if (err.code === "ENOENT") return {}; // expected: missing file → defaults
throw new ConfigError(`cannot load ${path}`, { cause: err }); // otherwise wrap and propagate
} finally {
metrics.increment("config.read");
}
}
A rejected await throws at that line, so try/catch covers asynchronous failures exactly like synchronous ones — including errors from several awaited steps in one block. The module-7 rules apply unchanged: catch at boundaries, guard types, wrap with cause.
The trap: try { return doWork(); } catch … — returning a promise without awaiting exits the try before the promise rejects; the catch never fires and the caller gets the rejection. Inside a try (or when finally cleanup must wait), write return await doWork(). Elsewhere a bare return doWork() is fine and saves a microtask.
The missing await
const data = fetchData(); (no await) gives a promise, so data.items is undefined — the classic "Cannot read properties of undefined". fetchData(); as a statement with no await and no catch is a floating promise: its work runs, its result is dropped, and its rejection is unhandled (a crash in Node). Linters (@typescript-eslint/no-floating-promises) exist for this. Every promise must be awaited, returned, or explicitly handled (void p.catch(log) when fire-and-forget is intended).
Concurrency without threads
An async function's body runs in slices between awaits; no other JavaScript runs during a slice, but other code runs between slices. Shared state read before an await may have changed after it:
if (!cache.has(key)) { // two callers both see "missing"
const value = await load(key); // both load
cache.set(key, value);
}
Fix by caching the promise (cache.set(key, load(key)) before awaiting) or with an explicit lock/queue. Check-then-act across an await is the async race condition.
Top-level await and startup
In ES modules await works at module top level (Node 14.8+), which makes const config = await loadConfig(); in an entry module legal and makes module evaluation asynchronous — importers wait. In CommonJS (require) it is a syntax error; wrap in an async function main() and call main().catch((err) => { console.error(err); process.exit(1); }) — the standard entry pattern that also handles startup failures.
Under the hood
async/await desugars to a generator-like state machine driven by promises: each await is a yield of the promise to a driver that resumes the function with the settled value in a then callback. That is why continuations are microtasks, why await on an already-fulfilled promise still yields once, and why stack traces across await need V8's async-stack-trace reconstruction.
Common mistakes
- Sequential awaits for independent work;
forEach(async …). - Missing
await: reading a promise as a value, or a floating promise with an unhandled rejection. return promiseinsidetry— thecatchcannot see the rejection.awaitinside a non-async callback (arr.map((x) => await f(x))is a syntax error; mark the callbackasync).- Assuming nothing changes across an
await(check-then-act races). - Wrapping an async function in
new Promise— it already returns one.
Interview angle
- "What does
asyncdo to a function?" Makes it return a promise;returnfulfils,throwrejects. - "What does
awaitdo?" Suspends the async function until the promise settles, yielding to the event loop; resumes with the value or throws the rejection. - "How do you run async operations concurrently with
await?" Start them first (ormapto promises), thenawait Promise.all. - "
return promiseversusreturn await promise?" Insidetry/finallyonlyreturn awaitlets the block see the rejection; elsewhere both are fine. - "Why does
forEach(async …)not work?"forEachignores the returned promises; nothing waits and errors are lost.
Key takeaways
async→ returns a promise;await→ pause, yield, resume with the value or throw the rejection; continuations are microtasks.- Sequential awaits are sequential; start independent work first, then
await Promise.all; neverforEach(async). try/catch/finallywork across awaits;return awaitinsidetry; wrap withcause, catch at boundaries.- Every promise is awaited, returned, or handled — a floating promise is a bug and, on rejection, a crash.
- State can change across an
await: cache promises, not values; check-then-act needs a lock.
Common questions
How do you run async functions concurrently with await?
Start them before awaiting: await Promise.all([fetchA(), fetchB()]), or call each function, keep the promises and await them afterwards. Awaiting independent calls one after another runs them one after another — the most common performance bug in async code.
Why doesn't forEach work with async/await?
forEach ignores the promises its callback returns, so an async callback is never awaited: the loop finishes at once and any rejections are lost. Use for…of with await to go one at a time, or map to promises and await Promise.all(…) to run them concurrently.
What is the difference between return promise and return await promise?
Inside a try block only return await lets the catch or finally see a rejection; a bare return promise leaves the try before the promise rejects, so the caller receives the rejection instead. Outside try both behave the same, and the bare return saves a microtask.
What happens if you forget await in JavaScript?
You get the promise instead of its value, so reading data.items gives undefined. A call made as a bare statement with no await and no catch becomes a floating promise: its result is dropped and a rejection goes unhandled, which crashes Node. Every promise should be awaited, returned or explicitly handled.
Can you use await at the top level in JavaScript?
Yes, in ES modules: top-level await works since Node 14.8, and importers wait for the module to finish evaluating. In CommonJS it is a syntax error, so wrap startup in async function main() and call main().catch((err) => { console.error(err); process.exit(1); }).
Exercises
Sequential versus concurrent, observed
Each line is a task name ms. Run them sequentially (await in a for…of, recording the completion order) and then concurrently (Promise.all over async callbacks that each record their completion). Print sequential: <order>, parallel: <order>, and sequentialTime=<sum of ms> parallelTime=<max ms> (computed from the numbers, not measured).
Example: a 30, b 10, c 20 →
sequential: a b c
parallel: b c a
sequentialTime=60 parallelTime=30
Sequential completes in input order; concurrent completes in duration order.
return await, and the forEach that waits for nothing
The starter's work(id) rejects for ids ending in x. withoutAwait(id) does try { return work(id); } catch { … }; write withAwait(id) identically but with return await work(id). For each id call both inside an outer try/catch and print <id>: withoutAwait -> <result> | withAwait -> <result>, where a rejection caught inside prints caught inside: <message> and one caught by the outer block prints caught outside: <message>. Then run ids.forEach(async (id) => { await work(id).catch(() => {}); finished++; }) and immediately print afterForEach=<finished>; then await Promise.all(ids.map(...)) the same way and print afterPromiseAll=<finished>.
Example: a bx c →
a: withoutAwait -> a done | withAwait -> a done
bx: withoutAwait -> caught outside: bx failed | withAwait -> caught inside: bx failed
c: withoutAwait -> c done | withAwait -> c done
afterForEach=0
afterPromiseAll=3In this module: Asynchronous JavaScript
- Callbacks and the event loop — one thread, two queues
- Promises — a value that is not here yet
- async/await — promises with the syntax of ordinary code (this lesson)
- Combinators, concurrency limits, timeouts and cancellation
- Timers and scheduling — setTimeout, setInterval, nextTick, and debounce/throttle
- Checkpoint — Asynchronous JavaScript
← Promises — a value that is not here yet · Combinators, concurrency limits, timeouts and cancellation →