Concept
The beginner framing: fetch(url) sends an HTTP request and returns a promise that resolves once the response starts arriving, you then read the actual data out of that response, usually as JSON.
The precise mental model: fetch()'s promise resolves as soon as the server sends back headers and a status code, not the full response body. The resolved value is a Response object, a handle to an in-progress or complete HTTP exchange, not the data itself. Reading the body is a second, separate async step (.json(), .text(), .blob(), etc.), because the body can be large and is streamed.
const response = await fetch("/api/users/1");
// response is a Response object here, headers/status known, body NOT yet read
console.log(response.status); // 200
console.log(response.ok); // true (status in the 200-299 range)
const user = await response.json(); // SECOND await, actually reads + parses the bodyThe one rule that catches almost everyone: fetch does NOT reject on HTTP errors
const response = await fetch("/api/users/999"); // server responds 404 Not Found
console.log(response.ok); // false
console.log(response.status); // 404
// fetch() did NOT throw or reject here, a 404 is a completely "successful" fetch
// from the network's point of view: a response WAS received.fetch()'s promise only rejects on genuine network failures, DNS failure, no connectivity, CORS blocking, the request being aborted. An HTTP 404, 500, or any other error status is, as far as fetch is concerned, a perfectly normal completed request. You must check response.ok (or response.status) yourself, this single rule is responsible for more silent bugs than any other part of the Fetch API.
async function getUser(id) {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
}
return response.json();
}Request configuration and headers
const response = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Ada" }),
});Cancellation with AbortController
const controller = new AbortController();
fetch("/api/slow-endpoint", { signal: controller.signal })
.then((res) => res.json())
.catch((err) => {
if (err.name === "AbortError") console.log("request was cancelled");
});
controller.abort(); // cancels the in-flight request; the fetch promise rejects with AbortErrorTry It
This function has the classic fetch bug. Find it before checking the solution.
async function getUser(id) {
const response = await fetch(`/api/users/${id}`);
const user = await response.json();
return user;
}
const user = await getUser(999); // server returns 404 with an error JSON body
console.log(user.name); // TypeError: Cannot read properties of undefinedSolution
getUser never checks response.ok. When the server returns a 404, fetch() still resolves successfully (it's not a network error), the code happily calls .json() on the error response (which might be { error: "Not found" }, with no .name property) and returns it as if it were a valid user. The bug surfaces later, confusingly, at user.name, far from where the actual problem occurred.
async function getUser(id) {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw
Implement It Yourself
Build a fetchWithTimeout helper combining fetch, AbortController, and setTimeout, a genuinely useful utility that isn't built into the platform:
async function fetchWithTimeout(url, options = {}, timeoutMs = 5000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { ...options, signal: controller.signal });
return response;
} catch (err) {
if (err.name === "AbortError") {
throw new Error(`Request to ${url} timed out after ${timeoutMs}ms`);
fetch() alone has no built-in timeout, combining it with AbortController and setTimeout like this is the standard pattern for adding one, and it directly exercises the cancellation mechanism from this topic.
In React
Data-fetching libraries (TanStack Query, SWR) exist largely to handle the parts of fetch() that are tedious to get right by hand every single time: request deduplication, caching, retries, race-condition handling (an older, slower request resolving AFTER a newer one and overwriting its result), and cancellation on unmount. A raw fetch() inside a useEffect is a completely valid starting point, but it's worth recognizing exactly which of those problems you're signing up to solve yourself, particularly the race condition, which needs the same cancelled guard flag pattern shown in Async/Await's "In React" section, or an AbortController tied to the effect's cleanup function.
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then((res) => res.json())
.then(setUser)
.catch((err) => {
if (err.name !== "AbortError") console.error(err);
});
return () => controller.abort(); // cancel the in-flight request if userId changes or unmounts
}, [userId]);Common Mistakes
1. Not checking response.ok
Covered above, the single most important fetch habit. fetch resolves successfully for 404s and 500s alike.
2. Calling .json() on a body that isn't JSON, or reading the body twice
const response = await fetch("/api/status"); // returns plain text "OK", not JSON
const data = await response.json(); // throws: Unexpected token 'O', "OK" is not valid JSONconst response = await fetch("/api/data");
const text = await response.text();
const json = await response.json(); // TypeError: body stream already readA Response body can only be read once, its stream is consumed by the first .json()/.text()/etc. call. If you need the raw data in two forms, read it once and derive the second form yourself (e.g., JSON.parse(text)), or response.clone() before reading if you genuinely need two independent reads.
3. Forgetting that fetch sends no Content-Type header by default for a JSON body
fetch("/api/users", {
method: "POST",
body: JSON.stringify({ name: "Ada" }), // ❌ missing headers, server may not parse this as JSON
});Without explicitly setting headers: { "Content-Type": "application/json" }, many server frameworks won't correctly parse the request body as JSON, even though you JSON.stringify'd it correctly on the client.
Best Practices
- Always check
response.okbefore reading the body as if it were successful data. - Centralize fetch logic in a small wrapper function (like the
getUserfix above) rather than repeating theresponse.okcheck at every call site. - Use
AbortControllerfor any fetch tied to a component's lifecycle, cancel on unmount or when the relevant dependency changes, to avoid race conditions and wasted network usage. - Set
Content-Typeexplicitly whenever sending a body, and don't assume the server will infer the format.
Performance Tips
fetch()supports HTTP caching via the standardCache-Control/ETagmechanisms automatically, you don't need to reinvent caching for GET requests the browser can already cache correctly; use thecacheoption ("no-store","force-cache", etc.) to override when needed.- Reading a large response body with
.json()parses the entire payload into memory at once. For genuinely large responses,response.body(aReadableStream) allows processing data incrementally as it arrives, rather than waiting for the whole thing. - Always cancel fetches for data the user no longer needs (navigated away, closed a modal), an uncancelled fetch keeps consuming bandwidth and, if its result eventually arrives, can cause the exact stale-overwrite race condition described in the React section above.
