Async JavaScript: promises, await and fetch
The event loop, async and await, fetch with res.ok checks, Promise.all, and timeouts with AbortController.
Create a free account Try part of it here
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
- One thread and a queue
- Promises: a value that arrives later
- async and await
- The first product in stock
- Errors nobody awaits
- fetch: calling an API
- Find the bug: an error page treated as a product
- Several requests at once: Promise.all
- Giving up: timeouts and AbortController
console.log("order placed"); setTimeout(() => console.log("email sent"), 0); console.log("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.
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.
async function getStock(sku) { return sku === "MUG-01" ? 0 : 4; } const stock = getStock("MUG-01"); console.log(stock === 0 ? "sold out" : "in stock");
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> }.
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"]));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.
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.
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(...).
const res = await fetch("https://api.trailhead.test/orders/Z999"); console.log(res.ok, res.status);
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.
Step through one await. The page stays responsive in the middle, while the request is out.
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"));
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.
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);
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.
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.
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
Try a quiz question
What does an async function return when its body ends with return 42?