Technology  /  JavaScript

๐ŸŸจ JavaScript 15 guides ยท updated 2026

Closures, the event loop, promises, and modern ES syntax โ€” the language of the web explained the way it actually behaves at runtime.

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 total
const user = await fetchUser(id); // 300ms
const 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 total
const [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 now
const configPromise = fetchConfig(); // start now
const user = await userPromise;
const orders = await fetchOrders(user.id); // genuinely depends on user
const config = await configPromise; // was loading the whole time

Note 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 returns
items.forEach(async (item) => {
await processItem(item);
});
console.log("done"); // logs IMMEDIATELY; processing continues in background,
// completion unknowable, errors unhandled

forEach 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 fast
await Promise.all(items.map((item) => processItem(item)));
// Parallel with per-item outcomes
const 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:

  1. Every promise accounted for? Any call that returns a promise must be awaited, returned, .catch-ed, or explicitly void-ed with a comment. Bare doAsyncThing(); mid-function is either a bug or needs to say it isnโ€™t.
  2. Independent awaits serialized? Two consecutive awaits where the second doesnโ€™t use the firstโ€™s result โ€” the accidental waterfall. Group with Promise.all.
  3. forEach/map with async callbacks? forEach: always wrong. map: only right when feeding Promise.all/allSettled immediately.
  4. try scope honest? Does the try wrap exactly the operations whose failures it can handle โ€” and are the awaits inside it (return await when returning from within a try)?
  5. Shared state across awaits? Every await is a gap where other code runs. Reading this.items before 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.
  6. 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.