Free lesson · JavaScript Basic to Advance
fetch and the Modern HTTP API
Making HTTP Requests From JavaScript
fetch is the modern API for making HTTP requests in JavaScript. It replaced the older XMLHttpRequest (XHR) and is the standard for both browser and Node.js code today.
// GET request — simple case
const response = await fetch("/api/users");
const users = await response.json();
console.log(users);
// POST request with a body
const result = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Olena", role: "admin" })
});
const created = await result.json();
fetch returns a Promise that resolves to a Response object. The Response has methods like .json() and .text() to read the response body, which themselves return Promises (because the body might still be streaming in).
This Topic covers fetch in detail: making requests, handling responses, error handling, common patterns, and the gotchas that surprise developers.
Interactive animation: Why fetch takes two awaits - the Response arrives first, then .json() returns a second Promise for the body.
The Basic Pattern
async function fetchData(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error ${response.status}`);
}
const data = await response.json();
return data;
}
fetchData("/api/users")
.then(users => console.log(users))
.catch(err => console.error("Failed:", err.message));
The pattern: call fetch, check response.ok, parse the body. This handles 90% of real-world cases.
The response.ok check is important — covered under "fetch and errors" below.
Request Options
The second argument to fetch is an options object specifying everything about the request:
fetch("/api/users", {
method: "POST", // GET, POST, PUT, DELETE, PATCH
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer token-here",
"X-Custom-Header": "value"
},
body: JSON.stringify({ name: "Olena" }), // request body
credentials: "include", // send cookies
cache: "no-cache", // cache strategy
mode: "cors", // CORS mode
redirect: "follow", // how to handle redirects
signal: abortController.signal, // for cancellation
});
Some commonly-used options:
method — defaults to "GET". Use "POST", "PUT", "DELETE", etc. for other HTTP verbs.
headers — HTTP headers to send. "Content-Type" and "Authorization" are the most common custom headers.
body — the request body, for POST/PUT/PATCH. Common types: a JSON string (via JSON.stringify), a FormData object, a Blob, or URLSearchParams.
credentials — whether to send cookies. "same-origin" is the default (only send to same origin). "include" sends across origins. "omit" never sends.
signal — an AbortSignal for cancellation (covered below).
Interactive animation: A request travels out with its method, headers and body, and the response returns carrying its status code.
Reading the Response
The Response object has several methods for parsing the body, each returning a Promise:
const response = await fetch("/api/data");
// Parse as JSON (most common for APIs)
const data = await response.json();
// Or read as plain text
const text = await response.text();
// Or read as a binary Blob (for files, images)
const blob = await response.blob();
// Or as raw ArrayBuffer
const buffer = await response.arrayBuffer();
// Or as a Stream for incremental processing
const reader = response.body.getReader();
You can only call one of these methods per response — the body can only be read once. Calling .json() then .text() on the same response throws an error.
Other useful Response properties:
response.ok // true if status is 200-299
response.status // HTTP status code (200, 404, 500, etc.)
response.statusText // "OK", "Not Found", etc.
response.headers // Headers object
response.url // the final URL (after redirects)
response.redirected // whether the request was redirected
fetch and Errors
A critical detail: fetch does not reject on HTTP error status codes. A 404 or 500 response still resolves the Promise normally. The rejection only happens for network failures — the request couldn't reach the server.
// This does NOT throw if the URL returns 404
const response = await fetch("/api/does-not-exist");
console.log(response.ok); // false
console.log(response.status); // 404
// Need to check yourself
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
This is a frequent source of bugs. Developers expect fetch to throw on error status codes the way some other HTTP libraries do, and forget to check response.ok.
The wrapper pattern handles this cleanly:
async function fetchJSON(url, options = {}) {
const response = await fetch(url, options);
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`HTTP ${response.status}: ${errorBody}`);
}
return response.json();
}
// Now this throws on HTTP errors as well as network errors
const data = await fetchJSON("/api/users");
Most projects develop a wrapper like this to centralize error handling.
Interactive animation: Three classes, three responses - a network failure rejects fetch itself, while a 404 or 500 fails only through response.ok.
Cancellation With AbortController
A real fetch request might take a long time. Sometimes you need to cancel it — when the user navigates away, when a newer request supersedes it, when a timeout elapses.
AbortController is the standard mechanism:
const controller = new AbortController();
// Start the fetch
fetch("/api/slow-endpoint", { signal: controller.signal })
.then(response => response.json())
.then(data => console.log(data))
.catch(err => {
if (err.name === "AbortError") {
console.log("Request was cancelled");
} else {
console.log("Other error:", err);
}
});
// Later — cancel the request
setTimeout(() => controller.abort(), 1000);
The signal option on fetch accepts an AbortSignal from a controller. Calling controller.abort() cancels the request, causing the Promise to reject with an AbortError.
This pattern is essential for production code that handles user interactions. Without cancellation, an autocomplete that fires fetch on every keystroke can produce a flood of competing requests, with results arriving in unpredictable order.
A common pattern for autocomplete:
let currentController = null;
async function search(query) {
// Cancel any in-flight request
if (currentController) {
currentController.abort();
}
currentController = new AbortController();
try {
const response = await fetch(`/api/search?q=${query}`, {
signal: currentController.signal
});
return await response.json();
} catch (err) {
if (err.name !== "AbortError") {
throw err;
}
// AbortError means a newer request superseded this one
return null;
}
}
Each new search cancels the previous one. Only the most recent search's response gets processed.
Common Patterns
A few patterns that show up in real codebases.
Sending JSON:
async function postJSON(url, data) {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data)
});
return response.json();
}
Sending form data:
async function uploadForm(url, formElement) {
const formData = new FormData(formElement);
const response = await fetch(url, {
method: "POST",
body: formData
// Don't set Content-Type — the browser sets it correctly for FormData
});
return response.json();
}
Adding an authentication header:
async function authedFetch(url, options = {}) {
const token = localStorage.getItem("authToken");
return fetch(url, {
...options,
headers: {
...options.headers,
"Authorization": `Bearer ${token}`
}
});
}
Timeout via AbortController:
async function fetchWithTimeout(url, timeoutMs) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: controller.signal });
return response;
} finally {
clearTimeout(timeoutId);
}
}
These patterns get wrapped into custom HTTP clients in real projects — often replacing direct fetch calls everywhere with a project-specific wrapper that handles auth, errors, timeouts, and retries consistently.
CORS: A Quick Note
When making a fetch from page A to a different origin B (different domain, port, or protocol), CORS (Cross-Origin Resource Sharing) governs whether the request is allowed.
CORS is enforced by the browser. The target server has to send specific headers (like Access-Control-Allow-Origin) for the browser to allow the response to be read by your JavaScript.
CORS errors are some of the most-asked questions on Stack Overflow. The typical experience: you can see the request go out and the server respond successfully in the Network panel, but JavaScript can't read the response because the CORS headers aren't right.
The fix is almost always server-side — the server needs to be configured to send CORS headers for the origins it should allow. Client-side workarounds (browser extensions, disabling CORS in development) are debugging aids, not real solutions.
Common Pitfalls
Interactive animation: The catch that never ran - a 404 still resolves the fetch Promise until a response.ok check throws.
Warning: The fetch traps.
Not checking
response.ok.fetchdoesn't throw on HTTP error status codes. Always checkresponse.okbefore parsing the body, or you'll process error responses as if they were successful.Setting
Content-Typefor FormData. When sending a FormData body, don't set Content-Type yourself. The browser sets it tomultipart/form-datawith the correct boundary parameter. Setting it manually breaks the upload.Trying to read the response body twice.
.json()and.text()consume the stream. After calling one, the body is empty. To read it twice, clone the response first:const cloned = response.clone().Not handling CORS during development. A
fetchthat works in development against a same-origin API might fail in production if the API moves to a different domain without proper CORS config.
Why This Matters in Interviews
Interview:
fetchquestions are foundational for frontend interviews. "How do you make an HTTP request in JavaScript?" expectsfetchas the answer (with mention of olderXMLHttpRequestif asked about legacy). "What's the difference between fetch and an HTTP library like axios?" probes whether you understand fetch's quirks — especially the no-throw-on-4xx behavior.Topic 12.7 — Interview Lens: Browser and DOM Questions — covers the deeper treatment.
Try It
Exercise: Build a small "GitHub user lookup" tool:
- Fetch from
https://api.github.com/users/USERNAME(a real public API)- Display the user's name, avatar, and bio
- Handle the case where the user doesn't exist (the API returns 404)
- Add an AbortController to cancel previous requests if the user types fast
Once this works, you've exercised the four main parts of real fetch usage: making requests, parsing responses, error handling, and cancellation.