Step 08 of 21
Async JavaScript
Understand the event loop, write clear async code with promises and async/await, and fetch data with proper error handling.
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:
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:
console.log("1: script start");
setTimeout(() => console.log("2: timeout"), 0);
Promise.resolve().then(() => console.log("3: microtask"));
console.log("4: script end");1: script start
4: script end
3: microtask
2: timeoutThe 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.
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.
Callbacks, and why promises replaced them
The original async style passed a callback to be called later:
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:
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:
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:
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:
awaitpauses the async function until the promise settles, then gives you the fulfilled value – or throws the rejection reason, sotry/catchworks naturally.- Pausing does not block the thread. While the function waits, the event loop keeps handling clicks and rendering.
- An
asyncfunction always returns a promise. Returning42from it gives the caller a promise of42; throwing inside rejects that promise. awaitonly works insideasyncfunctions, and at the top level of ES modules.- An async function runs synchronously until its first
await:
async function demo() {
console.log("B");
await null;
console.log("D");
}
console.log("A");
demo();
console.log("C");A
B
C
DSequential 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:
// 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:
async function getJSON(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status} for ${url}`);
}
return response.json();
}Other body methods are response.text() and response.blob(). Each can be read only once.
To send data, set the method, headers and body:
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:
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":
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:
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 onceUnhandled 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.
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}`];
}loadProfile(id)returns{ user, posts }. It must get the user first (it fails for unknown ids), then the posts.loadUsers(ids)fetches all users in parallel and returns their names. ConfirmloadUsers([1, 2, 3])takes about 300 ms, not 900 ms.loadUsersSafely(ids)never rejects; it returns{ names, failed }wherefailedlists the ids that could not be loaded. Try[1, 99, 3].withTimeout(promise, ms)rejects withError("Timed out after <ms>ms")ifpromisetakes longer thanms. Check thatwithTimeout(getPosts(1), 200)rejects andwithTimeout(getPosts(1), 1000)fulfils.
Show solution
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));{
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,.catchand.finallychain. asyncfunctions always return promises;awaitpauses only that function and throws on rejection, so usetry/catch.- Run independent work in parallel with
Promise.allorPromise.allSettledinstead of awaiting one at a time. fetchdoes not reject on HTTP errors – checkresponse.ok– and useAbortControllerto cancel stale requests.
Check yourself
Quiz
0/4 correct · 4 left
1.What order does this log? console.log('a'); setTimeout(() => console.log('b'), 0); Promise.resolve().then(() => console.log('c')); console.log('d');
2.A fetch to /api/items returns a 404 status. What happens to the promise returned by fetch?
3.Three independent requests each take about 400 ms. Which code finishes in about 400 ms in total?
4.What does calling an async function that contains return 42 give the caller?
Concepts in this lesson
One-page notes on each idea, for review or a quick lookup later.
Finished reading?
Mark it complete to fill in your roadmap.