Network requests take time. JavaScript runs on a single thread, so instead of freezing the page while it waits, it schedules work and continues. Promises and async/await are the modern way to write that code so it still reads top to bottom.
Learning objectives
- Explain the difference between synchronous and asynchronous code.
- Create and consume Promises.
- Use
async/awaitwithfetch, and handle failures withtry/catch. - Run requests in parallel with
Promise.all, and cancel them withAbortController.
1. The problem
console.log("start");
setTimeout(() => console.log("later"), 0);
console.log("end");
// start
// end
// later
The callback does not run until the call stack is empty. This is the event loop: synchronous code finishes first, then queued work runs.
2. Promises
A Promise represents a value that will exist later. It is either pending, fulfilled or rejected.
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
wait(500)
.then(() => console.log("half a second later"))
.catch((error) => console.error(error));
3. async / await
await pauses the function (not the page) until the Promise settles.
async function loadUsers() {
try {
const response = await fetch("https://jsonplaceholder.typicode.com/users");
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
const users = await response.json();
return users;
} catch (error) {
console.error("Could not load users", error);
return [];
}
}
Two rules that matter:
fetchonly rejects on network failure. A 404 or 500 still resolves — checkresponse.ok.- Any function that uses
awaitmust beasync, and callers must handle its returned Promise.
4. Parallel requests
// slow: one after another
const user = await fetch("/api/user").then((r) => r.json());
const posts = await fetch("/api/posts").then((r) => r.json());
// fast: both start immediately
const [userRes, postsRes] = await Promise.all([
fetch("/api/user"),
fetch("/api/posts"),
]);
const [user2, posts2] = await Promise.all([userRes.json(), postsRes.json()]);
Promise.all rejects as soon as one request fails. Promise.allSettled waits for every request and reports each result — use it when partial data is still useful.
5. Cancelling work
If the visitor types again before the previous search returns, cancel the old request:
let controller;
async function search(query) {
controller?.abort();
controller = new AbortController();
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
});
return await response.json();
} catch (error) {
if (error.name === "AbortError") return null;
throw error;
}
}
Without this, a slow earlier request can overwrite the results of a faster later one.
6. Rendering async data
const list = document.querySelector("#results");
list.textContent = "Loading…";
const users = await loadUsers();
list.replaceChildren();
for (const user of users) {
const item = document.createElement("li");
item.textContent = `${user.name} — ${user.email}`;
list.append(item);
}
Always show a loading state and an empty state. A blank screen looks like a bug.
Common mistakes
- Forgetting
await response.json()(and then wondering why the data is a Promise). - Not checking
response.ok. - Using
forEachwith anasynccallback and expecting it to wait — it does not.
Practice
Build the small API explorer in the assignment. It exercises loading, error handling and rendering in one page.