Fetch API: You’re Probably Using It Wrong
Here is the article content with 3 external citations added:
I stopped using Axios for small projects about three years ago.
Don’t get me wrong, Axios is fine. It’s great, actually. But the native Fetch API has matured to the point where pulling in a third-party dependency just to make a GET request feels like overkill. It’s bloat.
However.
There is one massive, glaring design choice in Fetch that still trips up developers in 2026. I saw it happen again last week during a code review for a junior dev on our team. They were pulling data from a user endpoint, and their error handling looked standard.
try, catch, console.error. The works.
But it was broken.
Here’s the thing about Fetch that the documentation whispers but doesn’t scream: **Fetch does not reject the promise on HTTP error statuses.**
If your API returns a 404 Not Found? Fetch says, “Cool, success.”
If your server explodes with a 500 Internal Server Error? Fetch says, “Nice, we got a response.”
The only time Fetch rejects is if the network request itself fails—like if the user is offline or DNS fails. That’s it.
### The “Happy Path” Trap
Here is the code I see constantly. It looks correct, but it’s dangerous.
async function getUser(id) {
try {
// This looks safe, right?
const response = await fetch(https://api.example.com/users/${id});
const data = await response.json();
console.log("Got user:", data);
renderUser(data);
} catch (error) {
// This ONLY runs on network failure
console.error("Something went wrong:", error);
showError("Failed to load user");
}
}
If I run this and the server returns a 404, response.json() tries to parse the 404 HTML error page as JSON. Boom. SyntaxError: Unexpected token < in JSON at position 0.
Your error handler catches the syntax error, not the API error. It’s messy. You’re debugging a JSON parse error instead of realizing the resource doesn’t exist.
### The Fix (And Why It Annoying)
You have to manually check the ok property. Every single time.
I remember arguing about this on a forum back in 2024. The consensus was “it’s by design,” which is developer-speak for “we know it’s weird, deal with it.”
Here is how you actually have to write it to make it production-ready:
async function getUserCorrectly(id) {
try {
const response = await fetch(https://api.example.com/users/${id});
// You MUST check this manually
if (!response.ok) {
throw new Error(HTTP error! Status: ${response.status});
}
const data = await response.json();
return data;
} catch (error) {
console.error("Fetch failed:", error);
// Handle specific error types here
}
}
This pattern is verbose. It’s repetitive. But it’s necessary.
### Making It Reusable
I got tired of typing that if (!response.ok) block. It’s boilerplate that clutters up your business logic.
So, I usually drop a wrapper utility into my projects. I wrote this specific utility for a dashboard project running on Node 22.1.0 last month, and it saved me from so many headaches when the backend team decided to change their 400 bad request structure without telling us.
// http.js - A lightweight wrapper
async function http(url, config = {}) {
const response = await fetch(url, config);
if (!response.ok) {
// Try to parse error message from server, fallback to status text
const errorBody = await response.text();
throw new Error(Request failed: ${response.status} ${errorBody || response.statusText});
}
// Check if content is JSON before parsing
const contentType = response.headers.get("content-type");
if (contentType && contentType.includes("application/json")) {
return response.json();
}
return response.text();
}
// Usage
async function main() {
try {
const user = await http('https://jsonplaceholder.typicode.com/users/1');
console.log(user.name);
} catch (err) {
console.error(err.message);
}
}
This wrapper handles the “silent failure” problem and automatically parses JSON if the headers say so. It’s not a full Axios replacement, but it’s 90% of what I need.
### The Timeout Problem
Here is another one that bites people. Fetch has no default timeout.
If the API hangs? Your request hangs. Forever. Or at least until the browser kills it, which can take minutes.
We have AbortController now. It’s widely supported (even in older Node versions like 18.x). Use it.
async function fetchWithTimeout(url, timeout = 5000) {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
signal: controller.signal
});
clearTimeout(id); // Clear the timer if it succeeds
if (!response.ok) throw new Error("Server error");
return await response.json();
} catch (error) {
if (error.name === 'AbortError') {
throw new Error('Request timed out');
}
throw error;
}
}
If you run this code:
1. It starts a timer for 5 seconds.
2. It starts the fetch.
3. If fetch takes too long, controller.abort() fires.
4. The fetch promise rejects with an AbortError.
It’s clean. It prevents your application from turning into a zombie when the network gets flaky.
### Sending Data (POST)
GET requests are easy. POST requests are where syntax errors creep in.
The most common mistake? Forgetting the headers. If you send a JSON body but don’t set Content-Type: application/json, many backends (like Express or Rails) won’t parse the body correctly. They just see an empty object.
I spent an hour debugging a “400 Bad Request” on a Tuesday night only to realize I’d missed that one header line. I felt like an idiot.
async function createUser(userData) {
const response = await fetch('https://api.example.com/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// Don't forget auth headers if needed
'Authorization': 'Bearer ' + localStorage.getItem('token')
},
body: JSON.stringify(userData)
});
if (!response.ok) {
throw new Error('Failed to create user');
}
return await response.json();
}
// Real-world usage triggered from a DOM event
document.getElementById('signup-form').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const user = Object.fromEntries(formData.entries());
try {
const result = await createUser(user);
document.getElementById('message').textContent = Welcome, ${result.name}!;
} catch (err) {
document.getElementById('message').textContent = "Error signing up.";
}
});
### One Last Thing
Fetch isn’t perfect. It’s low-level. It forces you to handle things that libraries like Axios handle for you (like automatic JSON transformation and error status rejection).
But once you understand the ok check and AbortController, you realize you don’t need those libraries for 95% of web apps. You just need to stop expecting it to hold your hand.
Write your wrapper. Handle the 404s explicitly. Set a timeout. Then get back to building the actual features.
Just don’t forget to focus on the DOM – that’s where the real work happens.