Web APIs and Fetch
Modern web applications don't work in isolation. They communicate with servers to fetch data, submit forms, authenticate users, and more. The Fetch API is the modern browser API for making HTTP requests, and it's what most new code uses instead of the older XMLHttpRequest. (It comes from the browser and other runtimes, not from the JavaScript language itself.) In this lesson you will learn how to make requests, handle responses, work with JSON, and build robust API clients.
What You'll Learn
By the end of this lesson you will build a small API client and get it passing its own checks: a function
that composes a REST path, one that fetches a resource and throws on a bad HTTP status — because fetch
hands you a 404 as a perfectly successful response and leaves the judgement to you — and one that runs
several independent lookups concurrently. You arrive able to await a promise and run promises in parallel
(08-async-and-promises) and to throw and catch errors at a boundary (09-error-handling); you leave able
to talk to a server and to tell the difference between a request that failed and a request that succeeded
in delivering bad news.
That distinction is the whole lesson. The single most common fetch bug is code that treats "the response arrived" as "the response was good," and every section here circles back to it.
How this lesson runs, and where a real fetch runs
The in-page runner is a headless sandbox with no network access, so a real fetch cannot complete
here — every fence below stands in a fakeFetch that mimics the real one's shape. That substitution is
honest and it is deliberate: the logic this lesson teaches, especially "check response.ok before you
trust the body", is identical either way, and it is the logic the exercises grade.
But you should see the real thing resolve at least once, and it takes about two minutes. Save this as
index.html, then open it by double-clicking it — fetch is a browser API, so a browser is all you need,
with no server and no build step:
<!DOCTYPE html>
<html>
<body>
<h1>Real fetch</h1>
<pre id="out">loading…</pre>
<script>
const out = document.getElementById("out");
async function show() {
// A public, no-key JSON API. Change the id to 99999999 for the 404 case.
const response = await fetch("https://jsonplaceholder.typicode.com/todos/1");
// The line this whole lesson is about: arriving is not succeeding.
out.textContent =
"response.ok: " + response.ok + "\n" +
"status: " + response.status + "\n";
if (!response.ok) {
out.textContent += "\nRequest arrived, but it is bad news. Not parsing the body.";
return;
}
const data = await response.json();
out.textContent += "\n" + JSON.stringify(data, null, 2);
}
show();
</script>
</body>
</html>
What you should see. The page prints response.ok: true and status: 200, then a four-field JSON
object — userId, id, title and completed — where title is the string "delectus aut autem"
(that endpoint's fixed sample data, checked against the live service while writing this lesson). Then
change the 1 in the URL to 99999999 and reload: you get response.ok: false and status: 404, and
the body is never parsed. Notice what did not happen — no exception was thrown and no catch ran.
The request succeeded; the answer was a 404. That is the difference the rest of this lesson is built
around, and seeing it once in a real browser is worth more than reading it five times.
If the browser console shows a CORS error instead, that is a third outcome worth knowing: the request was blocked before it left the page, by a rule the server controls, not your code. The endpoint above permits cross-origin reads, so it will not happen here — but it will the first time you point this file at an API that does not.
Understanding REST APIs
REST (Representational State Transfer) is a set of conventions for building web APIs. It uses HTTP methods to perform operations on resources:
// REST API conventions
const restMethods = [
{ method: "GET", url: "/api/users", description: "Get all users" },
{ method: "GET", url: "/api/users/1", description: "Get user with id 1" },
{ method: "POST", url: "/api/users", description: "Create a new user" },
{ method: "PUT", url: "/api/users/1", description: "Replace user 1 entirely" },
{ method: "PATCH", url: "/api/users/1", description: "Update parts of user 1" },
{ method: "DELETE", url: "/api/users/1", description: "Delete user 1" }
];
console.log("REST API Methods:\n");
restMethods.forEach(function(r) {
console.log(" " + r.method.padEnd(7) + " " + r.url.padEnd(18) + " " + r.description);
});
// HTTP Status Codes
console.log("\nCommon Status Codes:\n");
const statusCodes = [
{ code: 200, meaning: "OK - Request succeeded" },
{ code: 201, meaning: "Created - Resource was created" },
{ code: 204, meaning: "No Content - Success with no body" },
{ code: 400, meaning: "Bad Request - Invalid input" },
{ code: 401, meaning: "Unauthorized - Not authenticated" },
{ code: 403, meaning: "Forbidden - Not allowed" },
{ code: 404, meaning: "Not Found - Resource doesn't exist" },
{ code: 500, meaning: "Internal Server Error - Server broke" }
];
statusCodes.forEach(function(s) {
console.log(" " + s.code + " - " + s.meaning);
});
The Fetch API Basics
The fetch() function returns a Promise that resolves to a Response object. You then extract the data using methods like .json() or .text(). Since we can't make real network requests in this playground, we'll simulate the behavior:
// Simulating the Fetch API
function fakeFetch(url, options) {
options = options || {};
const method = (options.method || "GET").toUpperCase();
return new Promise(function(resolve, reject) {
setTimeout(function() {
// Simulated server responses
const routes = {
"GET /api/users": {
status: 200,
data: [
{ id: 1, name: "Alice", email: "alice@example.com" },
{ id: 2, name: "Bob", email: "bob@example.com" }
]
},
"GET /api/users/1": {
status: 200,
data: { id: 1, name: "Alice", email: "alice@example.com" }
},
"GET /api/users/999": {
status: 404,
data: { error: "User not found" }
}
};
const key = method + " " + url;
const response = routes[key];
if (!response) {
resolve({ ok: false, status: 404, json: function() { return Promise.resolve({ error: "Not found" }); } });
return;
}
resolve({
ok: response.status >= 200 && response.status < 300,
status: response.status,
json: function() { return Promise.resolve(response.data); },
text: function() { return Promise.resolve(JSON.stringify(response.data)); }
});
}, 300);
});
}
// Using fetch (simulated)
async function demo() {
console.log("Fetching all users...");
const response = await fakeFetch("/api/users");
console.log("Status:", response.status, "OK:", response.ok);
const users = await response.json();
console.log("Users:", users);
console.log("\nFetching user 1...");
const singleResponse = await fakeFetch("/api/users/1");
const user = await singleResponse.json();
console.log("User:", user);
console.log("\nFetching non-existent user...");
const notFound = await fakeFetch("/api/users/999");
console.log("Status:", notFound.status, "OK:", notFound.ok);
const errorData = await notFound.json();
console.log("Error:", errorData);
}
demo();
Predict
A request comes back with an HTTP 500 (the server crashed). Predict before you run it: does the promise reject and jump to the catch block, or does it resolve so the line after await runs?
function fakeFetch(url) {
return new Promise(function (resolve) {
setTimeout(function () {
// The server hit an internal error and sends back a 500
resolve({
ok: false,
status: 500,
json: function () { return Promise.resolve({ error: "Server crashed" }); },
});
}, 100);
});
}
async function loadDashboard() {
try {
const response = await fakeFetch("/api/dashboard");
console.log("A: reached the line after await, status " + response.status);
} catch (err) {
console.log("B: jumped to catch — " + err.message);
}
}
loadDashboard();Sending Data with POST
To send data to a server, you use POST (or PUT/PATCH) and include the data in the request body as JSON:
// Simulating POST requests
const database = {
users: [
{ id: 1, name: "Alice", email: "alice@example.com" }
],
nextId: 2
};
function fakeFetch(url, options) {
options = options || {};
const method = (options.method || "GET").toUpperCase();
return new Promise(function(resolve) {
setTimeout(function() {
if (method === "GET" && url === "/api/users") {
resolve({
ok: true, status: 200,
json: function() { return Promise.resolve([...database.users]); }
});
} else if (method === "POST" && url === "/api/users") {
const body = JSON.parse(options.body);
const newUser = { id: database.nextId++, ...body };
database.users.push(newUser);
resolve({
ok: true, status: 201,
json: function() { return Promise.resolve(newUser); }
});
} else if (method === "DELETE" && url.startsWith("/api/users/")) {
const id = parseInt(url.split("/").pop());
database.users = database.users.filter(function(u) { return u.id !== id; });
resolve({ ok: true, status: 204, json: function() { return Promise.resolve(null); } });
} else {
resolve({ ok: false, status: 404, json: function() { return Promise.resolve({ error: "Not found" }); } });
}
}, 200);
});
}
async function demo() {
// POST - Create a new user
console.log("Creating new user...");
const createResponse = await fakeFetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Charlie", email: "charlie@example.com" })
});
const newUser = await createResponse.json();
console.log("Created:", newUser);
// Create another user
await fakeFetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Diana", email: "diana@example.com" })
});
// GET - List all users
console.log("\nAll users:");
const listResponse = await fakeFetch("/api/users");
const users = await listResponse.json();
users.forEach(function(u) {
console.log(" " + u.id + ". " + u.name + " (" + u.email + ")");
});
// DELETE
console.log("\nDeleting user 1...");
await fakeFetch("/api/users/1", { method: "DELETE" });
const afterDelete = await fakeFetch("/api/users");
const remaining = await afterDelete.json();
console.log("Remaining users:", remaining.map(function(u) { return u.name; }));
}
demo();
Error Handling with Fetch
A critical thing to understand: fetch only rejects on network errors (like no internet). HTTP error status codes (404, 500, etc.) are considered successful responses. You must check response.ok yourself:
// Proper error handling pattern
function createAPI(baseUrl) {
async function request(endpoint, options) {
options = options || {};
// Simulate different scenarios
return new Promise(function(resolve, reject) {
setTimeout(function() {
if (endpoint === "/timeout") {
reject(new Error("Network request failed"));
return;
}
if (endpoint === "/server-error") {
resolve({ ok: false, status: 500, statusText: "Internal Server Error",
json: function() { return Promise.resolve({ error: "Server crashed" }); } });
return;
}
if (endpoint === "/unauthorized") {
resolve({ ok: false, status: 401, statusText: "Unauthorized",
json: function() { return Promise.resolve({ error: "Invalid token" }); } });
return;
}
resolve({ ok: true, status: 200,
json: function() { return Promise.resolve({ data: "Success from " + endpoint }); } });
}, 200);
});
}
return {
async get(endpoint) {
try {
const response = await request(endpoint);
// Check if the HTTP status indicates an error
if (!response.ok) {
const errorBody = await response.json();
throw new Error("HTTP " + response.status + ": " + (errorBody.error || response.statusText));
}
return await response.json();
} catch (error) {
// Handle both network errors and HTTP errors
if (error.message.startsWith("HTTP")) {
console.log("Server error: " + error.message);
} else {
console.log("Network error: " + error.message);
}
return null;
}
}
};
}
async function demo() {
const api = createAPI("https://api.example.com");
console.log("Test 1: Successful request");
const data = await api.get("/users");
console.log("Result:", data);
console.log("\nTest 2: Server error (500)");
await api.get("/server-error");
console.log("\nTest 3: Unauthorized (401)");
await api.get("/unauthorized");
console.log("\nTest 4: Network failure");
await api.get("/timeout");
}
demo();
Debug
getUserName is supposed to print 'User 1: ALICE' then 'User 999: Unknown user (HTTP 404)'. Instead it prints the first line and then crashes. Predict what it prints and where it dies, then fix it so both lines print and it exits cleanly.
function fakeFetch(url) {
return new Promise(function (resolve) {
setTimeout(function () {
if (url === "/api/users/1") {
resolve({
ok: true,
status: 200,
json: function () { return Promise.resolve({ id: 1, name: "Alice" }); },
});
} else {
resolve({
ok: false,
status: 404,
json: function () { return Promise.resolve({ error: "Not found" }); },
});
}
}, 50);
});
}
async function getUserName(id) {
const response = await fakeFetch("/api/users/" + id);
const user = await response.json();
return user.name.toUpperCase();
}
async function main() {
console.log("User 1:", await getUserName(1));
console.log("User 999:", await getUserName(999));
}
main();Expected output:
User 1: ALICE User 999: Unknown user (HTTP 404)
Recall
Without scrolling up: the API client you are about to build needs two things you were taught before this lesson. It has to fetch several independent users at once rather than one after another, and it has to survive a request that fails without taking the whole program down. Which pair of tools does that — and what does each one specifically buy you here?
AbortController: Canceling Requests
Sometimes you need to cancel a request, for example when a user navigates away or types a new search query before the previous one finishes:
// Simulating AbortController behavior
function createAbortController() {
let aborted = false;
const listeners = [];
return {
signal: {
get aborted() { return aborted; },
addEventListener(event, fn) { listeners.push(fn); }
},
abort() {
aborted = true;
listeners.forEach(function(fn) { fn(); });
}
};
}
function fetchWithAbort(url, signal) {
return new Promise(function(resolve, reject) {
// Check if already aborted
if (signal && signal.aborted) {
reject(new Error("AbortError: The operation was aborted"));
return;
}
const timer = setTimeout(function() {
resolve({ ok: true, data: "Data from " + url });
}, 2000);
// Listen for abort
if (signal) {
signal.addEventListener("abort", function() {
clearTimeout(timer);
reject(new Error("AbortError: The operation was aborted"));
});
}
});
}
async function demo() {
// Scenario 1: Request completes normally
console.log("Request 1: Normal request");
try {
const result = await fetchWithAbort("/api/data", null);
console.log("Success:", result.data);
} catch (e) {
console.log("Error:", e.message);
}
// Scenario 2: Request is cancelled
console.log("\nRequest 2: Cancelled after 500ms");
const controller = createAbortController();
setTimeout(function() {
console.log("Aborting request...");
controller.abort();
}, 500);
try {
const result = await fetchWithAbort("/api/slow-data", controller.signal);
console.log("Success:", result.data);
} catch (e) {
console.log("Caught:", e.message);
}
console.log("\n--- Real browser syntax ---");
console.log("const controller = new AbortController();");
console.log("fetch(url, { signal: controller.signal })");
console.log("controller.abort(); // cancel the request");
}
demo();
Arrange the code
Reassemble the body of loadUserName, the four-line shape almost every fetch call has: request, check the status yourself, read the body, use it. The backend answers 200 with a user for id 1 and 404 with a non-JSON body for anything else. Only one arrangement produces the expected output; every other one crashes — but two of them crash only on the second call, after printing a perfectly correct first line. Why must these run in this order, and what specifically breaks if the status check moves?
const response = await fakeFetch("/api/users/" + id);const user = await response.json();return user.name.toUpperCase();if (!response.ok) return "missing user, status " + response.status;
Transfer
AbortController lets you cancel an in-flight request when it is no longer wanted. New setting: a search box fires a fetch on every keystroke. The user types 'react' — five keystrokes, five requests — but only the results for the final query 'react' should win, and slow earlier responses must not overwrite them. Which approach carries the SAME cancel-the-stale-work idea across to this problem?
// A search-as-you-type handler. What should happen on each keystroke?
function onSearchInput(query) {
// ... start a request for `query` ...
}Try It Yourself
Reading about fetch is not the same as building an API client. This is a build task: a small client that reports its own pass/fail. You are given a fakeFetch backend — the same self-contained stand-in the sections above use, since the playground has no network — and three empty functions to finish. Run it as-is and it fails immediately, telling you which function is still missing. Implement each one until every check passes and it prints All checks passed.
The three functions reuse exactly what this lesson taught: composing a REST path like the ones in Understanding REST APIs, throwing on a bad HTTP status because fetch resolves rather than rejects on 4xx/5xx (the discipline from Error Handling with Fetch), and running independent lookups concurrently with Promise.all (from Async and Promises). The starter already has the backend, the stubs, and the checks — you write only the logic inside each function.
Build
Finish the build. Three functions are stubbed out and the checks below them fail until each one returns the right value. Run it as-is to see which check fails first, decide what that function is missing, then implement the three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.
const assert = require('assert');
// The simulated backend. Do NOT change this.
const db = { 1: { id: 1, name: "Alice" }, 2: { id: 2, name: "Bob" }, 3: { id: 3, name: "Charlie" } };
function fakeFetch(url) {
return new Promise(function (resolve) {
setTimeout(function () {
const id = Number(url.split("/").pop());
if (db[id]) {
resolve({ ok: true, status: 200, json: function () { return Promise.resolve(db[id]); } });
} else {
resolve({ ok: false, status: 404, json: function () { return Promise.resolve({ error: "Not found" }); } });
}
}, 10);
});
}
// TODO 1: join the base and id into a REST path (a plain string, no await).
// buildUrl("/api/users", 1) -> "/api/users/1"
function buildUrl(base, id) {
// your code here
}
// TODO 2: await fakeFetch(url). fetch RESOLVES on a 404, so check response.ok
// yourself: if it is false, throw new Error("HTTP " + status); otherwise return
// the parsed JSON body.
// fetchJson("/api/users/1") -> { id: 1, name: "Alice" }
// fetchJson("/api/users/999") -> throws Error("HTTP 404")
async function fetchJson(url) {
// your code here
}
// TODO 3: fetch every id CONCURRENTLY (start them all, then await together) and
// return the user objects in order.
// fetchAllUsers([1, 2, 3]) -> [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }, { id: 3, name: "Charlie" }]
async function fetchAllUsers(ids) {
// your code here
}
// --- Build checks: these must all pass. Do not edit below this line. ---
async function main() {
assert.strictEqual(
buildUrl("/api/users", 1),
"/api/users/1",
"buildUrl(base, id) should join the base and id into a REST path",
);
assert.deepStrictEqual(
await fetchJson("/api/users/1"),
{ id: 1, name: "Alice" },
"fetchJson should return the parsed body when response.ok is true",
);
await assert.rejects(
function () { return fetchJson("/api/users/999"); },
/HTTP 404/,
"fetchJson should throw an HTTP 404 error when response.ok is false",
);
assert.deepStrictEqual(
await fetchAllUsers([1, 2, 3]),
[{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }, { id: 3, name: "Charlie" }],
"fetchAllUsers should resolve every lookup concurrently and return the users in order",
);
console.log("All checks passed.");
console.log("URL:", buildUrl("/api/users", 1));
console.log("User 1:", await fetchJson("/api/users/1"));
console.log("All users:", await fetchAllUsers([1, 2, 3]));
}
main().catch(function (err) {
console.error(err.message);
process.exitCode = 1;
});Expected output:
All checks passed.
URL: /api/users/1
User 1: { id: 1, name: 'Alice' }
All users: [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
{ id: 3, name: 'Charlie' }
]Once it passes, try two variations and predict each before running (predict the exact value or error text, not the timing — order is the only timing claim that holds here):
- One missing id in the batch. Call
fetchAllUsers([1, 999, 3])inside atry/catchand log either the resolved array or the caught message. Write down which of the two you get, and its exact text or contents, before running. (Instructive rejection: you are predicting the caught error text.) - allSettled instead of all. Replace the
Promise.allinsidefetchAllUserswithPromise.allSettledover the same mapped calls, then log each result'sstatusand itsvalue.nameorreason.messagefor[1, 999, 3]. Write down the three statuses and the middle entry's reason before running. (Output-changing: you are predicting the status array and the reason string.)
After you have written your prediction down, check it against what actually happens.
What you should have seen
- The
catchruns.Promise.allrejects with the first rejection it sees, sofetchJson("/api/users/999")throwsHTTP 404and the whole call rejects with that exact message — you do not get a partial array holding the two users that did resolve. Their results are discarded. allSettlednever rejects, so you get["fulfilled", "rejected", "fulfilled"]with Alice,HTTP 404and Charlie. A dashboard loading many independent widgets wants this one: one dead widget should not blank the other two, which is exactly whatPromise.allwould do here.
Key Takeaways
- REST APIs use HTTP methods (GET, POST, PUT, DELETE) to perform CRUD operations on resources
fetch()returns a Promise that resolves to a Response object; use.json()to parse the bodyfetchdoes not reject on HTTP errors (404, 500); always checkresponse.ok- Send JSON data by setting
Content-Type: application/jsonand usingJSON.stringifyon the body - Use
AbortControllerto cancel requests that are no longer needed - Build API clients that compose request URLs, guard on
response.ok, and run independent requests concurrently withPromise.all
Next Steps
You now know how to communicate with servers. The next lesson covers performance optimization, where you will learn techniques like debouncing, throttling, and memoization to make your JavaScript applications fast and responsive.
Pro Tip: Always validate and sanitize server responses before using them. Never assume the data structure matches what you expect. Defensive coding prevents mysterious bugs when the API changes or returns unexpected data.
Next lesson
Performance Optimization
Optimize your JavaScript with debouncing, throttling, memoization, and memory management
22 min