Fetch API and HTTP Requests
Talking to Servers
Almost every web app needs to communicate with a server. Load user data. Submit a form. Fetch search results. Save a file. The fetch API is the modern, Promise-based way to make HTTP requests from JavaScript. It replaced XMLHttpRequest (the old AJAX approach) and is now the standard way to do network requests in the browser.
It's clean, it's powerful, and it has one very common footgun that catches almost every developer the first time they use it. We'll cover that too.
Think of fetch as ordering food at a restaurant. You place an order (the request) and get a ticket (the Promise). The kitchen prepares your food (server processes the request) and eventually brings it out (the response). But here's the thing — when the waiter brings your food, it's covered with a lid. You still need to take the lid off to see what's inside (parse the response body with .json(), .text(), etc.). And if the kitchen is overloaded and sends out a "sorry, we're out of that" response (a 404), the waiter still brings you that message — they don't just disappear. fetch only rejects the Promise on network failures, not HTTP errors.
Basic Fetch
// GET request (the default)
const response = await fetch('https://api.example.com/users');
const users = await response.json();
console.log(users);
fetch returns a Promise that resolves to a Response object. The Response contains headers, status codes, and the body — but the body needs to be parsed separately because it might be JSON, text, a blob, or a stream.
Parsing the Response Body
The Response body can only be read once. Choose the right method:
const response = await fetch('/api/data');
// Parse as JSON — most common for APIs
const json = await response.json();
// Parse as plain text
const text = await response.text();
// Parse as binary blob (images, files)
const blob = await response.blob();
// Parse as ArrayBuffer (raw binary)
const buffer = await response.arrayBuffer();
// Parse as FormData (multipart form responses)
const formData = await response.formData();
The response body is a ReadableStream that can only be consumed once. If you call response.json() and then try response.text(), the second call throws because the stream is already read. If you need the body in multiple formats, call response.clone() before reading.
The Biggest Fetch Gotcha: HTTP Errors Don't Reject
This is the thing that trips up almost every developer learning fetch. A 404 or 500 response does NOT cause the Promise to reject. Fetch only rejects on actual network failures (DNS failure, server unreachable, CORS blocked).
// This does NOT throw for 404 or 500
const response = await fetch('/api/nonexistent');
console.log(response.status); // 404
console.log(response.ok); // false
// You MUST check response.ok yourself
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
A Proper Fetch Wrapper
async function fetchJSON(url, options) {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json();
}
// Usage
try {
const users = await fetchJSON('/api/users');
} catch (error) {
console.error('Request failed:', error.message);
}
Making Different Types of Requests
POST with JSON Body
const response = await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'Ada Lovelace',
role: 'engineer',
}),
});
POST with FormData
const formData = new FormData();
formData.append('name', 'Ada');
formData.append('avatar', fileInput.files[0]);
const response = await fetch('/api/profile', {
method: 'POST',
body: formData,
// Don't set Content-Type — the browser sets it automatically
// with the correct multipart boundary
});
When sending FormData, do not manually set the Content-Type header. The browser automatically sets it to multipart/form-data with the correct boundary string. If you set it yourself, the boundary is missing and the server can't parse the data.
PUT, PATCH, DELETE
// PUT — replace a resource
await fetch('/api/users/42', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Ada', role: 'architect' }),
});
// PATCH — partially update a resource
await fetch('/api/users/42', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: 'architect' }),
});
// DELETE — remove a resource
await fetch('/api/users/42', {
method: 'DELETE',
});
Headers
// Reading response headers
const response = await fetch('/api/data');
response.headers.get('Content-Type'); // "application/json"
response.headers.get('X-Request-Id'); // custom header
response.headers.has('Authorization'); // false
// Setting request headers
const response = await fetch('/api/data', {
headers: {
'Authorization': 'Bearer token123',
'Accept': 'application/json',
'X-Custom-Header': 'value',
},
});
// Using the Headers constructor
const headers = new Headers();
headers.append('Content-Type', 'application/json');
headers.append('Authorization', 'Bearer token');
AbortController — Canceling Requests
AbortController lets you cancel in-flight fetch requests. This is essential for search-as-you-type, route changes, and component unmounts.
const controller = new AbortController();
// Pass the signal to fetch
const response = await fetch('/api/search?q=javascript', {
signal: controller.signal,
});
// Cancel the request from anywhere
controller.abort();
When a request is aborted, the fetch Promise rejects with an AbortError:
try {
const response = await fetch('/api/data', {
signal: controller.signal,
});
const data = await response.json();
} catch (error) {
if (error.name === 'AbortError') {
console.log('Request was canceled');
} else {
console.error('Request failed:', error);
}
}
Search-As-You-Type Pattern
let currentController = null;
async function search(query) {
// Abort the previous request if it's still in flight
if (currentController) {
currentController.abort();
}
currentController = new AbortController();
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: currentController.signal,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
if (error.name === 'AbortError') return null; // expected, ignore
throw error;
}
}
input.addEventListener('input', async (e) => {
const results = await search(e.target.value);
if (results) renderResults(results);
});
Timeout 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);
}
}
Modern browsers support AbortSignal.timeout(ms) which creates a signal that auto-aborts after a timeout, without needing to manually manage an AbortController:
const response = await fetch('/api/data', {
signal: AbortSignal.timeout(5000),
});This is cleaner than manually creating an AbortController with setTimeout.
Request and Response Objects
Fetch uses formal Request and Response objects under the hood. You can create them explicitly:
// Create a reusable request
const request = new Request('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Ada' }),
});
// Use it with fetch
const response = await fetch(request);
// Response properties
response.status; // 200
response.statusText; // "OK"
response.ok; // true (status 200-299)
response.url; // the final URL (after redirects)
response.redirected; // true if the response came from a redirect
response.type; // "basic", "cors", "opaque", etc.
- 1fetch only rejects on network failures — check response.ok for HTTP errors
- 2Response body can only be read once — clone first if you need it twice
- 3Use AbortController to cancel in-flight requests (search, route changes, unmounts)
- 4Don't set Content-Type manually when sending FormData — the browser sets the boundary
- 5Always use encodeURIComponent for user input in URLs
| What developers do | What they should do |
|---|---|
| Assuming fetch throws on 404/500 status codes fetch only rejects for network failures. A 404 response resolves successfully — you must check response.ok or response.status and handle errors yourself | Checking response.ok and throwing manually for HTTP errors |
| Setting Content-Type: multipart/form-data when sending FormData The browser generates a unique boundary string for multipart form data. Setting the header yourself loses that boundary, and the server can't parse the request body | Omitting Content-Type and letting the browser set it automatically |
| Not aborting previous requests in search-as-you-type Without aborting, old requests can resolve after newer ones, showing stale results. Each new search should cancel the previous in-flight request | Using AbortController to cancel stale requests before making new ones |