Index Ai Academy
Phase 8 · APIs · JavaScript

Async JavaScript: promises, await and fetch

The event loop, async and await, fetch with res.ok checks, Promise.all, and timeouts with AbortController.

About 30 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

A chat front end spends most of its life waiting: for the model's next words, for a click on Stop, for an API that might be slow or down. JavaScript was built for this. It never blocks while it waits; instead it keeps a list of what to do when each wait ends. Python has the same idea in asyncio, but in JavaScript it's everywhere: every network call returns a promise. Getting the order of events right is the difference between an interface that feels instant and one that shows stale data, or freezes, or charges a card twice.

What you'll be able to do

  • Predict the order async code runs in: everything already running first, then what was waiting
  • Write async functions with await, and catch their errors with try/catch
  • Call an API with fetch, check res.ok, and send JSON
  • Start requests together with Promise.all, and give up on slow ones with a timeout

What it covers

  1. One thread and a queue
  2. Promises: a value that arrives later
  3. async and await
  4. The first product in stock
  5. Errors nobody awaits
  6. fetch: calling an API
  7. Find the bug: an error page treated as a product
  8. Several requests at once: Promise.all
  9. Giving up: timeouts and AbortController
Quick check
A guess first. In what order do these three lines print?
console.log("order placed");
setTimeout(() => console.log("email sent"), 0);
console.log("thank-you page shown");
  1. order placed thank-you page shown email sent
  2. email sent order placed thank-you page shown
  3. order placed thank-you page shown
  4. order placed email sent thank-you page shown
Show the answer

order placed thank-you page shown email sent

A timer's callback waits in a queue until the code that's running now has finished, even with a delay of 0. So both direct logs come first.

One thread and a queue

JavaScript runs one piece of code at a time. When code starts something slow, a timer or a network request, it doesn't stand and wait: it hands the job to the browser (or Node) with a function to call when the job is done, and carries straight on. Finished jobs line up in a queue, and the event loop takes the next one only when the code that's running has returned.

Two consequences matter every day. First, a callback never interrupts your code halfway through a function, which makes shared variables much easier to reason about than with threads. Second, a long calculation blocks everything: no clicks, no redraws, no network callbacks until it ends. Keep work on the main thread short, and let waiting be waiting.

Python's asyncio works the same way, but you have to start its loop yourself. In JavaScript, every page and every Node program already has one running.

Quick check
A product page ranks 50,000 reviews in a plain loop that takes three seconds. What does the shopper see during those seconds?
  1. A normal page: the loop runs in the background
  2. A page whose clicks run on a second thread
  3. A normal page, except that network requests pause
  4. A frozen page: clicks, scrolling and redraws wait for the loop
Show the answer

A frozen page: clicks, scrolling and redraws wait for the loop

There's one thread, and the loop holds it. Nothing else runs until it returns. Long work belongs in smaller pieces, a Web Worker or the server.

Promises: a value that arrives later

A promise stands for a result that isn't ready yet. It starts pending, then becomes fulfilled with a value or rejected with an error, once, for good. fetch returns one, as does anything that waits.

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

console.log("start");
const ready = wait(50).then(() => "tent packed");
console.log(ready);
console.log(await ready);

The second line prints the promise itself, still pending. await ready pauses until it settles and gives you the value. You'll still meet .then(callback) in older code; it registers what to do with the value, and returns a new promise, so calls chain. await reads more like ordinary code, so modern code uses it.

Quick check
The mug is sold out, and getStock("MUG-01") resolves to 0. What does this print?
async function getStock(sku) {
  return sku === "MUG-01" ? 0 : 4;
}
const stock = getStock("MUG-01");
console.log(stock === 0 ? "sold out" : "in stock");
  1. in stock
  2. sold out
  3. Promise { 0 }
  4. TypeError: stock is a promise
Show the answer

in stock

An async function always returns a promise. Without await, stock is that promise, which is never === 0, so the code says "in stock" for a sold-out mug. const stock = await getStock("MUG-01") fixes it.

async and await

Mark a function async and two things change. It always returns a promise, even when it returns a plain value. And inside it you may write await, which pauses this function only until a promise settles. The rest of the page keeps running while it waits.

When an awaited promise rejects, await throws the error at that line, so ordinary try/catch handles it:

async function reserve(sku) {
  if (sku === "MUG-01") throw new Error("MUG-01 is sold out");
  return `reserved ${sku}`;
}

for (const sku of ["TENT-2P", "MUG-01"]) {
  try {
    console.log(await reserve(sku));
  } catch (err) {
    console.log("couldn't reserve:", err.message);
  }
}

Forgetting await is the classic bug, as the last check showed: the code carries on with a promise where it expected a value, and an error inside it surfaces nowhere near the cause. When a value looks wrong, log it; a pending promise prints as Promise { <pending> }.

Drill

The first product in stock

getStock(sku) is an async function that resolves to a stock count. Write async function firstInStock(skus) that returns the first SKU whose stock is above 0, checking them in order, or null when none are.

const STOCK = { "TENT-2P": 4, "MUG-01": 0, "FLEECE-M": 11 };
async function getStock(sku) {
  return STOCK[sku] ?? 0;
}

async function firstInStock(skus) {
  // check each sku in order with getStock, and return the first with stock above 0
}

console.log(await firstInStock(["MUG-01", "FLEECE-M", "TENT-2P"]));
In the lesson, your code is checked as you write it.
In your own words
In what order does async code run, and what does an async function return?

Write a sentence or two, then compare it with a good answer in the lesson.

Errors nobody awaits

An error inside a promise travels with the promise. If something awaits it, or calls .catch, the error lands there. If nothing does, it's an unhandled rejection: Node stops the program with the error, and a browser logs it to the console, where no user will see it, while the page carries on half-finished.

Two habits prevent it. Await every promise you start, or return it to a caller who will; a call like saveDraft(text) with no await in front deserves a second look in code review. And put one try/catch around the code each event handler runs, so a failure becomes a message on the page instead of a silent line in the console.

Quick check
A Save button's handler calls saveDraft(text) without await, and the request inside saveDraft fails. What happens?
  1. The handler stops at that line and shows the error to the user
  2. The browser reloads the page to recover
  3. The rejection goes unhandled: no message on the page, an error in the console
  4. fetch retries the request until it works
Show the answer

The rejection goes unhandled: no message on the page, an error in the console

Without await, the handler finished long before the request failed, so no try/catch is there to receive the error. Await it inside a try/catch and show a message.

fetch: calling an API

fetch(url) sends a request and returns a promise of a Response. Reading the body is a second wait, because the body can still be arriving: await res.json() or await res.text().

const res = await fetch("https://api.trailhead.test/products?max_price=100");
console.log(res.status, res.ok, res.headers.get("content-type"));
const { products } = await res.json();
console.log(products.map((p) => `${p.sku} ${p.price}`));

const created = await fetch("https://api.trailhead.test/orders", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ sku: "FLEECE-M", qty: 2 }),
});
console.log(created.status, await created.json());

To send JSON, set the method, a content-type header and a string body from JSON.stringify. One rule matters more than any other: fetch only rejects when no response arrives at all, such as a network failure. A 404 or a 500 is still a response, so the promise fulfills and your code must check res.ok (true for 200 to 299) itself.

Quick check
A reminder from calling APIs in Python. What does response.raise_for_status() do when the server answered 404?
  1. Nothing: 404 is a normal response
  2. It returns False
  3. It raises an HTTPError
  4. It retries the request once
Show the answer

It raises an HTTPError

raise_for_status turns a 4xx or 5xx status into an exception. fetch has no such call: the JavaScript habit is if (!res.ok) throw new Error(...).

Quick check
Order Z999 doesn't exist. What does this print?
const res = await fetch("https://api.trailhead.test/orders/Z999");
console.log(res.ok, res.status);
  1. true 404
  2. false 200
  3. false 404
  4. TypeError: fetch failed
Show the answer

false 404

The server answered, so fetch fulfills; the answer just says 404. res.ok is false for anything outside 200 to 299, which is what your code has to check.

Try it now

Step through one await. The page stays responsive in the middle, while the request is out.

Drill

Find the bug: an error page treated as a product

getProduct("NOPE-1") should fail with an error that mentions the status, but it returns the API's error message as if it were a product. Fix it so a non-OK response throws an Error whose message names the SKU and the status, exactly product NOPE-1: HTTP 404 for this one.

async function getProduct(sku) {
  const res = await fetch(`https://api.trailhead.test/products/${sku}`);
  return await res.json();
}

console.log(await getProduct("TENT-2P"));
console.log(await getProduct("NOPE-1"));
In the lesson, your code is checked as you write it.
In your own words
Why does fetch not throw on a 404, and what happens to an error nobody awaits?

Write a sentence or two, then compare it with a good answer in the lesson.

Several requests at once: Promise.all

await inside a loop runs the requests one after another, each waiting for the last. When they don't depend on each other, start them all and wait for all of them together. One slow reply then no longer holds up the others:

const skus = ["TENT-2P", "MUG-01", "FLEECE-M"];
const load = async (sku) => (await fetch(`https://api.trailhead.test/products/${sku}`)).json();

let started = Date.now();
for (const sku of skus) await load(sku);
console.log("one by one:", Date.now() - started, "ms");

started = Date.now();
const products = await Promise.all(skus.map(load));
console.log("together:", Date.now() - started, "ms", products.map((p) => p.name));

Promise.all takes an array of promises and fulfills with an array of their values in the same order, however the replies were timed. If any one rejects, the whole thing rejects with that error. When you want every result, failures included, use Promise.allSettled, which gives { status, value } or { status, reason } per item.

Quick check
What does this print?
const later = (ms, value) => new Promise((resolve) => setTimeout(() => resolve(value), ms));
const results = await Promise.all([later(60, "tent"), later(10, "mug"), later(30, "fleece")]);
console.log(results);
  1. [ 'tent' ]
  2. [ 'tent', 'mug', 'fleece' ]
  3. [ 'mug', 'fleece', 'tent' ]
  4. Promise { <pending> }
Show the answer

[ 'tent', 'mug', 'fleece' ]

Promise.all keeps the order of the array you gave it, whichever promise finished first. That's what lets you line results up with their inputs.

Giving up: timeouts and AbortController

A request with no time limit can wait for minutes. Give fetch a signal that can cancel it. For a time limit, AbortSignal.timeout(ms) makes one for you; the request then rejects with an error named TimeoutError. For a Stop button, make an AbortController, pass its signal, and call controller.abort() when the user clicks; the error is then named AbortError.

try {
  await fetch("https://api.trailhead.test/slow", { signal: AbortSignal.timeout(300) });
} catch (err) {
  console.log(err.name, "after 300 ms: show a message and offer to retry");
}

Some failures deserve a second try. A 503 or 429 often comes with a Retry-After header saying how many seconds to wait; a 400 or 404 won't change however often you ask. Retry the first kind a few times with a growing wait, and report the second kind at once.

Quick check
A chat page has a Stop button. Which pairing is right?
  1. Pass AbortSignal.timeout(0) to fetch when Stop is clicked
  2. Pass an AbortController's signal to fetch; call abort() on click
  3. Call res.json() early so the response ends sooner
  4. Throw an Error inside the Stop button's click handler
Show the answer

Pass an AbortController's signal to fetch; call abort() on click

The controller's signal goes into fetch when the request starts; abort() later cancels that request, and the awaiting code gets an AbortError it can catch.

In your own words
What does Promise.all keep, and how does AbortController stop a request?

Write a sentence or two, then compare it with a good answer in the lesson.

Practice what you just read

  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Create a free account

Try a quiz question

What does an async function return when its body ends with return 42?

  1. A promise that fulfills with 42
  2. The number 42
  3. undefined until it's awaited
  4. A function that returns 42

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson