Most fetch() tutorials only show the happy path. The bug that actually catches beginners is a different one: fetch() does not throw an error when a server responds with a 404 or 500. Your code just keeps running with bad data.
This article is for you if you know basic JavaScript but haven't used fetch() yet. We'll write a small script that:
- requests data from a public test API,
- checks whether the request succeeded,
- parses the JSON response,
- handles errors and timeouts without crashing.
fetch() works in modern browsers and in Node.js (it's built in since Node 18, so use a currently supported LTS version). There's nothing to install.
The test API
We'll use JSONPlaceholder, a free fake REST API made for testing and prototyping. It doesn't need an API key. The /posts/1 endpoint returns this JSON:
{
"userId": 1,
"id": 1,
"title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
"body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"
}
What fetch() actually does
fetch(url) sends an HTTP request (a GET by default) and returns a Promise. Once the server sends back its response headers, that promise resolves to a Response object.
Two things to know before we go further:
- The
Responseisn't your data yet. It's a wrapper around the status, headers, and body. To get the data, you call a method likeresponse.json(), which also returns a Promise. -
fetch()only rejects its promise when the request itself fails, for example when there's no connection, the URL is invalid, CORS blocks the request, or it gets aborted or times out. A404or500still counts as a completed round trip, so the promise resolves. That's why you have to check the status yourself.
async/await in one minute
async/await lets you write Promise-based code that reads from top to bottom.
- A function marked
asyncalways returns a Promise. - Inside it,
awaitpauses the function until a Promise settles, then hands you the result. - If the awaited Promise rejects,
awaitthrows, so you can catch it withtry/catch.
The complete example
Save this as example.mjs. The .mjs extension lets Node run it as an ES module without any setup:
const BASE_URL = "https://jsonplaceholder.typicode.com";
async function getPost(id) {
const response = await fetch(`${BASE_URL}/posts/${id}`);
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
return response.json();
}
async function main() {
try {
const post = await getPost(1);
console.log("Title:", post.title);
console.log("Post:", post);
} catch (error) {
console.error("Request failed:", error.message);
}
}
main();
Run it:
node example.mjs
Expected output
If the request succeeds, Node prints:
Title: sunt aut facere repellat provident occaecati excepturi optio reprehenderit
Post: {
userId: 1,
id: 1,
title: 'sunt aut facere repellat provident occaecati excepturi optio reprehenderit',
body: 'quia et suscipit\n' +
'suscipit recusandae consequuntur expedita et cum\n' +
'reprehenderit molestiae ut ut quas totam\n' +
'nostrum rerum est autem sunt rem eveniet architecto'
}
Node splits long strings across lines when it prints objects, but the data is the same JSON you saw above.
You can also skip Node entirely: open your browser's DevTools console, paste in the whole script, and press Enter.
Watch an error happen
Now change getPost(1) to getPost(99999) and run it again. That post doesn't exist, so JSONPlaceholder responds with a 404:
Request failed: HTTP error: 404
Without the response.ok check, this request would have looked like a success, and your code would have carried on with an empty object. That's exactly what the check protects you from.
How it works, step by step
1. Send the request.
const response = await fetch(`${BASE_URL}/posts/${id}`);
await waits for the response headers to arrive and gives you the Response object.
2. Check for HTTP errors with response.ok.
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
response.ok is true when the status code is between 200 and 299. For anything else, like a 404 or 500, we throw our own error so the problem gets handled in one place instead of being ignored.
3. Parse the JSON.
return response.json();
response.json() reads the body and turns it into a JavaScript object. It returns a Promise, and since we return it from an async function, callers get the parsed object when they await getPost(1).
4. Catch errors in one place. The try/catch in main() covers everything that can go wrong: network failures, the error we throw for bad status codes, and invalid JSON.
Gotcha: This works because the
try/catchlives inmain(). If you later add atry/catchinsidegetPost, a JSON parse error will slip past it when you writereturn response.json(). Usereturn await response.json()there, so the rejection is thrown inside yourtryblock.
The ways a request can fail
| What went wrong | What happens |
|---|---|
| Server returns 404 or 500 |
fetch() resolves, response.ok is false, and our code throws |
| Server returns a non-JSON body with a 200 status |
response.json() rejects with a SyntaxError
|
| Server can't be reached |
fetch() rejects with a TypeError
|
| Request takes too long (with a timeout set) |
fetch() rejects with a TimeoutError
|
Error message text varies between runtimes and versions, and browsers and Node word things differently. So don't compare against message strings. Use response.status for HTTP errors, and treat everything else as "something went wrong."
If you want to react to a specific status code, attach it to the error:
if (!response.ok) {
const error = new Error(`HTTP error: ${response.status}`);
error.status = response.status;
throw error;
}
Tip for Node: A network failure often shows up as just fetch failed. The useful detail, such as ENOTFOUND or ECONNREFUSED, is in error.cause, so log that too:
console.error("Request failed:", error.message, error.cause);
Tip for browsers: If CORS blocks a request, all you'll see is a generic TypeError: Failed to fetch. Check the DevTools console and network tab for the real reason. The server has to allow your origin, and you can't fix that from the client.
Add a timeout
If a server never answers, a request can hang for a very long time. To give up after a set number of milliseconds, pass AbortSignal.timeout() as the signal:
const response = await fetch(`${BASE_URL}/posts/${id}`, {
signal: AbortSignal.timeout(5000), // give up after 5 seconds
});
When time runs out, fetch() rejects with an error whose name is "TimeoutError". You can handle it separately in your catch:
catch (error) {
if (error.name === "TimeoutError") {
console.error("The request took too long.");
} else {
console.error("Request failed:", error.message);
}
}
Common mistakes
1. Assuming fetch() throws on 404 or 500.
It doesn't. Try getPost(99999): JSONPlaceholder returns a 404, the promise resolves normally, and response.ok is false. Without a response.ok check, your code behaves as if everything worked.
2. Forgetting await (or .json()).
Both fetch() and response.json() return Promises. If you skip await, you get a pending Promise (it prints as Promise { <pending> }) instead of your data.
3. Reading the body twice.
A response body can only be read once, and calling response.json() a second time throws a TypeError. If you need it twice, call response.clone() first, or store the result in a variable.
4. Calling response.json() on a response that isn't JSON.
Some error responses come back as plain text or HTML. Check response.ok first, and use response.text() when you need to see what the server actually sent.
5. Skipping try/catch.
An unhandled rejection from await fetch(...) can crash a Node script or leave a browser UI stuck on a loading state.
Bonus: sending data with POST
The same pattern works for other HTTP methods. Pass an options object as the second argument. Save this as a separate file called post.mjs:
const BASE_URL = "https://jsonplaceholder.typicode.com";
async function createPost(data) {
const response = await fetch(`${BASE_URL}/posts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
return response.json();
}
async function main() {
try {
const created = await createPost({
title: "Hello",
body: "My first post",
userId: 1,
});
console.log(created);
} catch (error) {
console.error("Request failed:", error.message);
}
}
main();
Run it with node post.mjs. JSONPlaceholder accepts write requests but doesn't save anything, so it's safe to experiment. It echoes back what you sent, plus a fake id:
{ title: 'Hello', body: 'My first post', userId: 1, id: 101 }
Conclusion
Here's the pattern for calling an API with fetch():
-
await fetch(url)to get theResponse. - Check
response.okand throw if it'sfalse, becausefetch()won't do it for you. -
await response.json()to get your data. - Wrap everything in
try/catch, and set a timeout withAbortSignal.timeout().
That covers most everyday API calls. From here, you can explore query parameters and custom request headers.
Top comments (0)