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
- What REST APIs are and how they work
- How to use the Fetch API to make HTTP requests
- How to send and receive JSON data
- Error handling strategies for network requests
- How to cancel requests with AbortController
- Building a reusable API client
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)
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();
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. Predict what surfaces —Promise.allrejects with the first rejection it sees, sofetchJson("/api/users/999")throwsHTTP 404and the whole call rejects with that exact message rather than returning a partial array. (Instructive rejection: you are predicting the caught error text,HTTP 404.) - allSettled instead of all. Replace the
Promise.allinsidefetchAllUserswithPromise.allSettledover the same mapped calls, then log each result'sstatusand itsvalue.nameorreason.messagefor[1, 999, 3]. Predict the three statuses and the middle entry's reason —allSettlednever rejects, so you get["fulfilled", "rejected", "fulfilled"]with Alice,HTTP 404, Charlie. Which one would a dashboard loading many independent widgets prefer, and why? (Output-changing: you are predicting the status array and the reason string.)
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