Web & Frontend Development

JavaScript async/await: How Asynchronous Code Actually Works

async/await is syntax over promises, not a way to pause JavaScript. An async function returns a promise; await suspends that function, hands control back to the event loop, and resumes when the awaited promise settles. Nothing blocks. The runtime keeps handling other work the entire time — which is exactly why misplaced awaits make fast code slow.

That distinction — suspending a function versus blocking a thread — explains almost every surprising behaviour developers hit with async code. Get it right and the rest follows.

What does await actually do?

JavaScript runs your code on a single thread. There is no mechanism to make that thread sit still and wait; if there were, the UI would freeze and the server would stop answering requests. So await does something else entirely: it returns from the current function, registering the rest of the function body as work to run later.

Concretely, when the engine reaches an await:

  1. It evaluates the expression to its right. If the result isn't a promise, it's wrapped in one.
  2. It schedules the remainder of the function as a continuation on that promise.
  3. It returns control to the caller — immediately.
  4. When the promise settles, the continuation is queued as a microtask and runs at the next available opportunity.
async function load() {
  console.log('A');
  const res = await fetch('/api/items');
  console.log('C');
  return res.json();
}

console.log('start');
load();
console.log('B');
// start, A, B, ... C

B prints before C because load() returned at the await. This is not a quirk to memorise; it's the whole model. An async function is a state machine that runs in chunks, and every await is a chunk boundary.

The queue those continuations land in matters too. Microtasks (promise callbacks) drain completely before the engine moves on to the next macrotask (a setTimeout callback, an I/O event, a render). That's why a Promise.resolve().then(...) fires before a setTimeout(..., 0) scheduled earlier, and why an unbounded chain of promise resolutions can starve rendering entirely — the browser never gets a chance to paint. If you're chasing a frame-timing problem, that interaction with the render step is worth understanding alongside how your app renders in the first place.

Why is my async code slower than it should be?

The single most common performance bug in async JavaScript is accidental sequencing:

// Sequential: total time = user + orders + settings
const user     = await getUser(id);
const orders   = await getOrders(id);
const settings = await getSettings(id);

Three independent requests, run one after another, because each await suspends the function until that specific promise settles. If none of them depends on the others, you've tripled your latency for no reason.

The fix is to start the work first and await the results afterward:

// Concurrent: total time ≈ the slowest of the three
const [user, orders, settings] = await Promise.all([
  getUser(id),
  getOrders(id),
  getSettings(id),
]);

The key insight: promises begin executing when they're created, not when they're awaited. Calling getUser(id) fires the request immediately. await only decides when you collect the result. Once that clicks, the rule writes itself — await as late as you can, and only serialize when a later call genuinely needs an earlier call's output.

The counter-case is real, though. If getOrders needs the user's account ID, sequencing is correct and unavoidable. And unbounded Promise.all over a large array will happily open five hundred sockets at once, which your API — and possibly your own database connection pool — will not thank you for. Batch it, or use a concurrency limiter.

Which promise combinator should I use?

Promise.all is the default, but it isn't always the right shape. Each combinator encodes a different failure policy, and picking the wrong one is how partial outages turn into total ones.

Combinator Resolves when Rejects when Use it for
Promise.all Every promise fulfils Any promise rejects (immediately) Work where a single failure makes the whole result meaningless
Promise.allSettled Every promise settles Never Dashboards and fan-out reads where partial results are still useful
Promise.race The first promise settles The first promise rejects Timeouts and cancellation — race real work against a timer
Promise.any The first promise fulfils Every promise rejects Redundant sources where you want the first success

Two details worth remembering. Promise.all rejects on the first rejection, but it does not cancel the others — they keep running, and if one of them rejects later with no handler attached you can get an unhandled rejection warning. And allSettled never rejects, which means you must inspect each result's status yourself; forgetting to do that silently swallows every error in the batch.

How do errors propagate through async functions?

A rejected promise inside an async function behaves like a thrown exception, which means try/catch works the way you'd hope:

async function load(id) {
  try {
    const res = await fetch(`/api/items/${id}`);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return await res.json();
  } catch (err) {
    logger.warn({ err, id }, 'item load failed');
    throw err;              // rethrow — don't swallow
  }
}

Three traps live in that small block.

fetch does not reject on HTTP errors. A 404 or a 500 is a perfectly successful HTTP exchange as far as fetch is concerned; it only rejects on network-level failure. Check res.ok explicitly, every time. (Some HTTP clients do throw on 4xx/5xx — know which behaviour yours has rather than assuming.)

return await versus return inside try. A bare return somePromise() inside a try block resolves after the try has exited, so a rejection escapes the catch. Writing return await somePromise() keeps the rejection inside the block. Outside a try, the extra await is harmless but pointless.

Forgetting await entirely. someAsyncCall() without await returns a floating promise. The function continues, the error surfaces later as an unhandled rejection with a useless stack trace, and in a test the process may exit before it resolves. If you deliberately want fire-and-forget, attach a .catch() so the failure is at least logged. Designing your API's error responses so they're actually actionable at this layer is a separate discipline — worth reading alongside API design fundamentals.

When should I not use async/await?

await inside a loop is the classic smell — but it's only a bug when the iterations are independent:

for (const id of ids) {
  await process(id);          // deliberate: rate-limited, or order matters
}

That's correct code if you're respecting a rate limit or the operations must happen in order. It's a mistake if you just wanted to process a list. Say which one you mean; the reader can't tell from the syntax.

Other cases where await isn't the answer: streams, where you want for await...of over an async iterator rather than buffering everything; CPU-bound work, which async does nothing for — you need a worker thread, because a busy loop still blocks the single thread; and event-driven code in React, where an await that resolves after a component unmounts can set state on a dead component. Use an AbortController or an ignore flag; see the patterns in React state management.

Finally, remember that await at module top level is only available in ES modules, not CommonJS. If you need async setup in a CJS file, wrap it in an async IIFE or export an initialisation function.

FAQ

Is async/await faster than .then() chains? No. It compiles to essentially the same promise machinery — the difference is readability, not throughput. Choose whichever expresses the control flow more clearly. Sequential steps read better with await; simple transforms often read better as a .then().

Does making a function async slow it down? The overhead is a promise allocation and a microtask hop — negligible next to any real I/O. Marking a function async when it does no awaiting is pointless, but it isn't a performance problem.

Why does my await inside forEach not wait? Array.prototype.forEach ignores the promise its callback returns, so it fires every iteration immediately and moves on. Use a for...of loop for sequential work, or Promise.all(items.map(fn)) for concurrent work.

Can I await something that isn't a promise? Yes. await 42 resolves to 42 after one microtask tick. Anything with a .then method (a "thenable") is treated as a promise, which is how older promise libraries interoperate with modern syntax.

How do I add a timeout to an async call? Race the work against a timer with Promise.race, or — better, where supported — pass an AbortController signal so the underlying request is actually cancelled rather than merely ignored.


The mental model that carries all of this: await suspends a function, not a thread; promises start on creation, not on await; and rejections travel like exceptions once you're inside an async function. Almost every async bug is one of those three being assumed backwards.

If you want to go deeper on the language itself rather than the patterns, our JavaScript book recommendations cover the event loop in far more detail than an article can. And when you need the runnable one-task version — fetching JSON, handling a failed request, aborting a fetch — the reference pages at TheAppCode give you the snippet without the essay.

Comments are disabled for this article.