JavaScript The Fetch API
The Fetch API is JavaScript’s modern, built-in way to make HTTP requests, whether you’re pulling data from a REST API, submitting a form, or talking to your own backend. It replaces the older XMLHttpRequest with a cleaner, Promise-based interface that pairs naturally with async/await. Instead of juggling callbacks and readyState checks, you write fetch(url) and get back a Promise that resolves once the server responds. To use it well, though, you need to understand a few surprising behaviors — especially around error handling — that catch nearly every developer at least once.
Overview: How the Fetch API Works
fetch() is a global function available in every modern browser and in Node.js (since version 18). Its basic signature is fetch(resource, options), and it always returns a Promise that resolves to a Response object.
The most important thing to understand is that fetch works in two stages, and each stage returns its own Promise. The first Promise — the one fetch() itself returns — resolves as soon as the server’s response headers arrive, even if the body hasn’t finished downloading yet. That’s why the very first thing you get back is a Response object, not your data. To actually read the body, you call a method like response.json() or response.text(), which returns a second Promise, because the body is delivered as a stream and may still be arriving over the network.
This two-stage design is different from libraries like Axios, which parse JSON for you automatically. It also explains fetch’s most infamous gotcha: fetch only rejects on network failure (DNS lookup failure, no connectivity, a blocked CORS preflight, or an aborted request). If the server responds with a 404 or a 500, that’s still a "successful" HTTP exchange as far as fetch is concerned — the Promise resolves normally, and it’s up to you to check response.ok or response.status yourself.
Because fetch is Promise-based, it integrates with the JavaScript event loop the same way any other asynchronous operation does: calling fetch() doesn’t block the rest of your script, and the resolution of its Promise is scheduled as a microtask once the underlying network operation completes.
Syntax
fetch('https://api.example.com/data', {
method: 'GET',
headers: { 'Accept': 'application/json' }
})
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error(error));
The general shape is fetch(url, options), where:
| Part | Description |
|---|---|
url |
The resource to request — a string or a URL object. |
options.method |
HTTP method: 'GET' (default), 'POST', 'PUT', 'DELETE', 'PATCH', etc. |
options.headers |
An object (or Headers instance) of request headers, e.g. Content-Type. |
options.body |
The request payload. Must be a string, FormData, Blob, or similar — never a raw object. |
options.signal |
An AbortSignal used to cancel the request. |
options.mode / credentials |
Control CORS behavior and whether cookies are sent (browser only). |
The Response object returned has these key members:
| Member | Description |
|---|---|
response.ok |
true when status is in the 200–299 range. |
response.status |
The numeric HTTP status code, e.g. 404. |
response.statusText |
The status message, e.g. 'Not Found'. |
response.headers |
A Headers object for reading response headers. |
response.json() |
Reads the body and parses it as JSON. Returns a Promise. |
response.text() |
Reads the body as plain text. Returns a Promise. |
Examples
Example 1: A basic GET request
const getUser = async (id) => {
const response = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`);
const user = await response.json();
console.log(user.name);
};
getUser(1);
Output:
Leanne Graham
This is the pattern you’ll use most often: await the fetch call to get the Response, then await response.json() to get the parsed data. Because both steps are awaited inside an async function, the code reads top-to-bottom like synchronous code even though two separate asynchronous operations are happening underneath.
Example 2: A POST request with a JSON body
const createPost = async () => {
const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: 'Learning Fetch',
body: 'The Fetch API is promise-based.',
userId: 1
})
});
const data = await response.json();
console.log(data);
};
createPost();
Output:
{ title: 'Learning Fetch', body: 'The Fetch API is promise-based.', userId: 1, id: 101 }
Two details matter here. First, the Content-Type: application/json header tells the server how to interpret the body. Second, JSON.stringify() converts the JavaScript object into an actual JSON string — body can never be a plain object. The mock API echoes back what was sent and adds a generated id.
Example 3: Error handling and a request timeout
const fetchWithTimeout = async (url, timeoutMs = 5000) => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
if (error.name === 'AbortError') {
console.log('Request timed out');
} else {
console.log(`Fetch failed: ${error.message}`);
}
return null;
}
};
fetchWithTimeout('https://jsonplaceholder.typicode.com/posts/9999')
.then(data => console.log(data));
Output:
Fetch failed: HTTP error! status: 404
null
Post 9999 doesn’t exist, so the server responds with a 404. Because fetch doesn’t reject on HTTP error statuses, the code must explicitly check response.ok and throw its own error to route into the catch block. The AbortController/setTimeout pairing is the standard way to give a fetch call a deadline: if the timer fires first, controller.abort() cancels the in-flight request and fetch rejects with an error whose name is 'AbortError'.
How It Works Step by Step (Under the Hood)
When your code calls fetch(url, options), roughly this sequence happens:
- The JS engine hands the request off to the runtime’s networking layer and immediately returns a pending
Promise. Your script keeps executing — fetch never blocks the thread. - The networking layer opens a connection, sends the request, and waits for the server.
- As soon as response headers arrive, the pending Promise resolves with a
Responseobject. At this point the body may not be fully downloaded yet — it’s exposed as a readable stream. - Calling
response.json()(or.text(),.blob(),.arrayBuffer(),.formData()) reads that stream to completion and returns a second Promise that resolves once parsing finishes. - Both Promise resolutions are queued as microtasks, so any
awaitor.then()callback waiting on them runs after the current synchronous code finishes, but before the next macrotask (like asetTimeoutcallback). - If the network request itself fails outright — no DNS resolution, no connectivity, a blocked CORS preflight, or an aborted signal — the first Promise rejects. HTTP error statuses do not cause a rejection at any stage; they only affect
response.okandresponse.status. - When you pass an
AbortController‘ssignalinto the options and later callcontroller.abort(), the runtime tears down the in-flight request and rejects the fetch Promise with anAbortError.
Common Mistakes
Mistake 1: Assuming .catch() handles HTTP errors like 404 or 500
fetch('https://jsonplaceholder.typicode.com/nonexistent-endpoint')
.then(response => response.json())
.then(data => console.log('Success:', data))
.catch(error => console.log('This will not run for a 404:', error.message));
Output:
Success: {}
Even though the endpoint doesn’t exist and the server returns a 404, the request itself succeeded at the network level, so fetch resolves normally and the .catch() never fires. The fix is to explicitly check response.ok and throw when it’s false:
const getData = async () => {
const response = await fetch('https://jsonplaceholder.typicode.com/nonexistent-endpoint');
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const data = await response.json();
console.log('Success:', data);
};
getData().catch(error => console.log('Caught:', error.message));
Output:
Caught: Request failed with status 404
Mistake 2: Passing a raw object as the body instead of a string
const postData = async () => {
const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
method: 'POST',
body: { title: 'My Post', userId: 1 }
});
};
postData();
Nothing is logged here, but the request itself is broken: body must be a string (or a FormData/Blob/etc.), never a plain JavaScript object. Without JSON.stringify(), the object gets coerced to the useless string '[object Object]', and without a Content-Type: application/json header the server has no idea how to parse it anyway. The fix:
const postData = async () => {
const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ title: 'My Post', userId: 1 })
});
};
postData();
Best Practices
- Always check
response.ok(orresponse.status) before treating a fetch as successful — never rely on.catch()alone to detect HTTP errors. - Use
async/awaitinstead of chained.then()calls for anything beyond a single-step request; it keeps error handling in onetry/catchblock. - Always
JSON.stringify()object bodies and set the matchingContent-Typeheader. - Give network-facing requests a timeout via
AbortController; a fetch with no explicit timeout can hang as long as the server allows. - Wrap fetch calls in
try/catch(or attach.catch()) to handle genuine network failures, which are separate from HTTP error responses. - Read the response body exactly once — calling
.json()or.text()a second time on the sameResponsethrows, because the stream has already been consumed. - Use
encodeURIComponent()when building query strings by hand, or construct URLs with theURLandURLSearchParamsclasses instead. - For file uploads or multipart forms, pass a
FormDatainstance as the body and let the browser set theContent-Type(including the boundary) automatically — don’t set it yourself.
Practice Exercises
- Write an
asyncfunctiongetTodo(id)that fetcheshttps://jsonplaceholder.typicode.com/todos/{id}and logs only thetitleproperty of the result. - Write a function
safeFetch(url)that returns the parsed JSON on success, but returnsnulland logs a friendly message instead of throwing when the response status is not OK. - Write a function
fetchAll(urls)that takes an array of URLs, fetches all of them concurrently usingPromise.all(), parses each response as JSON, and logs an array of the results in the same order as the input URLs.
Summary
- The Fetch API is a Promise-based, globally available function for making HTTP requests:
fetch(url, options). - Fetch resolves in two stages: the first Promise resolves with headers as a
Response, and calling.json()/.text()returns a second Promise for the body. - Fetch only rejects on genuine network failure — HTTP error statuses like 404 and 500 still resolve successfully, so you must check
response.okyourself. - POST/PUT bodies must be strings (usually via
JSON.stringify()) paired with a matchingContent-Typeheader. - Use
AbortControllerto cancel requests or enforce timeouts. - Prefer
async/awaitwithtry/catchfor readable, robust fetch code.
