How to buildOctober 11, 20268 min read
JavaScript from scratchStep 8 of 9

Asynchronous JavaScript: promises, async/await and fetch

In short

Asynchronous JavaScript is built on promises: a promise is an object standing for a result that is pending now and later either fulfills with a value or rejects with an error. async/await lets you work with promises like ordinary code and catch errors with try/catch. fetch rejects only on a network failure or an abort, so you check the response status with response.ok; independent requests run at the same time with Promise.all or Promise.allSettled, and you cancel them with AbortController and AbortSignal.timeout().

TutorialLevel: IntermediateJavaScriptPractice: ~1.5–3 h

After this you will be able to

  • Explain the execution order of synchronous code, promise microtasks and timers
  • Write data loading with fetch and async/await, checking response.ok and telling HTTP errors, network failures, aborts and timeouts apart
  • Run independent requests in parallel with Promise.all and Promise.allSettled and choose between them
  • Cancel a request with AbortController, limit it with AbortSignal.timeout() and combine signals with AbortSignal.any()

Verified with

Node.js 24 LTS (24.18.0)JavaScript (ES2024)Fetch APIAbortSignal.timeout / AbortSignal.any (Baseline 2024)Microsoft Edge 155 + Playwright 1.64

Checked against docs: October 11, 2026

Contents
  1. 01Synchronous and asynchronous: the event loop without deep theory
  2. 02Promises: states, then, catch and finally
  3. 03async/await and try/catch
  4. 04fetch: the response, response.ok and json()
  5. 05In parallel: Promise.all and Promise.allSettled
  6. 06Cancelling and timeouts: AbortController and AbortSignal.timeout
  7. 07Practice: a list with loading, error, retry and cancel
  8. 08Exercises and next steps

Synchronous and asynchronous: the event loop without deep theory

JavaScript runs code on a single thread: one statement at a time. Yet the page does not freeze while a network request is in flight. The trick is the event loop: long operations — a request, a timer, reading a file — are carried out by the browser or Node.js, and when the result is ready, a function that handles it is put in a queue. According to MDN, each such job runs to completion before the next one starts; it cannot be interrupted halfway.

order.mjs
console.log("1. synchronous code: start");

setTimeout(() => console.log("4. timer — a task from the queue"), 0);

Promise.resolve().then(() => console.log("3. promise — a microtask"));

console.log("2. synchronous code: end");
Four lines: in which order will they appear?
terminal
$ node order.mjs
1. synchronous code: start
2. synchronous code: end
3. promise — a microtask
4. timer — a task from the queue
Real Node.js 24 output. The order is the same in the browser.
  • First, all synchronous code runs to the end: lines 1 and 2.
  • Then microtasks, i.e. promise handlers. According to MDN, they run as soon as the current code has finished and the stack is empty, before control returns to the event loop.
  • Then the next task from the queue: the timer callback, even with a 0 delay.

Promises: states, then, catch and finally

A promise is an object that represents the future result of an asynchronous operation. According to MDN it has three states: pending, fulfilled (there is a value) and rejected (there is a reason, an error). A fulfilled or rejected promise is called settled, and its state never changes again.

promise-basics.mjs
// A promise that fulfills after ms milliseconds
function wait(ms, value) {
  return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}

// A promise that rejects after ms milliseconds
function failAfter(ms, message) {
  return new Promise((resolve, reject) => setTimeout(() => reject(new Error(message)), ms));
}

const order = wait(200, "Cappuccino is ready");
console.log(order);                               // still pending

order
  .then((text) => {
    console.log(text);
    return text.length;                           // the value goes to the next then
  })
  .then((length) => console.log("Characters:", length))
  .catch((error) => console.log("Error:", error.message))
  .finally(() => console.log("Order closed"));

failAfter(100, "Out of milk")
  .then(() => console.log("We never get here"))
  .catch((error) => console.log("Error:", error.message));
Two timer-based promises: one fulfills after 200 ms, the other rejects after 100 ms.
terminal
$ node promise-basics.mjs
Promise { <pending> }
Error: Out of milk
Cappuccino is ready
Characters: 19
Order closed
Real Node.js 24 output.
  • Right after creation the promise is <pending>: there is no value yet.
  • "Out of milk" was printed before "Cappuccino is ready": the second promise settled after 100 ms, the first after 200. Output order follows completion time, not line order.
  • then returns a new promise: whatever the handler returns (text.length) is passed to the next then. That is how a chain is built.
  • catch catches a rejection from any step above it in the chain, and finally runs either way, which is handy for hiding a loading indicator.

pending

Meaning
The operation is still running
Which handler runs
None yet: we wait

fulfilled

Meaning
Success, there is a value
Which handler runs
then, then finally

rejected

Meaning
Failure, there is a reason
Which handler runs
catch, then finally

async/await and try/catch

then chains get unwieldy quickly. async/await is the same promise mechanism written as ordinary sequential code. According to MDN, an async function always returns a promise, and await pauses only that function until the promise settles; the rest of the program keeps running. If the promise rejects, await throws, and a plain try/catch catches it.

async-await.mjs
const wait = (ms, value) => new Promise((resolve) => setTimeout(() => resolve(value), ms));
const elapsed = (start) => `${Math.round((performance.now() - start) / 100) * 100} ms`;

async function makeOrder() {
  const coffee = await wait(300, "cappuccino");   // the function waits here, other code does not
  const dessert = await wait(300, "cheesecake");
  return `${coffee} and ${dessert}`;               // an async function always returns a promise
}

async function makeOrderParallel() {
  const [coffee, dessert] = await Promise.all([wait(300, "cappuccino"), wait(300, "cheesecake")]);
  return `${coffee} and ${dessert}`;
}

let start = performance.now();
console.log(await makeOrder(), "—", elapsed(start));

start = performance.now();
console.log(await makeOrderParallel(), "—", elapsed(start));

async function failingOrder() {
  await wait(100);
  throw new Error("The coffee machine is under maintenance"); // becomes a rejected promise
}

try {
  await failingOrder();
} catch (error) {
  console.log("Caught:", error.message);
}
Sequential waiting, parallel waiting and an error inside an async function. Time is rounded to 100 ms.
terminal
$ node async-await.mjs
cappuccino and cheesecake — 600 ms
cappuccino and cheesecake — 300 ms
Caught: The coffee machine is under maintenance
Real Node.js 24 output: the same result in 600 ms and in 300 ms.

The main trap is needless sequencing. In makeOrder the second await starts only after the first, so two independent 300 ms waits took 600 ms. In makeOrderParallel both promises are created at once and Promise.all waits for them together: 300 ms. If the second request does not depend on the result of the first, don't await them one after another.

fetch: the response, response.ok and json()

What an HTTP request and response consist of and how to call fetch was covered in the HTTP and JSON lesson. Here we wrap fetch in a function you can safely call from anywhere. The key fact from MDN: the fetch promise rejects only on a network error, while a 404 or 500 response arrives as an ordinary Response. So the status is checked by hand: response.ok is true for codes 200–299.

get-json.mjs
export class HttpError extends Error {
  constructor(response) {
    super(`HTTP ${response.status} for ${response.url}`);
    this.name = "HttpError";
    this.status = response.status;
  }
}

// fetch + status check + JSON parsing in one place
export async function getJson(url, { signal } = {}) {
  const response = await fetch(url, { signal });  // rejects only on a network error or abort
  if (!response.ok) throw new HttpError(response); // 404 and 500 arrive as a normal response
  return response.json();                         // also a promise: the body is read as a stream
}
The helper used in all examples below: an HTTP error becomes an exception carrying the status.
fetch-demo.mjs
import { getJson } from "./get-json.mjs";

const API = "http://127.0.0.1:8090/api";

async function show(url) {
  try {
    const data = await getJson(url);
    console.log("OK:", data.map((item) => item.name).join(", "));
  } catch (error) {
    console.log(`${error.name}: ${error.message}`);
  }
}

await show(`${API}/menu`);
await show(`${API}/missing`);
await show("http://127.0.0.1:8091/api/menu");   // nothing listens on this port
Three requests to a local server: a successful one, a missing path and a port nobody listens on.
terminal
$ node fetch-demo.mjs
OK: Cappuccino, Latte, Raf, Cheesecake
HttpError: HTTP 404 for http://127.0.0.1:8090/api/missing
TypeError: fetch failed
Real Node.js 24 output with server.mjs from the practice section running.

A 2xx response

What fetch returned
Response, ok: true
What getJson throws
Nothing: it returns the data

A 404, 500, 503 response

What fetch returned
Response, ok: false
What getJson throws
HttpError with status

Server unreachable, no network

What fetch returned
A rejected promise
What getJson throws
TypeError (in Node.js "fetch failed", reason in cause)

Cancelled with abort()

What fetch returned
A rejected promise
What getJson throws
AbortError

AbortSignal.timeout() expired

What fetch returned
A rejected promise
What getJson throws
TimeoutError

response.json() is a promise too: the body arrives as a stream, and parsing starts once it has been received in full. If the server returned something other than JSON, this promise rejects with a SyntaxError.

In parallel: Promise.all and Promise.allSettled

A page often needs several independent responses: the menu, stock levels, promotions. Sequential awaits add up the time of all requests; running them in parallel takes as long as the slowest one. Promise has static methods for this; the two main ones are all and allSettled.

parallel.mjs
import { getJson } from "./get-json.mjs";

const API = "http://127.0.0.1:8090/api";
const elapsed = (start) => `${Math.round((performance.now() - start) / 100) * 100} ms`;

// Promise.all: we need every result, the requests run at the same time
let start = performance.now();
const [menu, stock] = await Promise.all([
  getJson(`${API}/menu?delay=400`),
  getJson(`${API}/stock?delay=400`),
]);
console.log(`all: ${menu.length} items, ${stock.beans} kg of beans — ${elapsed(start)}`);

// If one request fails, Promise.all rejects as a whole
try {
  await Promise.all([getJson(`${API}/menu`), getJson(`${API}/promo`)]);
} catch (error) {
  console.log("all rejected:", error.message);
}

// Promise.allSettled: wait for all of them and learn the outcome of each
const results = await Promise.allSettled([
  getJson(`${API}/menu`),
  getJson(`${API}/promo`),
  getJson(`${API}/stock`),
]);
for (const r of results) {
  console.log("allSettled:", r.status, r.status === "fulfilled" ? "got data" : r.reason.message);
}
The server returns the menu and stock with a 400 ms delay; the promo service responds with 503.
terminal
$ node parallel.mjs
all: 4 items, 12 kg of beans — 400 ms
all rejected: HTTP 503 for http://127.0.0.1:8090/api/promo
allSettled: fulfilled got data
allSettled: rejected HTTP 503 for http://127.0.0.1:8090/api/promo
allSettled: fulfilled got data
Real Node.js 24 output: two 400 ms requests took 400 ms, not 800.

Promise.all

  • Fulfills with an array of values in the same order as the promises
  • Rejects immediately on the first error, and the other results are lost
  • MDN: when tasks are related and you cannot continue without any of them
  • Example: a product and its price for an order page

Promise.allSettled

  • Always waits for all of them and never rejects
  • Returns { status, value } or { status, reason } objects
  • MDN: when tasks are independent or you need the outcome of each
  • Example: dashboard widgets, where a broken promo must not hide the menu

Two more methods are less common: Promise.race settles together with the first promise that settles, successful or not, and Promise.any with the first successful one, rejecting only if all of them fail. You no longer need race for request timeouts: AbortSignal.timeout() does that and also stops the request itself.

Cancelling and timeouts: AbortController and AbortSignal.timeout

A request can become unnecessary: the user left the page, pressed "Cancel" or typed another character into a search box. And a server might not respond at all: fetch without a limit will wait a long time. For both cases fetch accepts a signal option. The signal comes from an AbortController: calling controller.abort() rejects the fetch promise with an AbortError. AbortSignal.timeout(ms) creates a signal that fires on its own after the given time, with a TimeoutError, and AbortSignal.any([...]) combines several signals: whichever fires first wins.

abort.mjs
import { getJson } from "./get-json.mjs";

const SLOW = "http://127.0.0.1:8090/api/slow";   // the server responds after 5 seconds

// 1. Cancel on demand: the user left the page or pressed "Cancel"
const controller = new AbortController();
setTimeout(() => controller.abort(), 200);
try {
  await getJson(SLOW, { signal: controller.signal });
} catch (error) {
  console.log("1:", error.name);
}

// 2. Timeout: do not wait longer than 500 ms
try {
  await getJson(SLOW, { signal: AbortSignal.timeout(500) });
} catch (error) {
  console.log("2:", error.name);
}

// 3. Both: whichever comes first
const userCancel = new AbortController();
const signal = AbortSignal.any([userCancel.signal, AbortSignal.timeout(3000)]);
setTimeout(() => userCancel.abort(), 300);
try {
  await getJson(SLOW, { signal });
} catch (error) {
  console.log("3:", error.name, "— the user cancelled before the timeout");
}
The server answers /api/slow after 5 seconds; all three requests are aborted earlier.
terminal
$ node abort.mjs
1: AbortError
2: TimeoutError
3: AbortError — the user cancelled before the timeout
Real Node.js 24 output.
  • Different error names let you react differently: a user cancellation is usually ignored silently, a timeout is shown as an error with an offer to retry.
  • MDN notes that the timeout counts active time: in a page held in the back-forward cache the countdown is paused.
  • MDN lists AbortSignal.timeout and AbortSignal.any as Baseline 2024; Node.js has had them since versions 17.3 and 20.3. Very old browsers need a fallback built on setTimeout and controller.abort().
  • Aborting stops waiting for the response on the client side, but the server may already have received and processed the request. Aborting does not roll back an action on the server.

Practice: a list with loading, error, retry and cancel

Let's build what all of this is for: a "Load menu" button that shows a loading state, renders the list, offers "Retry" on error and lets you cancel while the request is running. We won't wait longer than 5 seconds. The server uses node:http without external packages and also serves the page, so requests go to the same origin and CORS is not needed.

server.mjs
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";

const menu = [
  { id: 1, name: "Cappuccino", price: 1600 },
  { id: 2, name: "Latte", price: 1800 },
  { id: 3, name: "Raf", price: 2200 },
  { id: 4, name: "Cheesecake", price: 2400 },
];
const stock = { beans: 12, milk: 30 };
let flakyCalls = 0;

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

function sendJson(res, status, data) {
  res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
  res.end(JSON.stringify(data));
}

const server = createServer(async (req, res) => {
  const url = new URL(req.url, "http://localhost");
  const delay = Number(url.searchParams.get("delay") ?? 0);
  if (delay) await wait(delay);                 // simulate a slow network

  switch (url.pathname) {
    case "/":
      res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
      return res.end(await readFile(new URL("index.html", import.meta.url)));
    case "/list.js":
      res.writeHead(200, { "Content-Type": "text/javascript; charset=utf-8" });
      return res.end(await readFile(new URL("list.js", import.meta.url)));
    case "/api/menu":
      return sendJson(res, 200, menu);
    case "/api/stock":
      return sendJson(res, 200, stock);
    case "/api/promo":
      return sendJson(res, 503, { error: "Promo service is down" });
    case "/api/slow":
      await wait(5000);
      return sendJson(res, 200, { ok: true });
    case "/api/flaky":                          // the first two requests get 503, then 200
      flakyCalls++;
      return flakyCalls <= 2
        ? sendJson(res, 503, { error: "Try again later" })
        : sendJson(res, 200, menu);
    case "/api/search": {
      const q = (url.searchParams.get("q") ?? "").toLowerCase();
      return sendJson(res, 200, menu.filter((item) => item.name.toLowerCase().includes(q)));
    }
    default:
      return sendJson(res, 404, { error: "Not found" });
  }
});

server.listen(8090, "127.0.0.1", () => console.log("API: http://127.0.0.1:8090"));
A practice API: menu, stock, a failing promo service, a slow response, a "flaky" path and search. The delay parameter simulates a slow network.
index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Coffee shop menu</title>
  <script src="list.js" defer></script>
</head>
<body data-state="idle">
  <main>
    <h1>Menu</h1>
    <button type="button" id="load">Load menu</button>
    <button type="button" id="cancel" hidden>Cancel</button>
    <p id="status" role="status"></p>
    <ul id="menu"></ul>
  </main>
</body>
</html>
The markup: buttons, a status line with role="status" for screen readers and an empty list.
list.js
const loadButton = document.querySelector("#load");
const cancelButton = document.querySelector("#cancel");
const statusText = document.querySelector("#status");
const list = document.querySelector("#menu");

let controller = null;

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

function setState(state, message) {
  document.body.dataset.state = state;      // idle | loading | done | error
  statusText.textContent = message;
  loadButton.disabled = state === "loading";
  loadButton.textContent = state === "error" ? "Retry" : "Load menu";
  cancelButton.hidden = state !== "loading";
}

function renderMenu(items) {
  list.replaceChildren(...items.map((item) => {
    const li = document.createElement("li");
    li.textContent = `${item.name} — ${item.price} ₸`;
    return li;
  }));
}

async function loadMenu() {
  controller = new AbortController();
  const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(5000)]);
  setState("loading", "Loading the menu…");
  list.replaceChildren();

  try {
    const items = await getJson("/api/menu", signal);
    renderMenu(items);
    setState("done", `Items on the menu: ${items.length}`);
  } catch (error) {
    if (error.name === "AbortError") {
      setState("idle", "Loading cancelled");
    } else if (error.name === "TimeoutError") {
      setState("error", "The server did not respond within 5 seconds");
    } else {
      setState("error", "Could not load the menu. Check your connection");
    }
  } finally {
    controller = null;
  }
}

loadButton.addEventListener("click", loadMenu);
cancelButton.addEventListener("click", () => controller?.abort());
All the logic: state, loading with cancel and timeout, rendering through textContent.
  1. Start the server

    Run node server.mjs in the folder with the three files and open http://127.0.0.1:8090 in a browser.

  2. Load the menu

    Press "Load menu": four items appear along with "Items on the menu: 4".

  3. Turn on a slow network

    In DevTools on the Network tab pick a slow network profile and press the button again; make sure to press "Cancel" in time.

  4. Break the server

    Stop server.mjs and press the button: an error and a "Retry" button appear. Start the server again and the retry loads the menu.

Success

How we simulated it
The real server, response delayed by 500 ms
Result
While loading the button is disabled and "Cancel" is visible; then 4 items

Cancel

How we simulated it
No response; "Cancel" clicked after 200 ms
Result
"Loading cancelled", empty list, the button is "Load menu" again

Timeout

How we simulated it
No response
Result
After ~5 s "The server did not respond within 5 seconds" and "Retry"

Server error

How we simulated it
A 500 response
Result
An error message and "Retry"; retrying with a working server loaded the menu

No network

How we simulated it
Connection dropped
Result
An error message, the list is cleared
Scenarios we ran in Edge 155 with Playwright

Exercises and next steps

Next, these techniques show up in React: loading data in useEffect and aborting when the component unmounts. And a fetch wrapper with types, retries and a single error format is built in the TypeScript API client recipe.

How this was verified3 · passedchecked 11 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.