Async/Await in JavaScript: Error Handling, Parallelism, and the Loop Traps
async/await (ES2017) is the syntax that made asynchronous JavaScript read like synchronous code โ no .then chains, no callback indentation, just top-to-bottom logic with await marking the suspension points. It is, by wide agreement, the default way to consume promises now.
But itโs syntax over promises, not a replacement for understanding them โ and it has its own traps: accidental serialization (the most common performance bug in modern JS), loop misbehavior with forEach, and error-handling seams. This guide covers the mapping, the patterns, and the traps.
The Two Keywords and the One Mapping
async before a function makes it always return a promise:
async function getAnswer() { return 42; // caller receives Promise<42>, not 42}getAnswer().then(console.log); // 42
async function fail() { throw new Error("nope"); // becomes a REJECTED promise, not a sync throw}Return value โ fulfillment. Throw โ rejection. An async function cannot leak a synchronous exception โ everything funnels into its promise (which retroactively explains the self-check trap in the promises guide: making save async is what sealed the seam).
await takes a promise and suspends the async function until it settles:
async function showUser(id) { const res = await fetch(`/api/users/${id}`); // suspend here... const user = await res.json(); // ...and here console.log(user.name); // runs when both are done}The critical event-loop fact: await suspends the function, not the thread. At each await, the function parks itself, control returns to the event loop (other tasks, rendering, handlers all proceed), and the rest of the function resumes as a microtask when the promise settles. Fulfilled promise โ await yields the value. Rejected โ await throws the reason at that line, catchable with ordinary try/catch.
Thatโs the entire feature: fulfillment becomes return values, rejection becomes exceptions, and suspension is invisible syntax. Every promise skill transfers directly.
Error Handling: try/catch Comes Home
Async errors get the same tools as sync errors, and the same design discipline:
async function loadDashboard(userId) { try { const user = await fetchUser(userId); const orders = await fetchOrders(user.id); return buildDashboard(user, orders); } catch (err) { if (err instanceof AuthError) { redirectToLogin(); return null; } throw new DashboardError(`Failed for ${userId}`, { cause: err }); // wrap, keep cause } finally { spinner.hide(); // either way }}Everything you know applies: catch narrowly or rethrow, wrap with cause, donโt swallow, handle at boundaries rather than everywhere. Three async-specific notes:
Scope the try to what can fail โ a function-wide try/catch that lumps together network errors, JSON parse errors, and bugs in your render logic makes diagnosis worse. Catch around the awaits that need handling; let bugs propagate.
The forgotten-await leak:
try { savePreferences(prefs); // BUG: not awaited โ returns a promise, try exits,} catch (err) { // a later rejection has NOWHERE to land showError(err); // never runs for async failures}Without await, the promise escapes the try block; its rejection becomes unhandled. Linters (no-floating-promises in typed projects) catch this โ turn that rule on. Corollary: return await f() inside a try block is not redundant (plain return f() exits before rejection can be caught); outside a try, plain return f() is fine.
Fire-and-forget must be deliberate. Sometimes you genuinely donโt want to wait (analytics, logging). Say so โ void logEvent(e).catch(reportError) โ attaching a catch even when not awaiting, so failures still land somewhere.
The Big One: Sequential vs. Parallel
await is sequential by construction โ each one waits before the next line runs. Perfect for dependent steps, silently wasteful for independent ones:
// SLOW โ three independent calls, serialized: ~900ms totalconst user = await fetchUser(id); // 300msconst config = await fetchConfig(); // 300ms โ didn't need user!const notices = await fetchNotices(); // 300ms โ didn't need either!
// FAST โ start all three, then await together: ~300ms totalconst [user2, config2, notices2] = await Promise.all([ fetchUser(id), fetchConfig(), fetchNotices(),]);This is the most common performance bug in modern JavaScript codebases โ not because itโs subtle, but because the slow version reads so naturally. The habit to install: at every await, ask โdoes the next operation actually need this result?โ If not, group them into Promise.all. Mixed dependencies compose cleanly too:
const userPromise = fetchUser(id); // start nowconst configPromise = fetchConfig(); // start nowconst user = await userPromise;const orders = await fetchOrders(user.id); // genuinely depends on userconst config = await configPromise; // was loading the whole timeNote the mechanism: promises start when created, not when awaited. Creating early and awaiting late is the general parallelism pattern; Promise.all is its tidy special case (and brings fail-fast semantics plus allSettled/race/any options for other policies).
Loops: Where forEach Betrays You
The trap every JS developer hits once:
// BROKEN โ forEach ignores the promises its callback returnsitems.forEach(async (item) => { await processItem(item);});console.log("done"); // logs IMMEDIATELY; processing continues in background, // completion unknowable, errors unhandledforEach was designed before promises; it calls the async callback and discards the returned promise. Nothing awaits anything. The correct forms, by intent:
// Sequential โ one at a time, order preserved (for..of + await)for (const item of items) { await processItem(item);}
// Parallel โ all at once, wait for all, fail fastawait Promise.all(items.map((item) => processItem(item)));
// Parallel with per-item outcomesconst results = await Promise.allSettled(items.map(processItem));Choose deliberately: sequential when order matters or the target canโt take concurrent load (rate-limited APIs, database writes with dependencies); parallel when items are independent and the other side can handle it. For โparallel but at most N in flightโ โ the realistic middle ground for hitting APIs politely โ use a small pool utility (p-limit is the standard library choice) rather than hand-rolling.
map + async has a subtlety worth making explicit: items.map(async ...) produces an array of promises โ useful only as input to Promise.all/allSettled. An un-awaited map of async callbacks is the forEach bug wearing glasses.
Top-Level Await and Where async Canโt Reach
Inside ES modules, await works at the top level โ no wrapper function:
// data.js (module)const res = await fetch("/api/boot");export const bootData = await res.json();Importing modules wait for this module to finish โ clean for one-time initialization (config, feature flags, dynamic imports), worth restraint anywhere slow (youโre delaying the whole import graph).
Places that arenโt async-friendly, and the workarounds: constructors canโt await (use a static async factory: static async create()), getters canโt (make it a method), and synchronous callback slots (array.sort comparators, JSON.stringify replacers) canโt be made async at all โ restructure so the async work happens before.
Reading Async Code in Review: A Checklist
Async bugs cluster into recognizable shapes. When reviewing (or debugging) an async function, scan for these in order:
- Every promise accounted for? Any call that returns a promise must be awaited, returned,
.catch-ed, or explicitlyvoid-ed with a comment. BaredoAsyncThing();mid-function is either a bug or needs to say it isnโt. - Independent awaits serialized? Two consecutive awaits where the second doesnโt use the firstโs result โ the accidental waterfall. Group with
Promise.all. forEach/mapwith async callbacks?forEach: always wrong.map: only right when feedingPromise.all/allSettledimmediately.- try scope honest? Does the try wrap exactly the operations whose failures it can handle โ and are the awaits inside it (
return awaitwhen returning from within a try)? - Shared state across awaits? Every
awaitis a gap where other code runs. Readingthis.itemsbefore an await and assuming itโs unchanged after is a single-threaded race โ the event loop interleaves tasks at await boundaries. Re-check or capture what you need before suspending. - User-visible loading/error states? Between the await and the UI: does something show progress, and does the catch path actually inform anyone?
Point 5 deserves its own example, because it surprises people who thought single-threaded meant race-free:
async function refresh() { this.loading = true; const data = await api.get("/items"); // โ user clicks refresh AGAIN here this.items = data; // older response may overwrite newer! this.loading = false;}Two overlapping calls interleave at the await; whichever response lands last wins, regardless of which request was newer. Fixes: disable the trigger while in flight, abort the previous request (AbortController), or tag requests and ignore stale responses. Single-threaded JS has no data races, but it absolutely has logical races โ every await is a yield point.
Frequently Asked Questions
Should I ever still use .then? Occasionally โ a one-line reaction where a full async wrapper is ceremony (fetchConfig().then(applyTheme).catch(log)), attaching a catch to fire-and-forget work, and inside code that composes promises as values. But mixing await and long .then chains in one function is a readability smell; pick one register per function.
Does async/await perform differently than raw promises? Semantically it is promises; engines optimize both heavily. Micro-differences (an extra tick here or there across engine versions) matter to test-ordering nerds, never to applications. Choose by readability.
How do I await multiple things and use results as they finish, not all at the end? Attach handling per-promise before a combined await (promises.map(p => p.then(render)) + Promise.allSettled), or use async iteration if the source is a stream. Promise.all alone gives you completion-at-the-end semantics only.
What does an async stack trace look like? Modern engines stitch โasync stack tracesโ across await points โ youโll see the awaiting frames in DevTools, which is a major debugging win over callback-era traces. One more reason await beats manually-nested thens.
Can I make a function โsync againโ once itโs async? No โ async is contagious upward: callers must await (or .then) all the way to an event handler, top-level await, or a deliberate fire-and-forget boundary. This isnโt a flaw; itโs the event loopโs honesty about time. Design your layers knowing IO-touching paths are async paths.
Async Iteration: for await and Streams, Briefly
One more construct completes the async toolkit: for await..of, which consumes async iterables โ sources that yield values over time:
for await (const chunk of response.body) { // streaming a fetch body progress += chunk.length;}
async function* pages(url) { // async generator: paginated API โ stream while (url) { const res = await api(url); yield* res.items; url = res.nextPage; }}for await (const item of pages("/orders?page=1")) process(item);The async generator pattern โ hide pagination/backpressure behind a clean iteration interface โ is how well-designed SDKs expose โall resultsโ without loading everything into memory. Youโll consume these more than write them at first; recognizing Symbol.asyncIterator and for await in library docs is the immediate win. (And a scoping note: for await on an array of promises processes sequentially in order โ for parallel-with-collection semantics you still want Promise.all.)
Self-Check
// 1 โ what's the total time, and what logs first?async function a() { await delay(100); console.log("a"); }async function b() { console.log("b"); await delay(50); console.log("b2"); }await Promise.all([a(), b()]);
// 2 โ bug hunt (two distinct bugs):async function submitAll(forms) { forms.forEach(async (f) => { try { validate(f); await api.post("/submit", f); } catch (e) { markFailed(f); } }); showToast("All submitted!");}
// 3 โ is the await needed?async function getUser(id) { try { return await db.find(id); } catch (e) { return CACHED_USER; }}Answers: (1) ~100ms total; logs b first (async functions run synchronously until their first await), then b2 at ~50ms, a at ~100ms. (2) forEach discards the promises so the toast lies โ use Promise.all(forms.map(...)) or for..of; and the toast claims success even if items failed โ collect results and report honestly. (3) Yes โ without await, a rejection from db.find escapes before the catch could apply; return await inside try is the correct form.
Next: putting async to work against the real world โ Fetch and Working with APIs: requests, JSON, status-code truths, timeouts, and the error-handling fetch makes you do yourself.