Asynchronous JavaScript

This section documents modern ECMAScript and core browser JavaScript APIs — it is not tied to any specific framework or library (React, Vue, Angular, etc.). This content was generated with the assistance of AI and should be verified against the current ECMAScript specification and MDN documentation before relying on it in production, since JavaScript language features and browser API support continue to evolve.

This section’s bibliography lists the reference material consulted while preparing these pages.

Most real-world JavaScript is asynchronous: it waits for a timer, a user click, or a network response instead of running start to finish in one uninterrupted burst. This page walks through the three layers JavaScript offers for working with that — callbacks, Promises, and async/await — plus async iteration, which extends the iterator protocol to streams of asynchronous values.

Callback-based async, the historical starting point

The oldest and most fundamental style of asynchronous JavaScript is the callback: a function you write and hand to another function, which invokes ("calls back") your function once some condition or event occurs. Timers are the simplest example:

// Call checkForUpdates once, 60 seconds from now
setTimeout(checkForUpdates, 60000);

// Call it every 60 seconds, until cancelled
const intervalId = setInterval(checkForUpdates, 60000);
clearInterval(intervalId); // stop the repetition

Browser event handling is callback-based too (see Events for the full picture):

document.querySelector('#confirm button.okay')
  .addEventListener('click', applyUpdate);

And so is the older XMLHttpRequest API for making HTTP requests, which registers separate onload/onerror callbacks for the eventual response:

function getCurrentVersion(callback) {
  const request = new XMLHttpRequest();
  request.open('GET', '/api/version');
  request.onload = () => {
    if (request.status === 200) callback(null, parseFloat(request.responseText));
    else callback(request.statusText, null);
  };
  request.onerror = request.ontimeout = (e) => callback(e.type, null);
  request.send();
}

Callbacks work, but they have two well-known problems: nesting a callback inside a callback inside a callback ("callback hell") becomes hard to read, and a thrown exception inside an asynchronous callback cannot propagate back to the code that started the operation — there is no longer a call stack connecting the two. Promises exist to fix both problems.

Promises

A Promise is an object representing the eventual result of an asynchronous operation. Rather than passing a callback into a function, a Promise-returning function returns immediately with a Promise object, and the caller registers callbacks on that Promise:

fetch('/api/user/profile').then(response => {
  // called once the response's status/headers are available
});

States and terminology

A Promise is always in exactly one of three states:

  • pending — neither fulfilled nor rejected yet.

  • fulfilled — the operation succeeded; the Promise has a value.

  • rejected — the operation failed; the Promise has a reason (typically an Error).

Once settled (fulfilled or rejected), a Promise’s outcome never changes.

Chaining, not nesting

Each call to .then() returns a new Promise, which lets sequential async steps be expressed as a flat chain instead of nested callbacks:

fetch('/api/user/profile')
  .then(response => response.json()) // returns a Promise for the parsed body
  .then(profile => displayUserProfile(profile));

Nesting the second step inside the first’s callback would also work, but defeats the point — always return the next Promise from a .then() callback so the chain stays flat.

Error handling: .catch() and .finally()

.catch(fn) is shorthand for .then(null, fn): it registers a handler for rejection. Because a rejection "trickles down" a Promise chain until it finds a .catch(), the idiomatic pattern ends every chain with one:

fetch('/api/user/profile')
  .then(response => {
    if (!response.ok) return null; // treat as "logged out", not an error
    return response.json();
  })
  .then(profile => profile ? displayUserProfile(profile) : displayLoggedOutPage())
  .catch(error => {
    console.error('Failed to load profile:', error);
    displayErrorMessage('Something went wrong.');
  });

.finally(fn) registers a callback that runs whichever way the Promise settles (with no argument, since it can’t tell which) — the natural place for cleanup such as hiding a loading spinner.

A .catch() doesn’t have to sit only at the end of a chain — inserted mid-chain, it recovers from an error and lets the rest of the chain continue with its return value:

startAsyncOperation()
  .then(doStageTwo)
  .catch(recoverFromStageTwoError) // swallows a stage-two error and continues
  .then(doStageThree)
  .catch(logStageThreeErrors);

Running Promises in parallel, or racing them

  • Promise.all(promises) — fulfills with an array of every result once all fulfill; rejects immediately if any rejects.

  • Promise.allSettled(promises) — never rejects; resolves once every input has settled, with one {status, value} or {status, reason} object per input.

  • Promise.race(promises) — settles as soon as the first input settles (fulfilled or rejected).

  • Promise.any(promises) — fulfills as soon as the first input fulfills; rejects only if all reject.

const urls = ['/a.json', '/b.json', '/c.json'];
const bodies = await Promise.all(urls.map(url => fetch(url).then(r => r.json())));

Making your own Promises

Given an existing Promise-returning function, you can build another one just by chaining .then():

function getJSON(url) {
  return fetch(url).then(response => response.json());
}

To create a Promise from scratch (typically wrapping a callback-based API), use the Promise constructor, which synchronously invokes your function with resolve/reject callbacks that you call once the async work completes:

function wait(durationMs) {
  return new Promise((resolve, reject) => {
    if (durationMs < 0) {
      reject(new Error('duration must be non-negative'));
      return;
    }
    setTimeout(resolve, durationMs); // resolves with undefined once the timer fires
  });
}

Promise.resolve(value)/Promise.reject(reason) create an already-decided Promise — useful for handling a synchronous special case inside a function that must always return a Promise.

async/await

async/await (ES2017) is syntax sugar over Promises that lets asynchronous code read like ordinary synchronous code, without changing what actually happens at runtime — the code is still fully asynchronous.

  • Marking a function async makes it always return a Promise: a normal return value resolves that Promise to value, and a thrown exception rejects it.

  • Inside an async function (only), await promise pauses that function until promise settles: if it fulfills, await evaluates to the fulfillment value; if it rejects, await throws that rejection, which ordinary try/catch can handle.

async function getUserProfile() {
  try {
    const response = await fetch('/api/user/profile');
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return await response.json();
  } catch (error) {
    console.error('Failed to load profile:', error);
    throw error; // still rejects the Promise getUserProfile() returns
  }
}

await works with any expression that evaluates to a function call returning a Promise, and can be nested as deeply as needed — but only inside another async function (or, in supporting environments, at a module’s top level).

Awaiting multiple Promises concurrently

Sequential `await`s run one after another, even when the operations don’t depend on each other:

// Slow: the second fetch doesn't start until the first finishes
const a = await getJSON(urlA);
const b = await getJSON(urlB);

// Fast: both requests start immediately, and both are awaited together
const [a2, b2] = await Promise.all([getJSON(urlA), getJSON(urlB)]);

Worked example: fetching a REST API two ways

The same request, once with .then() chaining and once with async/await, both handling a failed or non-OK response. See Networking for the full fetch() API surface (request options, headers, streaming bodies) — this example focuses purely on the async-control-flow side.

// .then() chaining
function loadUser(id) {
  return fetch(`/api/users/${id}`)
    .then(response => {
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      return response.json();
    })
    .catch(error => {
      console.error('Failed to load user:', error);
      throw error;
    });
}

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

Both versions reject/throw for a network failure or a non-2xx status; callers can use .catch() or their own try/catch around either one identically, since an async function’s thrown errors surface as Promise rejections either way.

Async iteration

Neither a single Promise nor a plain for…​of loop is a great fit for a stream of asynchronous values (chunks of a file, incoming WebSocket messages). ES2018 adds asynchronous iterators and a for await…​of loop that extend the synchronous iterator protocol to this case.

An asynchronously iterable object implements Symbol.asyncIterator (instead of Symbol.iterator), returning an iterator whose next() method returns a Promise for a {value, done} result rather than the result directly. for await…​of calls that next(), awaits the returned Promise, and runs the loop body with the resolved value:

async function readAll(stream) {
  for await (const chunk of stream) {
    process(chunk);
  }
}

Async generators

Just as a generator is usually the easiest way to implement a synchronous iterable, an async generator — a function declared async function* — is usually the easiest way to implement an asynchronous one. It combines await (to pause for an async operation) with yield (to produce a value), and every yielded value is automatically wrapped in a Promise:

function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

async function* clock(intervalMs, times = Infinity) {
  for (let n = 1; n <= times; n++) {
    await delay(intervalMs);
    yield n;
  }
}

async function run() {
  for await (const tick of clock(1000, 5)) {
    console.log(tick); // 1, 2, 3, 4, 5 -- one per second
  }
}

Conceptually: an async generator is a regular generator function ( covered on the Iterators & Generators page) whose `yield`ed values also carry the ability to wait on a Promise before the next value is ready — the two features compose rather than being separate mechanisms.

Promise resolution and the microtask queue

Understanding when a .then() callback actually runs requires knowing about JavaScript’s event loop, which schedules three kinds of work: the synchronous call stack, the microtask queue (Promise callbacks, queueMicrotask()), and the macrotask queue (setTimeout, I/O, UI events). After every synchronous script (or call-stack frame) finishes, the engine drains the entire microtask queue — including any new microtasks scheduled while draining it — before running a single macrotask. This is why a Promise.resolve().then(…​) callback always runs before a setTimeout(…​, 0) callback, even though both are "asynchronous":

sequenceDiagram participant Stack as Call Stack participant Micro as Microtask Queue participant Macro as Macrotask Queue participant Engine as Event Loop Stack->>Stack: run synchronous script Stack->>Micro: Promise.then() schedules a microtask Stack->>Macro: setTimeout() schedules a macrotask Stack-->>Engine: call stack empties Engine->>Micro: drain entire microtask queue Micro-->>Engine: (new microtasks queued during drain run too) Engine->>Macro: run exactly one macrotask Macro-->>Engine: call stack empties again Engine->>Micro: drain microtask queue again Note over Engine: repeat: one macrotask, then fully drain microtasks, forever

The practical implication: Promise chains always resolve "as soon as possible" relative to timers and I/O callbacks, which is why await-heavy code can still starve the UI thread if a microtask chain keeps scheduling more microtasks without ever yielding to a macrotask.