Scientyfic World

How Async/Await Works in JavaScript: Event Loop, Errors & Parallel Code

Async/await (often written async-await) is JavaScript syntax for working with promises. An async function always returns a promise, and await pauses that function until a promise settles, then resumes it...

Share:

Get an AI summary of this article

Blog banner for Asynchronous JavaScript function async-await

Async/await (often written async-await) is JavaScript syntax for working with promises. An async function always returns a promise, and await pauses that function until a promise settles, then resumes it with the result or throws the error. While the function is paused, the rest of your program keeps running: the engine saves the function’s state and resumes it later from the microtask queue, so nothing blocks the main thread.

This guide explains what actually happens when you write await, how it fits into the event loop, how to handle errors, how to run work in parallel instead of one step at a time, and the mistakes that trip people up most often. Every example was run in Node.js 23 (and the event-loop example also in a Chromium browser), and the outputs shown are the real ones.

Async/Await in 30 Seconds

async function getUser(id) {
  const res = await fetch(`https://api.example.com/users/${id}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();                 // the async function's promise resolves with this
}

try {
  const user = await getUser(1);     // waits without blocking anything else
  console.log(user.name);
} catch (err) {
  console.error('Could not load user:', err.message);
}
  • async before a function makes it return a promise. Returning a value resolves that promise; throwing rejects it.
  • await can be used inside an async function, and at the top level of an ES module.
  • await unwraps a promise: you get the fulfilled value, or the rejection is thrown as an error you can catch with try...catch.

MDN’s async function reference has the full syntax; the rest of this post is about how it behaves.

Why JavaScript Needs Asynchronous Code

JavaScript runs your code on a single thread, using a call stack of function calls (see how a stack works). If a network request or file read held that thread until it finished, the page would freeze. Instead, slow work is handed to the browser or Node.js, and your code is told later when it is done. JavaScript has had three ways to receive that “later” result:

ApproachIntroducedWhat it looks like
CallbacksFrom the startreadFile(path, (err, data) => { ... }); nesting several leads to “callback hell”
PromisesES2015fetch(url).then(res => res.json()).catch(handle)
Async/awaitES2017const res = await fetch(url), written like synchronous code

Async/await doesn’t replace promises; it is a nicer way to consume them. Everything you know about promises still applies, and you will still use Promise.all and friends.

How Async/Await Works Under the Hood

The event loop, tasks and microtasks

The event loop runs one piece of code at a time. When the call stack is empty, it first runs all queued microtasks (promise callbacks, including the code after an await), and only then takes the next task (a timer, an I/O callback or a click event). This script shows the order:

async function getUser() {
  console.log('2. inside getUser, before await');
  const user = await Promise.resolve({ name: 'Ada' });
  console.log('5. after await:', user.name);
  return user;
}

console.log('1. start');
setTimeout(() => console.log('6. setTimeout callback (task)'), 0);
const p = getUser();
console.log('3. getUser returned:', p instanceof Promise ? 'a Promise' : p);
Promise.resolve().then(() => console.log('4. promise .then (microtask)'));

Output, identical in Node.js 23 and headless Chromium:

1. start
2. inside getUser, before await
3. getUser returned: a Promise
5. after await: Ada
4. promise .then (microtask)
6. setTimeout callback (task)

What this shows:

  1. An async function runs synchronously until its first await, which is why line 2 prints before line 3.
  2. At await the function is suspended and the caller immediately gets back a pending promise (line 3).
  3. The rest of the function is scheduled as a microtask. It was queued before the .then callback, so line 5 runs before line 4.
  4. The setTimeout callback is a task, so it waits until every microtask has run, even with a 0 ms delay.

The practical lesson: code after await always runs later, even when the promise is already resolved, and a long chain of microtasks can delay timers and rendering just like a long loop can.

Async functions are generators plus a promise runner

Before async/await existed, libraries such as co got the same effect with generator functions, which can pause at yield and be resumed with a value. An async function behaves like a generator driven by a small runner that resumes it whenever the awaited promise settles. Here is that runner in 20 lines:

// A tiny version of what the engine does for an async function:
// run a generator, and resume it each time a yielded promise settles.
function run(generatorFn) {
  return new Promise((resolve, reject) => {
    const it = generatorFn();
    function step(method, arg) {
      let result;
      try {
        result = it[method](arg);          // resume the paused function
      } catch (err) {
        return reject(err);                // an uncaught throw rejects the promise
      }
      if (result.done) return resolve(result.value);
      Promise.resolve(result.value).then(
        (value) => step('next', value),    // fulfilled: send the value back in
        (err) => step('throw', err),       // rejected: throw it at the yield
      );
    }
    step('next');
  });
}

const wait = (ms, v) => new Promise((r) => setTimeout(() => r(v), ms));

// async/await version
async function withAsync() {
  const a = await wait(100, 2);
  const b = await wait(100, 3);
  return a * b;
}

// the same logic as a generator + runner
const withGenerator = () => run(function* () {
  const a = yield wait(100, 2);
  const b = yield wait(100, 3);
  return a * b;
});

console.log('async/await:', await withAsync());
console.log('generator + run():', await withGenerator());
console.log('errors too:', await run(function* () {
  try { yield Promise.reject(new Error('boom')); } catch (e) { return `caught ${e.message}`; }
}));
async/await: 6
generator + run(): 6
errors too: caught boom

Both versions produce the same result, and a rejected promise is thrown back into the paused function, where try...catch can handle it. Engines implement this natively and much more efficiently, but the model is accurate, and it is roughly what transpilers generated for older browsers.

Handling Errors with Async/Await

const wait = (ms, value) => new Promise((resolve) => setTimeout(() => resolve(value), ms));
const fail = (ms, msg) => new Promise((_, reject) => setTimeout(() => reject(new Error(msg)), ms));

// 1. try/catch around await
async function load() {
  try {
    await fail(100, 'Network down');
  } catch (err) {
    console.log('caught:', err.message);
  }
}
await load();

// 2. A thrown error becomes a rejected promise
async function boom() { throw new Error('thrown inside async'); }
await boom().catch((err) => console.log('.catch():', err.message));
caught: Network down
.catch(): thrown inside async

A rejected promise becomes a thrown error at the await, and an error thrown inside an async function becomes a rejected promise for its caller. So you can handle errors with try...catch inside the function, or with .catch() on the promise it returns.

return vs return await

Inside a try block, the difference matters:

async function noAwait() {
  try { return fail(50, 'escaped'); } catch { return 'caught here'; }
}
async function withAwait() {
  try { return await fail(50, 'escaped'); } catch { return 'caught here'; }
}

// noAwait()   -> rejected to caller: escaped
// withAwait() -> caught here

Without await, the function returns the promise before it rejects, so its own catch never runs. Use return await whenever the return is inside try.

Unhandled rejections crash Node.js

If nobody awaits or catches a rejected promise, browsers log an “Uncaught (in promise)” error, and Node.js (since version 15) terminates the process. In my test, a script that called an async function that throws, without awaiting it, exited with code 1 and the line scheduled after it never ran. Always await your promises or attach a .catch().

Sequential vs Parallel: The Biggest Performance Mistake

Each await waits for the previous one to finish. That is correct when step 2 needs step 1’s result, but wasteful when the steps are independent:

const wait = (ms, value) => new Promise((resolve) => setTimeout(() => resolve(value), ms));

async function sequential() {
  const a = await wait(500, 'user');
  const b = await wait(500, 'orders');
  return [a, b];
}

async function parallel() {
  const [a, b] = await Promise.all([wait(500, 'user'), wait(500, 'orders')]);
  return [a, b];
}

for (const fn of [sequential, parallel]) {
  const t = performance.now();
  const result = await fn();
  console.log(fn.name, result, Math.round(performance.now() - t), 'ms');
}
sequential [ 'user', 'orders' ] 1004 ms
parallel [ 'user', 'orders' ] 500 ms

Start independent operations first and await them together with Promise.all, and the total time is the slowest request instead of the sum of all of them.

Awaiting in loops

const wait = (ms, value) => new Promise((resolve) => setTimeout(() => resolve(value), ms));
const ids = [1, 2, 3];

// forEach does not wait
let t = performance.now();
const out1 = [];
ids.forEach(async (id) => { out1.push(await wait(300, id)); });
console.log('forEach: results right after loop =', out1, Math.round(performance.now() - t), 'ms');

// for...of runs one at a time
t = performance.now();
const out2 = [];
for (const id of ids) out2.push(await wait(300, id));
console.log('for...of:', out2, Math.round(performance.now() - t), 'ms');

// map + Promise.all runs them together
t = performance.now();
const out3 = await Promise.all(ids.map((id) => wait(300, id)));
console.log('map + Promise.all:', out3, Math.round(performance.now() - t), 'ms');
forEach: results right after loop = [] 0 ms
for...of: [ 1, 2, 3 ] 904 ms
map + Promise.all: [ 1, 2, 3 ] 303 ms
  • forEach doesn’t wait. It ignores the promises your callback returns, so code after the loop runs before any item finishes. Don’t use it with await.
  • for...of with await processes items one at a time. Use it when order matters or when you must not overload an API.
  • map + Promise.all runs all items at once and keeps results in input order.

For large lists, running everything at once can hit rate limits; process the list in batches of, say, 5 or 10 with Promise.all on each batch.

Promise.all, allSettled, any and race

await Promise.all([wait(100, 'a'), fail(50, 'b failed'), wait(200, 'c')]);
// rejects with: b failed

await Promise.allSettled([wait(100, 'a'), fail(50, 'b failed'), wait(200, 'c')]);
// [ 'a', 'rejected: b failed', 'c' ]   (mapped from {status, value | reason})

await Promise.any([fail(50, 'x'), wait(100, 'first success'), wait(200, 'late')]);
// 'first success'

await Promise.race([wait(100, 'fast'), wait(200, 'slow')]);
// 'fast'
MethodResolves whenRejects whenUse it for
Promise.allAll fulfilAny one rejects (fails fast)Requests that all must succeed
Promise.allSettled (ES2020)All have settledNeverBatch jobs where some may fail
Promise.any (ES2021)The first one fulfilsAll rejectFastest of several mirrors
Promise.raceThe first one settlesThe first one settles by rejectingRacing against a timer

All of these start the work at the same time; allSettled does not run anything sequentially, it just waits for every result instead of stopping at the first failure.

Retries and Timeouts

A retry loop with exponential backoff and a limit (an unlimited recursive retry, as in the 2023 version of this post, never stops if the server stays down):

let calls = 0;
async function flakyFetch() {
  calls++;
  if (calls < 3) throw new Error(`attempt ${calls} failed`);
  return { ok: true, attempt: calls };
}

async function retry(fn, { retries = 3, delay = 200 } = {}) {
  for (let attempt = 1; ; attempt++) {
    try {
      return await fn();
    } catch (err) {
      if (attempt > retries) throw err;
      console.log(`${err.message}, retrying in ${delay * 2 ** (attempt - 1)} ms`);
      await new Promise((r) => setTimeout(r, delay * 2 ** (attempt - 1)));
    }
  }
}

console.log(await retry(flakyFetch));
attempt 1 failed, retrying in 200 ms
attempt 2 failed, retrying in 400 ms
{ ok: true, attempt: 3 }

To give up on a slow request, pass an abort signal. AbortSignal.timeout() works with fetch in browsers and Node.js:

try {
  await fetch(url, { signal: AbortSignal.timeout(500) });
} catch (err) {
  console.log(err.name, '-', err.message);
}
// TimeoutError - The operation was aborted due to timeout   (server took 2 s)

For more on requests and cancellation, see HTTP networking in JavaScript and, in React, how to fetch data with React hooks.

Modern Async Features

// top-level await works in ES modules
const config = await Promise.resolve({ env: 'prod' });
console.log('top-level await:', config);

// for await...of over an async generator
async function* pages() {
  for (let page = 1; page <= 3; page++) {
    await new Promise((r) => setTimeout(r, 50));
    yield [`item ${page * 2 - 1}`, `item ${page * 2}`];
  }
}
for await (const items of pages()) console.log('page:', items);

// await on a non-promise value
console.log('await 42 ->', await 42);

// Promise.withResolvers (ES2024)
const { promise, resolve } = Promise.withResolvers();
setTimeout(() => resolve('resolved from outside'), 50);
console.log(await promise);
top-level await: { env: 'prod' }
page: [ 'item 1', 'item 2' ]
page: [ 'item 3', 'item 4' ]
page: [ 'item 5', 'item 6' ]
await 42 -> 42
resolved from outside
  • Top-level await (ES2022) works in ES modules, including <script type="module"> and .mjs files, but not in classic scripts or CommonJS.
  • for await...of consumes async iterables such as paginated APIs, streams and async generators.
  • Awaiting a non-promise simply returns the value (after a microtask), so await is safe on values that might or might not be promises.
  • Promise.withResolvers() (ES2024) gives you a promise together with its resolve and reject functions, handy for wrapping event-based APIs.

Common Async/Await Mistakes

  • Forgetting await: you get a pending Promise object instead of the data, and errors go unhandled.
  • Awaiting independent calls one after another instead of using Promise.all.
  • Using await inside forEach, which never waits.
  • Thinking await blocks the thread. It only pauses the current async function; CPU-heavy work still blocks, and belongs in a Web Worker (see how to speed up JavaScript).
  • return without await inside try, which skips your catch.
  • Not checking res.ok with fetch: fetch only rejects on network errors, not on 404 or 500 responses.
  • Retrying forever without a limit or backoff.

Conclusion

Async/await is promises with better syntax. An async function runs until its first await, hands its caller a promise, and resumes from the microtask queue once the awaited promise settles, so the main thread stays free. Wrap awaits in try...catch (and use return await inside it), start independent work together with Promise.all, avoid await in forEach, and always handle rejections. With those habits, asynchronous JavaScript reads almost like synchronous code without its costs.

FAQs

How does async/await work in JavaScript?

An async function always returns a promise. When it reaches await, it pauses, returns control to the caller and lets other code run. When the awaited promise settles, the function resumes from the microtask queue with the value, or with a thrown error if the promise rejected.

Does await block the main thread?

No. await pauses only the async function it is in; the event loop keeps running other code, timers and events. CPU-heavy synchronous code still blocks the thread, whether or not it is inside an async function.

What is the difference between promises and async/await?

Async/await is built on promises. Promises use .then() and .catch() callbacks; async/await lets you write the same logic as sequential code with try…catch. An async function returns a promise, and await consumes one.

How do I run multiple async functions in parallel?

Start them without awaiting each one, then await them together: const [a, b] = await Promise.all([getA(), getB()]). In my test, two 500 ms operations took 1004 ms sequentially and 500 ms with Promise.all.

How do I handle errors with async/await?

Wrap await calls in try…catch, or call .catch() on the promise the async function returns. Use return await inside try blocks so rejections are caught there, and use Promise.allSettled when some operations are allowed to fail.

Can I use await outside an async function?

Yes, at the top level of ES modules (ES2022), such as .mjs files and script type=”module”. In classic scripts, CommonJS modules and regular functions, await is only allowed inside async functions.

Why does await not work inside forEach?

forEach ignores the promises returned by its callback, so it does not wait for them. Use for…of to process items one at a time, or map with Promise.all to process them in parallel.

Snehasish Konger
Developed @scientyficworld.org | Technical writer @Nected | Content Developer
Connect with Snehasish Konger

On This page

Take a Pause with Intervals

A Sunday letter on building, writing, and thinking deeper as a developer — short, honest, and worth your time.

Snehasish Konger profile photo

"Hey there — I'm Snehasish. Hope this post saved you some head-scratching time! I've spent years turning technical chaos into clarity, and I'm here to be your guide through the maze of modern tech. Stick around for more lightbulb moments — we're just getting started."

Related Posts