Using async and await in JavaScript code
Using async and await in JavaScript code

Understanding async/await in JavaScript (Without the Confusion)

Asynchronous code is where a lot of JavaScript developers hit their first real wall. Callbacks nest into a pyramid, promises help but still read awkwardly, and then you meet async/await in JavaScript and suddenly asynchronous code looks almost like the synchronous code you already understand. Almost. There are a few sharp edges, and this guide walks through them.

From promises to async/await

Under the hood, async/await is just nicer syntax over promises. An async function always returns a promise, and await pauses the function until a promise settles. Compare the two styles:

// Promise chain
function getUser() {
  return fetch('/api/user')
    .then(res => res.json())
    .then(user => user.name);
}

// async/await
async function getUser() {
  const res = await fetch('/api/user');
  const user = await res.json();
  return user.name;
}

Same behavior, but the second version reads top to bottom like a story. That readability is the whole point.

Handling errors without losing your mind

With promises you chain .catch(). With async/await you use the humble try/catch you already know:

async function getUser() {
  try {
    const res = await fetch('/api/user');
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return await res.json();
  } catch (err) {
    console.error('Failed to load user:', err);
    return null;
  }
}

One catch block can guard several await calls, which is far cleaner than sprinkling error handlers through a chain. Note the res.ok check: fetch only rejects on a network failure, so you have to inspect the HTTP status code yourself and turn a 4xx/5xx into a thrown error — a habit that pairs naturally with consuming well-designed APIs.

Don’t await inside a loop (sequential vs parallel)

Here is the single most common async performance trap: awaiting things in a loop that could run in parallel. The cost is brutal and easy to miss — ten sequential one-second requests take ten seconds; fired together with Promise.all they finish in about one.

// Slow: each request waits for the previous one
for (const id of ids) {
  results.push(await fetch(`/api/item/${id}`));
}

// Fast: fire them together
const results = await Promise.all(
  ids.map(id => fetch(`/api/item/${id}`))
);

If the operations do not depend on each other, Promise.all can turn ten seconds into one. Reaching for await inside a loop is the single most common performance bug I see in async JavaScript.

A few habits worth keeping

Remember that await only works inside an async function (or at the top level of a module). Do not forget the keyword — a missing await gives you a pending promise instead of a value, and the bug can be maddening to spot. And when failure is expected, handle it; an unhandled rejected promise will happily crash your Node process.

Beyond Promise.all: the other combinators

Promise.all has three siblings, and knowing when each fits saves you from reimplementing them badly. Promise.all is all-or-nothing: one rejection and the whole thing throws, which is right when every result is required. Promise.allSettled never throws — it waits for everything and hands you each outcome tagged as fulfilled or rejected:

const results = await Promise.allSettled(urls.map(u => fetch(u)));
const succeeded = results
  .filter(r => r.status === 'fulfilled')
  .map(r => r.value);

That’s the tool for “fetch from five sources, use whatever came back” — dashboards, link checkers, batch jobs where partial success is still success. Promise.race settles with the first promise to settle either way, the classic building block for timeouts. And Promise.any resolves with the first success, ignoring failures unless everything fails — perfect for querying mirror servers where you want whichever answers first.

The four side by side — the fastest way to pick the right one:

Combinator Resolves when Rejects when Use case
Promise.all All inputs fulfill Any one rejects (fails fast) You need every result; all-or-nothing
Promise.allSettled All inputs settle Never — you inspect each outcome Partial success is fine; you want every outcome
Promise.race The first input settles (fulfill or reject) If that first-to-settle rejects Timeouts; first-to-respond wins
Promise.any The first input fulfills Only if all reject (AggregateError) First success from redundant sources

What await actually does (and why it isn’t blocking)

A mental model worth having: await does not freeze the browser or Node process. When execution hits an await, the function suspends and returns control to the event loop — other code, clicks, and requests keep running. When the promise settles, your function resumes where it left off. So “async code” isn’t about running on another thread (JavaScript still has one main thread); it’s about not wasting that thread while waiting on the network or disk.

This model explains the classic interview puzzle: an async function runs synchronously until its first await, then yields. It also explains why CPU-heavy work — parsing a giant JSON string, crunching numbers — still freezes your UI no matter how many async keywords you sprinkle on it. await yields while waiting; it cannot make computation happen elsewhere. For that you need a Worker.

The fire-and-forget trap

Calling an async function without awaiting it is legal, occasionally intentional, and frequently a silent bug. The function runs, but nothing waits for it and — crucially — nothing catches its errors:

// Looks fine, loses errors
saveDraft(doc);   // returns a promise nobody holds

// Explicit fire-and-forget: at least handle failure
saveDraft(doc).catch(err => reportError(err));

In Node, an unhandled rejection can take down the whole process; in browsers it lands as a console error your users never report. The discipline: every promise ends in an await, a .catch(), or a deliberate, commented decision to drop it. Linters can enforce this (no-floating-promises in typescript-eslint) and it’s one of the highest-value lint rules that exists for async-heavy code — the kind of automated guardrail that keeps a codebase clean without relying on willpower.

A related subtlety: return await fn() versus return fn() inside a try block are not equivalent. Without the await, the promise escapes the try before it rejects, and your catch never fires. Inside try/catch, keep the await.

Common async/await mistakes, in one place

Every trap covered above, gathered as a quick pre-flight checklist:

  • Awaiting in a loop when the iterations are independent — serializes work that should run in parallel. Use Promise.all over .map().
  • Forgetting await — you get a pending Promise object instead of the value, and comparisons/logic silently misbehave.
  • Fire-and-forget — calling an async function without await or .catch(), so its errors vanish (or crash Node). Enable no-floating-promises.
  • return fn() instead of return await fn() inside try — the promise escapes the try before rejecting, so your catch never fires.
  • Ignoring rejections — an unhandled rejected promise is a real crash in Node and a silent failure in the browser.
  • Expecting await to speed up CPU work — it yields while waiting on I/O; it can’t parallelize computation. Use a Worker for that.

Frequently asked questions

Can I use await outside a function? Yes — top-level await works in ES modules (files loaded with type="module" or .mjs). It’s ideal for initialization code like loading config before the app starts. It doesn’t work in classic scripts or CommonJS.

Does async/await replace promises? No — it consumes promises with nicer syntax. You still create promises (every fetch, every async function call), still use combinators like Promise.all, and still occasionally reach for .then() when it reads better. Understanding promises remains the foundation; async/await is the ergonomics layer.

How do I add a timeout to an await? Race it: await Promise.race([fetchData(), timeout(5000)]) where timeout rejects after the delay. For fetch specifically, prefer the built-in way — AbortSignal.timeout(5000) passed as the signal option — which actually cancels the request instead of merely abandoning it.

Does async/await block the main thread? No. await suspends only your function and hands control back to the event loop, so clicks, timers, and other code keep running while it waits. JavaScript stays single-threaded; async is about not wasting that thread during I/O, not about running on multiple threads.

Why is my await inside a loop so slow? Because each iteration waits for the previous one to finish before starting. If the iterations don’t depend on each other, kick them all off at once with Promise.all(items.map(...)) and await that instead — turning N sequential waits into one parallel wait.

Once async/await in JavaScript clicks, you will wonder how you tolerated callback pyramids. It does not remove the need to understand promises, but it makes writing correct asynchronous code feel natural instead of adversarial. For another everyday async-timing pattern, see debounce and throttle in JavaScript.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *