Why Fetch Over XMLHttpRequest or Axios?
The Fetch API is built into every modern browser and Node.js 18+. No npm install, no bundle size increase, no dependency to maintain.
Fetch advantages:- Built-in — no external dependency
- Promise-based — works naturally with async/await
- Streaming support — process large responses in chunks
- AbortController — cancel requests cleanly
- Available in browsers, Node.js, Deno, Bun, and service workers
- You need automatic request/response interceptors
- You need built-in request timeout (Fetch requires AbortController)
- You want automatic JSON parsing (Fetch requires manual
.json()call) - You need progress events for file uploads
Basic GET Request
// Simple GET
const response = await fetch('https://api.example.com/users');
const data = await response.json();
console.log(data);
Important: Fetch does NOT throw on HTTP errors (404, 500). You must check response.ok:
async function fetchJSON(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(HTTP ${response.status}: ${response.statusText});
}
return response.json();
}
try {
const users = await fetchJSON('/api/users');
console.log(users);
} catch (error) {
console.error('Failed to fetch users:', error.message);
}
This is the most common Fetch mistake — forgetting that fetch() only rejects on network errors, not HTTP errors.
POST with JSON Body
const response = await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': Bearer ${token},
},
body: JSON.stringify({
name: 'Alice',
email: 'alice@example.com',
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Request failed');
}
const newUser = await response.json();
console.log('Created:', newUser);
Key points:
- Always set
Content-Type: application/jsonfor JSON bodies - Always
JSON.stringify()the body — Fetch doesn't do this automatically - Parse error responses too — APIs usually return error details in JSON
File Upload with FormData
async function uploadFile(file) {
const formData = new FormData();
formData.append('file', file);
formData.append('quality', 'high');
const response = await fetch('/api/v1/convert/pdf-to-word', {
method: 'POST',
body: formData,
// Do NOT set Content-Type — browser sets it with boundary
});
if (!response.ok) {
throw new Error('Upload failed');
}
// Download the converted file
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'converted.docx';
a.click();
URL.revokeObjectURL(url);
}
// Usage with file input
const input = document.querySelector('input[type="file"]');
input.addEventListener('change', () => {
uploadFile(input.files[0]);
});
Critical: Do NOT set the Content-Type header when using FormData. The browser needs to set it automatically with the correct multipart boundary string.Cancelling Requests with AbortController
AbortController lets you cancel in-flight requests — essential for search-as-you-type and component unmounting:
let controller = null;
async function search(query) {
// Cancel previous request
if (controller) {
controller.abort();
}
controller = new AbortController();
try {
const response = await fetch(/api/search?q=${query}, {
signal: controller.signal,
});
const results = await response.json();
renderResults(results);
} catch (error) {
if (error.name === 'AbortError') {
// Request was cancelled — this is expected, don't show error
return;
}
console.error('Search failed:', error);
}
}
// React useEffect cleanup pattern
useEffect(() => {
const controller = new AbortController();
fetch('/api/data', { signal: controller.signal })
.then(res => res.json())
.then(setData)
.catch(err => {
if (err.name !== 'AbortError') setError(err);
});
return () => controller.abort(); // Cleanup on unmount
}, []);
Request Timeout
Fetch has no built-in timeout. Use AbortController with setTimeout:
async function fetchWithTimeout(url, options = {}, timeoutMs = 10000) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
});
return response;
} catch (error) {
if (error.name === 'AbortError') {
throw new Error(Request timed out after ${timeoutMs}ms);
}
throw error;
} finally {
clearTimeout(timeout);
}
}
// Usage
try {
const response = await fetchWithTimeout('/api/slow-endpoint', {}, 5000);
const data = await response.json();
} catch (error) {
console.error(error.message); // "Request timed out after 5000ms"
}
This is a production-ready pattern. Wrap it in a utility function and use it everywhere instead of raw fetch().