Async & fetch.
Talk to servers. Render real data.
Every real app talks to a server. Loading user profiles, fetching posts, submitting a form, streaming data — all of it is asynchronous. JavaScript's async model is what makes this possible without freezing the page. This lesson teaches Promises, async/await, and fetch. Once it clicks, you can build anything that talks to an API — the exact skill every frontend job requires.
JavaScript runs on a single thread. Long operations (network, timers) are asynchronous — the browser handles them off the main thread and queues a callback when done. fetch(url) returns a Promise that resolves to a Response object, not the data. You call response.json() (another Promise) to get the data. Wrap in async functions and use await to read top-to-bottom. Always try/catch. Always check response.ok — fetch does NOT reject on 404.
Before this lesson: complete Lesson 5. You need arrow functions, callbacks, and the DOM.
⏱️ Why async exists
JavaScript is single-threaded. Without async, every network request would freeze the page.
JavaScript runs one thing at a time. If a network request took 500ms and blocked everything, the page would be frozen for half a second — no scroll, no clicks, no animations. That's unacceptable. Asynchronous programming solves this: fetch() starts the request, returns immediately, and lets other code run while the browser waits for the response. When the response arrives, a callback fires.
// What would happen if fetch were synchronous:
console.log("Start");
const data = fetch("/api/users"); // this would freeze the page for 500ms
console.log("End"); // only runs after the fetch finishes
// What actually happens with async:
console.log("Start"); // 1 — runs immediately
fetch("/api/users").then(data => {
console.log("Got data"); // 3 — runs after response arrives
});
console.log("End"); // 2 — runs immediately, doesn't wait
// Output order:
// Start
// End
// Got data
// The fetch didn't block. Code kept running.
// When the response arrived, the .then callback fired.
// Same pattern with async/await:
async function loadUsers() {
console.log("Start");
const response = await fetch("/api/users"); // pauses THIS function only
console.log("Got data");
return response;
}
console.log("Start");
loadUsers(); // starts the async function
console.log("End"); // runs before the await resolves
// Output:
// Start
// End
// Got data
Single-threaded
JavaScript has one call stack. Only one function runs at a time. Async operations don't run "in parallel" — they run when the main thread is idle.
The event loop
When an async operation completes, its callback goes into a queue. The event loop pushes it onto the stack when it's empty. This is why "Start, End, Got data" — even though "Got data" was defined between Start and End.
Non-blocking
Async code doesn't freeze the page. Users can scroll, click, and interact while a request is in flight. Every real application depends on this.
Two syntaxes
Promises (.then/.catch) and async/await. They do the same thing. async/await is newer and reads more naturally. Use it unless you need Promise.all().
🤝 Promises — the underlying mechanism
A Promise is an object representing a future value. Three states: pending, fulfilled, rejected.
// A Promise is a placeholder for a value that arrives later
const promise = fetch("/api/users");
// promise is pending, then fulfilled or rejected
// .then() — attach a callback for success
promise.then(response => {
console.log("Success:", response);
});
// .catch() — attach a callback for failure
promise.catch(error => {
console.error("Failed:", error);
});
// .finally() — runs regardless of success or failure
promise.finally(() => {
console.log("Done, either way");
});
// Chaining — .then() returns a new Promise
fetch("/api/users")
.then(response => response.json()) // returns a Promise
.then(data => console.log(data)) // receives the parsed data
.catch(err => console.error(err));
// Promise.all() — run multiple in parallel, wait for all
const [users, posts, comments] = await Promise.all([
fetch("/api/users").then(r => r.json()),
fetch("/api/posts").then(r => r.json()),
fetch("/api/comments").then(r => r.json()),
]);
// All three requests fire in parallel. Waits for the slowest one.
// Promise.race() — first one to finish wins
const fastest = await Promise.race([
fetch("/api/primary"),
fetch("/api/fallback"),
]);
// Whichever responds first is used. The other is ignored.
// Creating a Promise manually
function wait(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
await wait(1000); // waits 1 second
console.log("1 second later");
.then()
Runs on success. Returns a new Promise, so you can chain. Pass a function, not the result of calling one.
.catch()
Runs on rejection. Catches errors from any point in the chain. Always add a .catch() to every chain — unhandled rejections crash silently.
Promise.all()
Runs Promises in parallel. Returns a Promise that resolves with all results. If any fails, the whole thing rejects. Use for independent operations.
Promise.race()
Returns a Promise that resolves with whichever Promise finishes first. Use for timeouts, fallbacks, or "whichever server responds faster."
Promises don't run when created — they run immediately. fetch("/api/users") starts the request as soon as it's called, before you attach a .then(). This means you can start a request, do other work, then attach handlers later. The request is already in flight. That's a feature, not a bug — it's how parallel requests happen.
✨ async/await — Promises, but readable
Syntactic sugar that makes asynchronous code look synchronous. The modern standard.
// Promise chain — works, but verbose
function loadUser() {
return fetch("/api/user")
.then(response => response.json())
.then(data => {
console.log(data);
return data;
})
.catch(err => console.error(err));
}
// async/await — same thing, easier to read
async function loadUser() {
try {
const response = await fetch("/api/user");
const data = await response.json();
console.log(data);
return data;
} catch (err) {
console.error(err);
}
}
// The async keyword does two things:
// 1. Allows you to use await inside the function
// 2. Makes the function always return a Promise
// await pauses the function, not the program
async function example() {
console.log("1");
await wait(1000);
console.log("2"); // runs after 1 second
}
// Sequential vs parallel — the performance difference
// SLOW — 3 seconds total, each await pauses
async function sequential() {
const a = await fetch("/api/a"); // 1s
const b = await fetch("/api/b"); // 1s
const c = await fetch("/api/c"); // 1s
return [a, b, c];
}
// FAST — 1 second total, all requests in flight
async function parallel() {
const [a, b, c] = await Promise.all([
fetch("/api/a"),
fetch("/api/b"),
fetch("/api/c"),
]);
return [a, b, c];
}
// Await only works inside async functions
// function broken() { await fetch("/api"); } // SyntaxError
// Use an async wrapper, or top-level await in ES modules:
// const data = await fetch("/api"); // valid in a .mjs file
await pauses the function, not the page. Inside an async function, await pauses execution until the Promise resolves. But the rest of your program keeps running — other functions, other event handlers, animations. This is why async doesn't freeze the browser. The await only pauses its own function.
Sequential vs parallel — the biggest performance trap in async code. Three await statements in a row take 3× as long as Promise.all() because each waits before starting the next. If the operations are independent, use Promise.all(). Only await sequentially when each operation depends on the previous result.
🌐 fetch() — the modern HTTP client
Two awaits: one for the response, one for the data. Forget the second and you get a Promise.
// Basic GET request — TWO awaits
async function getUsers() {
const response = await fetch("/api/users"); // 1st await
const data = await response.json(); // 2nd await
console.log(data);
}
// POST request — send data to the server
async function createUser(user) {
const response = await fetch("/api/users", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(user),
});
const data = await response.json();
return data;
}
// PUT/PATCH — update
await fetch("/api/users/1", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "New Name" }),
});
// DELETE
await fetch("/api/users/1", { method: "DELETE" });
// Custom headers (auth, API keys)
await fetch("/api/data", {
headers: {
"Authorization": "Bearer YOUR_TOKEN",
"Accept": "application/json",
},
});
// Query parameters — build URLs safely
const url = new URL("/api/search");
url.searchParams.set("q", "javascript");
url.searchParams.set("page", "1");
const response = await fetch(url);
// Response formats
const json = await response.json(); // parses JSON
const text = await response.text(); // raw string
const blob = await response.blob(); // for images, files
const buffer = await response.arrayBuffer(); // binary
fetch() does NOT reject on HTTP errors. A 404 or 500 response still resolves as a successful Promise. You must check response.ok yourself:
const response = await fetch("/api/users");
if (!response.ok) {
throw new Error("HTTP " + response.status);
}
This is the #1 fetch surprise. A 404 silently passes through your code unless you check. Always check response.ok.
Always two awaits
First for the Response object, second for the parsed data. Forgetting the second gives you a Promise, not the data. This is the most common fetch bug.
response.ok
True for 200–299, false otherwise. Check it before calling .json(). Fetch doesn't throw on 404/500 — it resolves with a "successful" response.
POST/PUT/DELETE
Second argument to fetch is an options object with method, headers, and body. Body must be a string — use JSON.stringify() for JSON.
CORS
Cross-origin requests are blocked by default unless the server sends CORS headers. If you see a CORS error, it's a server problem, not your code.
🚨 Error handling — try/catch for async
Async errors don't crash the program — they reject the Promise. Handle them explicitly.
// The pattern — try/catch around async operations
async function loadUser(id) {
try {
const response = await fetch("/api/users/" + id);
// fetch does NOT throw on 404/500 — must check manually
if (!response.ok) {
throw new Error("HTTP " + response.status);
}
const data = await response.json();
return data;
} catch (err) {
console.error("Failed to load user:", err.message);
return null; // or re-throw, or return a default
} finally {
console.log("Done loading user");
}
}
// Caller handles the result
const user = await loadUser(1);
if (user) {
renderUser(user);
} else {
showError("Could not load user");
}
// Promise chains — .catch() for the same purpose
fetch("/api/users/1")
.then(r => {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
})
.then(data => console.log(data))
.catch(err => console.error(err))
.finally(() => console.log("Done"));
// Common error patterns
// 1. Network failure — no internet, DNS error
// fetch() rejects → caught by try/catch
// 2. HTTP error — 404, 500
// fetch() resolves, response.ok is false → you throw
// 3. JSON parse error — malformed JSON
// response.json() rejects → caught by try/catch
// 4. Timeout — response never arrives
// Use AbortController (see below)
// Timeout with AbortController
async function fetchWithTimeout(url, ms = 5000) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), ms);
try {
const response = await fetch(url, { signal: controller.signal });
return await response.json();
} finally {
clearTimeout(timeout);
}
}
try/catch
Wraps any code that might throw or reject. catch receives the error. finally runs regardless of success or failure — for cleanup.
Check response.ok
fetch only rejects on network failure. HTTP errors like 404 or 500 resolve successfully. You must check response.ok and throw manually.
.catch() on chains
If you use .then() chains, add .catch() at the end. Without it, errors disappear silently.
AbortController
Pass signal: controller.signal to fetch, call controller.abort() to cancel. Use for timeouts and cancel-on-navigate.
Always handle errors in async code. An unhandled Promise rejection doesn't throw an error in the traditional sense — it fires an event on the window and may or may not log to the console depending on the browser. Your users just see "nothing happened." Add a .catch() or try/catch to every async operation, even if you just log the error.
🏆 Challenge
Three tasks. Each exercises a different async pattern.
Task 1 — Fetch a random quote
Use the public API at https://api.quotable.io/random. Write an async function that:
1. Fetches a random quote.
2. Checks response.ok.
3. Parses the JSON.
4. Logs the quote and author to the console.
5. Handles errors with try/catch.
Test it in the browser console. Then call it from a button click.
Task 2 — Parallel vs sequential
Write two versions of a function that fetches three GitHub users:
1. Sequential — await each fetch one at a time.
2. Parallel — use Promise.all().
Time both with console.time() and console.timeEnd(). The parallel version should be 2–3× faster. This demonstrates exactly why Promise.all() matters for independent operations.
Task 3 — Add a loading state
Take the GitHub lookup example above. Add:
1. A loading indicator while the fetch is in progress (already there — make it a spinner or animated dots).
2. Disable the search button during the fetch.
3. A timeout — if the fetch takes more than 5 seconds, show an error.
4. A "retry" button in the error state that re-runs the search.
These are the exact features every production search box has.
⚠️ Common mistakes
The async bugs that catch every beginner.
const data = await fetch(url) gives you a Response object, not the data. You need const data = await (await fetch(url)).json() or two separate awaits. The result of a missing await is a Promise, not the data.
fetch does NOT reject on 404 or 500. It resolves successfully. If you don't check response.ok, HTTP errors slip through and your code tries to parse the error page as JSON. Always check.
await fetch(url) at the top of a script throws a SyntaxError — await only works inside async functions. Fix: wrap in an async function, or use top-level await in an ES module.
for (const id of ids) await fetch(...) runs sequentially — 10× as slow as it needs to be. Use Promise.all(ids.map(id => fetch(...))) for parallel requests.
If the fetch fails and there's no try/catch, the async function's Promise rejects silently. Nothing happens in the UI. Users see nothing. Always wrap async operations in try/catch.
Don't wrap a fetch in a callback expecting the callback to be awaited. Pick one style. If the function is async, return Promises. Don't force callbacks on top.
Servers don't know you're sending JSON unless you say so. Include headers: { "Content-Type": "application/json" } on every POST/PUT/PATCH that sends JSON. Without it, the server sees form-encoded data and parses it wrong.
fetch doesn't work in IE11 or Node.js (before version 18). For modern browsers and Node 18+, it's built in. For older environments, use a polyfill or axios. For 99% of new code, native fetch is fine.
➡️ What's next
You've completed the JavaScript beginner + intermediate track.
❓ Frequently asked questions
What is asynchronous JavaScript?
Asynchronous JavaScript lets your code start a long-running task (like a network request) and continue running other code while that task completes. JavaScript runs on a single thread, so blocking operations would freeze the UI. Async programming solves this by deferring work and running a callback, Promise, or await continuation when the task finishes.
What is a Promise in JavaScript?
A Promise is an object representing the eventual result of an asynchronous operation. It has three states: pending, fulfilled, or rejected. You attach handlers with .then() for success and .catch() for failure. Promises are the foundation of async/await — await is just syntactic sugar over .then().
What is the difference between async/await and .then()?
async/await and .then() do the same thing — they wait for a Promise to resolve. async/await is syntactic sugar that makes asynchronous code look synchronous, which is easier to read and debug. Use async/await unless you need to fire multiple requests in parallel.
How does fetch() work in JavaScript?
fetch(url) sends an HTTP request and returns a Promise. The Promise resolves with a Response object, not the data. To get the data you call response.json() (or .text(), .blob()), which itself returns a Promise. So a full fetch is two awaits: const response = await fetch(url); const data = await response.json().
What is the difference between await and .then()?
await pauses the current async function until the Promise resolves, then returns the resolved value. .then() attaches a callback that runs later. Both work. await is cleaner for sequential operations. .then() is useful for parallel operations with Promise.all().
What does fetch() reject on?
fetch() only rejects on network failure — DNS errors, no internet, CORS blocks. It does NOT reject on HTTP errors like 404 or 500. Those resolve successfully with response.ok === false. You must check response.ok yourself.
How do I handle errors with async/await?
Wrap the await in try/catch: try { const data = await fetch(url); } catch (err) { console.error(err); }. The catch handles both network errors and any throw you make manually. For fetch specifically, also check response.ok — fetch doesn't throw on HTTP errors.
What is Promise.all() and when do I use it?
Promise.all([p1, p2, p3]) runs multiple Promises in parallel and waits for all of them to finish. It returns a single Promise that resolves with an array of results. Use it when you need multiple independent async operations and want to wait for all of them. It's faster than awaiting them one at a time.