# Async JavaScript

> Understand the event loop, write clear async code with promises and async/await, and fetch data with proper error handling.

Frontend Developer roadmap · JavaScript · 20 min read · https://levelcode.org/roadmaps/frontend/async-javascript

Loading data, waiting for a timer, reading a file – these take time, and JavaScript cannot simply stop and wait without freezing the whole page. Asynchronous code lets you start slow work, carry on, and handle the result when it arrives. In this lesson you will learn how the event loop schedules that work, how promises represent future values, how `async`/`await` makes async code read like normal code, and how to use `fetch` without the mistakes that cause most real-world bugs.

## Why JavaScript needs async

JavaScript in a page runs on a **single main thread**, which it shares with rendering and input handling. While your code runs, nothing else on the page can happen. Try this in a console on a page with a button:

```js
const end = Date.now() + 3000;
while (Date.now() < end) {
  // busy-wait for 3 seconds
}
console.log("done");
```

For three seconds the page is frozen: clicks do nothing, animations stop, text cannot be selected. A network request can take far longer than that, so APIs like timers and `fetch` work differently. You *start* the operation, the browser does the waiting in the background, and your code is called back when it finishes.

## The event loop

Several pieces work together:

- The **call stack** holds the functions currently running. JavaScript runs one thing at a time, to completion.
- **Browser APIs** (timers, network, events) do their waiting outside JavaScript.
- When they finish, they queue a callback. Callbacks from timers, events and network go into the **task queue**. Promise reactions go into the **microtask queue**.
- The **event loop** repeats forever: take one task and run it to completion; then run *every* microtask, including ones queued along the way; then let the browser render if it needs to; repeat.

That ordering explains output that surprises almost everyone the first time:

```js
console.log("1: script start");

setTimeout(() => console.log("2: timeout"), 0);

Promise.resolve().then(() => console.log("3: microtask"));

console.log("4: script end");
```

```text
1: script start
4: script end
3: microtask
2: timeout
```

The synchronous code finishes first. Then the microtask queue is drained, so the promise callback runs. Only then does the event loop take the next task – the timer – even though its delay was 0.

Step through the same program below (with the labels shortened to letters), then try two harder ones. Watch where each callback waits: timer callbacks sit in Web APIs until they expire and then join the task queue, promise callbacks go to the microtask queue, and the microtask queue is always emptied before the next task starts.

> **Interactive: The event loop, step by step.** In the async/await program, notice that the code after await is queued as a microtask. In the nested program, a microtask queued inside a task still runs before the next task. Try it: https://levelcode.org/labs/event-loop

> [!NOTE]
> `setTimeout(fn, 0)` does not mean "now". It means "as a new task, after the current code and all pending microtasks have finished, and no sooner than 0 ms". Browsers also clamp deeply nested timers to at least 4 ms.

## Callbacks, and why promises replaced them

The original async style passed a callback to be called later:

```js
setTimeout(() => {
  console.log("step 1");
  setTimeout(() => {
    console.log("step 2");
    setTimeout(() => {
      console.log("step 3");
    }, 1000);
  }, 1000);
}, 1000);
```

Each dependent step nests deeper, and every level needs its own error handling. This "callback pyramid" is hard to read and easy to get wrong, which is why modern APIs return **promises** instead.

## Promises

A **promise** is an object representing a value that will be available later. It is always in one of three states:

- **pending** – the work is still in progress;
- **fulfilled** – it succeeded, with a value;
- **rejected** – it failed, with a reason (usually an `Error`).

A promise settles **once**. After it is fulfilled or rejected, it never changes again.

### Consuming promises

`.then()` registers what to do with the value, `.catch()` handles a rejection, and `.finally()` runs either way. Each returns a *new* promise, so calls chain without nesting:

```js
fetch("/api/user")
  .then((response) => {
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return response.json();          // returning a promise: the chain waits for it
  })
  .then((user) => console.log(user.name))
  .catch((error) => console.error("Could not load user:", error))
  .finally(() => console.log("request finished"));
```

Whatever a `.then` callback returns becomes the value for the next `.then`. If it throws – or anything earlier in the chain rejects – control skips to the nearest `.catch`.

### Creating promises

You rarely need to construct a promise yourself, because APIs like `fetch` return them. The exception is wrapping an old callback-based API. The classic example turns `setTimeout` into a promise:

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

wait(1000).then(() => console.log("one second later"));
```

The function passed to `new Promise` receives `resolve` and `reject`. Call `resolve(value)` to fulfil the promise or `reject(error)` to reject it.

## async and await

`async`/`await` is syntax built on promises that lets you write asynchronous code top to bottom:

```js
async function loadUser() {
  try {
    const response = await fetch("/api/user");
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    const user = await response.json();
    console.log(user.name);
  } catch (error) {
    console.error("Could not load user:", error);
  } finally {
    console.log("request finished");
  }
}
```

The rules:

- `await` pauses the **async function** until the promise settles, then gives you the fulfilled value – or throws the rejection reason, so `try`/`catch` works naturally.
- Pausing does not block the thread. While the function waits, the event loop keeps handling clicks and rendering.
- An `async` function **always returns a promise**. Returning `42` from it gives the caller a promise of `42`; throwing inside rejects that promise.
- `await` only works inside `async` functions, and at the top level of ES modules.
- An async function runs synchronously until its first `await`:

```js
async function demo() {
  console.log("B");
  await null;
  console.log("D");
}

console.log("A");
demo();
console.log("C");
```

```text
A
B
C
D
```

### Sequential versus parallel

`await` in sequence is right when each step needs the previous result. When steps are independent, awaiting them one by one wastes time:

```js
// Sequential: about 300 + 300 + 300 = 900 ms
const a = await getUser(1);
const b = await getUser(2);
const c = await getUser(3);

// Parallel: start all three, then wait for all – about 300 ms
const [x, y, z] = await Promise.all([getUser(1), getUser(2), getUser(3)]);
```

| Combinator | Settles when | Result |
| --- | --- | --- |
| `Promise.all` | All fulfil, or any one rejects | Array of values, or the first rejection |
| `Promise.allSettled` | All have settled | Array of `{ status, value }` or `{ status, reason }` |
| `Promise.race` | The first one settles | That value or rejection |
| `Promise.any` | The first one fulfils | That value, or an `AggregateError` if all reject |

Use `all` when you need every result, `allSettled` when partial success is acceptable (a dashboard with independent widgets), `race` for timeouts, and `any` for "whichever mirror answers first".

## Fetching data

`fetch(url, options)` makes an HTTP request and returns a promise of a `Response`. Reading the body is a second async step, because the body may still be streaming in:

```js
async function getJSON(url) {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`HTTP ${response.status} for ${url}`);
  }
  return response.json();
}
```

> [!IMPORTANT]
> `fetch` only rejects when the request could not be made at all: the network is down, the request was blocked (for example by CORS), or it was aborted. A `404` or `500` response **fulfils** normally. Always check `response.ok` (true for status 200–299) or `response.status` yourself.

Other body methods are `response.text()` and `response.blob()`. Each can be read only once.

To send data, set the method, headers and body:

```js
const response = await fetch("/api/orders", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ productId: 42, qty: 1 }),
});
```

### Cancelling requests and timeouts

An `AbortController` cancels a request. This matters for search-as-you-type: if the user types "sho" and then "shoe", the slower "sho" response could arrive *last* and overwrite the correct results. Aborting the previous request prevents that race:

```js
const input = document.querySelector("#search");
let controller;

input.addEventListener("input", async () => {
  controller?.abort();                 // cancel the previous request
  controller = new AbortController();

  try {
    const url = `/api/search?q=${encodeURIComponent(input.value)}`;
    const response = await fetch(url, { signal: controller.signal });
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    renderResults(await response.json());
  } catch (error) {
    if (error.name === "AbortError") return; // expected: we cancelled it
    showError(error);
  }
});
```

For a simple timeout, `AbortSignal.timeout(ms)` creates a signal that aborts automatically; the request then rejects with an error named `"TimeoutError"`:

```js
const response = await fetch("/api/slow", { signal: AbortSignal.timeout(5000) });
```

## Common pitfalls

**Forgetting `await`.** `const user = getUser(1)` gives you a pending promise, not a user, so `user.name` is `undefined`. If a value prints as `Promise { <pending> }`, you missed an `await`.

**`await` inside `forEach`.** `forEach` ignores the promises its callback returns, so nothing waits:

```js
ids.forEach(async (id) => await save(id));
console.log("all saved"); // runs before any save finishes

for (const id of ids) await save(id);       // fix 1: one at a time
await Promise.all(ids.map((id) => save(id))); // fix 2: all at once
```

**Unhandled rejections.** A rejected promise that nobody catches logs an "Uncaught (in promise)" error and your error handling never runs. Every chain needs a `.catch`, and every `await` that can fail needs a `try`/`catch` somewhere up the call chain.

**Mixing styles.** Combining `.then` chains and `await` in one function makes control flow hard to follow. Pick `async`/`await` for new code.

## Practice

Save this simulated API as `async-practice.mjs` (the `.mjs` extension enables top-level `await` in Node), then write the four functions below it and run `node async-practice.mjs`.

```js
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const users = { 1: "Ana", 2: "Ben", 3: "Cleo" };

async function getUser(id) {
  await wait(300);
  if (!users[id]) throw new Error(`User ${id} not found`);
  return { id, name: users[id] };
}

async function getPosts(userId) {
  await wait(500);
  return [`Post A by user ${userId}`, `Post B by user ${userId}`];
}
```

1. `loadProfile(id)` returns `{ user, posts }`. It must get the user first (it fails for unknown ids), then the posts.
2. `loadUsers(ids)` fetches all users **in parallel** and returns their names. Confirm `loadUsers([1, 2, 3])` takes about 300 ms, not 900 ms.
3. `loadUsersSafely(ids)` never rejects; it returns `{ names, failed }` where `failed` lists the ids that could not be loaded. Try `[1, 99, 3]`.
4. `withTimeout(promise, ms)` rejects with `Error("Timed out after <ms>ms")` if `promise` takes longer than `ms`. Check that `withTimeout(getPosts(1), 200)` rejects and `withTimeout(getPosts(1), 1000)` fulfils.

**Solution**

```js
async function loadProfile(id) {
  const user = await getUser(id);       // must come first
  const posts = await getPosts(user.id);
  return { user, posts };
}

async function loadUsers(ids) {
  const users = await Promise.all(ids.map((id) => getUser(id)));
  return users.map((user) => user.name);
}

async function loadUsersSafely(ids) {
  const results = await Promise.allSettled(ids.map((id) => getUser(id)));
  const names = [];
  const failed = [];
  results.forEach((result, i) => {
    if (result.status === "fulfilled") names.push(result.value.name);
    else failed.push(ids[i]);
  });
  return { names, failed };
}

function withTimeout(promise, ms) {
  let timer;
  const timeout = new Promise((_, reject) => {
    timer = setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms);
  });
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}

console.log(await loadProfile(2));

console.time("loadUsers");
console.log(await loadUsers([1, 2, 3]));
console.timeEnd("loadUsers");

console.log(await loadUsersSafely([1, 99, 3]));

try {
  await withTimeout(getPosts(1), 200);
} catch (error) {
  console.log(error.message);
}
console.log(await withTimeout(getPosts(1), 1000));
```

```text
{
  user: { id: 2, name: 'Ben' },
  posts: [ 'Post A by user 2', 'Post B by user 2' ]
}
[ 'Ana', 'Ben', 'Cleo' ]
loadUsers: 301.512ms
{ names: [ 'Ana', 'Cleo' ], failed: [ 99 ] }
Timed out after 200ms
[ 'Post A by user 1', 'Post B by user 1' ]
```

Your exact timing will differ slightly, but it should be close to 300 ms because all three requests wait at the same time. `allSettled` keeps the results in the same order as the input, which is why `ids[i]` identifies the failure. In `withTimeout`, `finally` clears the timer so a fast promise does not leave a pending timeout behind.

## Key takeaways

- JavaScript runs on one thread; async APIs do the waiting elsewhere and queue callbacks for the event loop.
- After each task, all microtasks (promise reactions) run before the next task, so promises beat `setTimeout(…, 0)`.
- A promise is pending, then fulfilled or rejected exactly once; `.then`, `.catch` and `.finally` chain.
- `async` functions always return promises; `await` pauses only that function and throws on rejection, so use `try`/`catch`.
- Run independent work in parallel with `Promise.all` or `Promise.allSettled` instead of awaiting one at a time.
- `fetch` does not reject on HTTP errors – check `response.ok` – and use `AbortController` to cancel stale requests.

## Quiz

1. What order does this log? console.log('a'); setTimeout(() => console.log('b'), 0); Promise.resolve().then(() => console.log('c')); console.log('d');

   A. a, b, c, d
   B. a, d, b, c
   C. a, c, d, b
   D. a, d, c, b

   **Answer: D.** Synchronous code runs first (a, d). Then all microtasks run (c) before the event loop takes the next task, the timer (b).

2. A fetch to /api/items returns a 404 status. What happens to the promise returned by fetch?

   A. It fulfils with a Response whose ok property is false
   B. It rejects with a TypeError
   C. It never settles
   D. It rejects with an AbortError

   **Answer: A.** fetch only rejects when no response could be obtained, such as a network failure or an abort. HTTP error statuses still produce a Response, so you must check response.ok.

3. Three independent requests each take about 400 ms. Which code finishes in about 400 ms in total?

   A. for (const id of ids) await load(id);
   B. const results = await Promise.all(ids.map(load));
   C. ids.forEach(async (id) => await load(id));
   D. const results = ids.map(async (id) => await load(id));

   **Answer: B.** Promise.all starts all requests at once and waits for every result. The for...of loop runs them one after another (about 1200 ms), forEach does not wait at all, and the plain map gives you an array of pending promises rather than results.

4. What does calling an async function that contains return 42 give the caller?

   A. The number 42 immediately
   B. undefined until the function finishes
   C. A promise that fulfils with 42
   D. A callback that must be invoked to get 42

   **Answer: C.** An async function always returns a promise. The returned value becomes the fulfilment value, so the caller uses await or .then to get 42.
