I still remember the days of XMLHttpRequest — nested callbacks, verbose setup, and a syntax that never quite felt natural. When the Fetch API arrived, it felt like a breath of fresh air: Promise-based, clean, and built into the platform. In this article, I want to share everything I’ve learned about using Fetch effectively, from the basics to the details that trip people up in production.
What Fetch Actually Is
fetch() is a global function available in browsers and in Node.js (natively since Node 18) that initiates an HTTP request and returns a Promise resolving to a Response object. It replaces most use cases for XMLHttpRequest with a cleaner, Promise-based interface.
Basic GET Request
fetch('https://api.example.com/users')
.then((response) => response.json())
.then((data) => console.log(data))
.catch((error) => console.error('Fetch error:', error));
With async/await, which I prefer for readability:
async function getUsers() {
try {
const response = await fetch('https://api.example.com/users');
const data = await response.json();
console.log(data);
} catch (error) {
console.error('Fetch error:', error);
}
}
The Gotcha Everyone Hits: Fetch Doesn’t Reject on HTTP Errors
This is the single most common mistake I see with Fetch. A fetch() Promise only rejects on network failure (DNS issues, no connectivity, CORS blocks) — not on HTTP error statuses like 404 or 500. You have to check response.ok yourself:
async function getUser(id) {
const response = await fetch(`https://api.example.com/users/${id}`);
if (!response.ok) {
throw new Error(`HTTP error: ${response.status} ${response.statusText}`);
}
return response.json();
}
Without this check, a 404 response silently “succeeds” as far as your .then() chain is concerned, and you’ll try to parse an error page as JSON, leading to confusing downstream errors.
Making POST Requests
async function createUser(userData) {
const response = await fetch('https://api.example.com/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(userData),
});
if (!response.ok) {
throw new Error(`Failed to create user: ${response.status}`);
}
return response.json();
}
createUser({ name: 'Alex', email: 'alex@example.com' })
.then((user) => console.log('Created:', user));
PUT, PATCH, and DELETE
// PUT - full replacement
await fetch(`/api/users/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fullUserData),
});
// PATCH - partial update
await fetch(`/api/users/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'new@example.com' }),
});
// DELETE
await fetch(`/api/users/${id}`, { method: 'DELETE' });
Handling Different Response Types
Response objects support multiple parsing methods depending on the content:
const jsonData = await response.json(); // parse JSON
const textData = await response.text(); // plain text
const blobData = await response.blob(); // binary data (images, files)
const bufferData = await response.arrayBuffer(); // raw bytes
const formData = await response.formData(); // multipart form data
Note that each of these methods can only be called once per response — the body is a stream that gets consumed. If you need to read it twice, clone the response first:
const response = await fetch('/api/data');
const clone = response.clone();
const asJson = await response.json();
const asText = await clone.text();
Aborting Requests with AbortController
I use AbortController constantly, especially for search-as-you-type features where a new request should cancel the previous, now-irrelevant one:
let controller;
async function search(query) {
if (controller) controller.abort(); // cancel the previous request
controller = new AbortController();
try {
const response = await fetch(`/api/search?q=${query}`, {
signal: controller.signal,
});
return response.json();
} catch (error) {
if (error.name === 'AbortError') {
console.log('Request was cancelled');
} else {
throw error;
}
}
}
Without this, rapid typing can trigger a race condition where an older, slower response arrives after a newer one and overwrites it with stale data.
Setting Timeouts
Fetch has no built-in timeout option, so I combine it with AbortController:
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;
} finally {
clearTimeout(timeoutId);
}
}
Handling Headers
async function fetchWithAuth(url) {
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${getToken()}`,
'Accept': 'application/json',
},
});
// Reading response headers
console.log(response.headers.get('Content-Type'));
console.log(response.headers.get('X-RateLimit-Remaining'));
return response.json();
}
Credentials and Cookies
By default, fetch() does not send cookies for cross-origin requests. If your API relies on cookie-based sessions, you need to opt in explicitly:
fetch('https://api.example.com/me', {
credentials: 'include', // sends cookies even cross-origin
});
For same-origin requests, credentials: 'same-origin' (the default in most browsers today) is usually sufficient.
Understanding CORS Errors
One of the most common frustrations I’ve helped debug is a fetch request failing with a vague “Failed to fetch” or CORS-related console error. This happens when the server doesn’t include the appropriate Access-Control-Allow-Origin header for cross-origin requests. It’s important to understand this is a server-side configuration issue — no amount of client-side JavaScript can bypass CORS restrictions, by design, since they exist to protect users, not developers.
Uploading Files
async function uploadFile(file) {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
// Do NOT set Content-Type manually here —
// the browser sets the correct multipart boundary automatically
});
return response.json();
}
A mistake I made early on: manually setting Content-Type: multipart/form-data when using FormData. This breaks the upload because the browser needs to append its own boundary string to that header — always let it set this automatically.
Retrying Failed Requests
For flaky networks, I implement retry logic with exponential backoff:
async function fetchWithRetry(url, options = {}, retries = 3, delay = 500) {
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const response = await fetch(url, options);
if (response.ok) return response;
if (response.status < 500) return response; // don't retry client errors
} catch (error) {
if (attempt === retries) throw error;
}
await new Promise((resolve) => setTimeout(resolve, delay * Math.pow(2, attempt)));
}
}
Fetch in Node.js
Since Node.js 18, fetch is available globally without any extra dependency, using the same API surface as the browser:
async function getRepoInfo() {
const response = await fetch('https://api.github.com/repos/nodejs/node');
const data = await response.json();
console.log(data.stargazers_count);
}
This has largely replaced libraries like node-fetch and axios for simple use cases in newer Node.js projects, though axios remains popular for its built-in interceptors and automatic JSON handling.
Security Best Practices
- Never expose API secrets or tokens in client-side fetch calls that are visible in browser DevTools; route sensitive calls through your own backend.
- Validate and sanitize any data received before rendering it into the DOM, to avoid XSS via API responses.
- Always use HTTPS endpoints in production.
Common Mistakes
| Mistake | Consequence | Fix |
|---|---|---|
| Assuming fetch rejects on 404/500 | Errors silently ignored | Check response.ok explicitly |
| Reading response body twice | TypeError: body stream already read | Clone the response first |
| Manually setting Content-Type with FormData | Broken multipart uploads | Let the browser set it automatically |
| No timeout on requests | Requests hang indefinitely on slow networks | Combine with AbortController for timeouts |
| Not aborting stale requests | Race conditions with out-of-order responses | Use AbortController to cancel outdated requests |
FAQs
Is Fetch better than Axios? Fetch is built into the platform and requires no dependency, but Axios offers conveniences like automatic JSON parsing, request/response interceptors, and built-in timeout support. I choose based on project needs — Fetch for simplicity, Axios for larger apps needing those extras.
Does Fetch support progress events for uploads/downloads? Not natively for uploads (unlike XMLHttpRequest). For download progress, you can use the ReadableStream from response.body to track bytes received manually.
Why does my fetch request fail only in the browser but work in Postman? This is almost always a CORS issue — Postman doesn’t enforce CORS the way browsers do, since CORS is a browser security mechanism, not a server-side restriction that all clients obey.
Summary and Key Takeaways
Fetch modernized how I make HTTP requests in JavaScript, but it has sharp edges worth knowing:
- Fetch only rejects on network failure — always check
response.okfor HTTP errors. - Use
AbortControllerfor both timeouts and canceling stale requests. - Let the browser manage
Content-TypeforFormDatauploads. - Fetch is now available natively in both browsers and Node.js, reducing the need for extra HTTP client libraries in many cases.
References
- MDN — Fetch API
- MDN — Using the Fetch API
- Node.js Documentation — nodejs.org/en/docs
- WHATWG Fetch Standard — fetch.spec.whatwg.org
