The Fetch API Is Not What You Think: Streams, Cancellation, and the Features Nobody Uses
Most developers treat fetch() as a glorified XMLHttpRequest. This deep dive explores the powerful features hiding in plain sight - streaming, cancellation, caching, and the patterns that separate hobby projects from production-grade code.
The Misconception - "You Are Using 10% of the Fetch API"
Let me paint you a picture that will feel uncomfortably familiar. You open a codebase - any codebase - and search for fetch. What do you find? The same pattern, copy-pasted across dozens of files:
const response = await fetch('/api/users');
const data = await response.json();
console.log(data);Three lines. That is the entirety of what most developers know about the Fetch API. Maybe they throw in a method: 'POST' and a JSON.stringify(body) on adventurous days. And honestly, I cannot blame them. Every tutorial, every Stack Overflow answer, every "JavaScript for beginners" course teaches exactly this and nothing more.
But here is the thing that has been gnawing at me for years: the Fetch API is not a thin wrapper around HTTP requests. It is a streaming primitive. It was designed from the ground up to work with the Streams API, the Cache API, Service Workers, and AbortController. The simple request-response pattern you have been writing is like buying a Swiss Army knife and only ever using the toothpick.
The Fetch API is not a better XMLHttpRequest. It is an entirely different mental model for how data moves through your application.
Consider the facts. The Response object gives you access to a ReadableStream through response.body. You can cancel in-flight requests with AbortController. You can send streaming request bodies. You can programmatically interact with the HTTP cache. You can control CORS behaviour, redirect handling, referrer policies, and credential modes - all through a clean, composable API surface.
The gap between what developers use and what the API offers is staggering. And it is not because these features are experimental or poorly supported. Every major browser has had full Fetch API support for years. The problem is education, habit, and the gravitational pull of "good enough."
In this article, I am going to walk you through every significant feature of the Fetch API that you are probably not using. Not as a dry API reference - you have MDN for that - but as a practitioner who has shipped these patterns in production and watched them solve real problems. Let us start with the feature that should be in every single application you build.
Cancellation - "AbortController: The Feature Every App Needs Yesterday"
If you are building any kind of search interface, any component that fetches data on mount, or any operation that a user might want to cancel - and you are not using AbortController - you have a bug. Not a hypothetical bug. A real, observable, in-production bug where stale responses overwrite fresh ones, where unmounted components try to update state, and where your users' bandwidth is wasted on requests nobody cares about anymore.
const controller = new AbortController();
fetch('/api/search?q=javascript', {
signal: controller.signal
})
.then(res => res.json())
.then(data => console.log(data))
.catch(err => {
if (err.name === 'AbortError') {
console.log('Request was cancelled');
} else {
throw err;
}
});
// Cancel the request at any point
controller.abort();The API is refreshingly simple. You create an AbortController, pass its signal property into the fetch options, and call abort() whenever you want the request to stop. The fetch promise rejects with an AbortError that you can catch and handle gracefully. But the real power reveals itself in patterns.
Pattern 1: Search Debouncing Without Race Conditions
Debouncing alone does not prevent race conditions. If a user types "java", pauses, then types "javascript", you have two requests in flight. The second one might resolve before the first. Without cancellation, the slower "java" response overwrites the correct "javascript" results.
let currentController = null;
async function search(query) {
// Cancel any in-flight request
if (currentController) {
currentController.abort();
}
currentController = new AbortController();
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: currentController.signal
});
const results = await response.json();
renderResults(results);
} catch (err) {
if (err.name !== 'AbortError') {
showError(err);
}
// AbortError is expected - silently ignore
}
}Pattern 2: Timeout Racing
The Fetch API has no built-in timeout option. But AbortSignal.timeout() - supported in all modern browsers since 2022 - gives you exactly that:
// Abort automatically after 5 seconds
const response = await fetch('/api/slow-endpoint', {
signal: AbortSignal.timeout(5000)
});Pattern 3: React Cleanup on Unmount
useEffect(() => {
const controller = new AbortController();
fetch(`/api/user/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(data => setUser(data))
.catch(err => {
if (err.name !== 'AbortError') setError(err);
});
return () => controller.abort();
}, [userId]);Since 2024, you can also combine multiple signals using AbortSignal.any(). This lets you abort a request if either a timeout fires or the user navigates away - whichever happens first. This composability is what makes AbortController genuinely elegant.
Every single fetch call in your application should accept a signal. Make it a habit, make it a lint rule, make it a code review checkbox. The cost of adding it is trivial. The cost of not having it is stale data and memory leaks.
Streaming Responses - "ReadableStream: Processing Data as It Arrives"
Here is a question I want you to sit with: when you call response.json(), what actually happens? The browser waits for the entire response body to arrive, buffers it all in memory, parses the complete JSON string, and hands you the result. For a 2KB API response, nobody cares. For a 50MB dataset, a large file download, or a server-sent stream of tokens from an LLM - you have just created a terrible user experience.
The response.body property gives you a ReadableStream. This is not an afterthought bolted onto the API. This is the foundation. Methods like .json() and .text() are convenience wrappers that consume the stream in one go. Going directly to the stream gives you chunk-by-chunk access.
const response = await fetch('/api/large-dataset');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let result = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
// value is a Uint8Array chunk
result += decoder.decode(value, { stream: true });
console.log(`Received ${result.length} bytes so far...`);
}Real-World Pattern: Download Progress Tracking
One of the most common complaints about the Fetch API is that it does not support progress events like XMLHttpRequest did. This is technically true - there is no onprogress callback. But ReadableStream gives you something better: precise, programmatic control over every byte received.
async function downloadWithProgress(url, onProgress) {
const response = await fetch(url);
const contentLength = response.headers.get('Content-Length');
const total = contentLength ? parseInt(contentLength, 10) : null;
const reader = response.body.getReader();
const chunks = [];
let received = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.length;
if (total) {
onProgress({ received, total, percentage: (received / total) * 100 });
} else {
onProgress({ received, total: null, percentage: null });
}
}
// Reassemble the chunks into a single Uint8Array
const body = new Uint8Array(received);
let position = 0;
for (const chunk of chunks) {
body.set(chunk, position);
position += chunk.length;
}
return body;
}The Content-Length header is not always present - servers using chunked transfer encoding or compression may omit it. Your progress UI should handle the case where total size is unknown. Show received bytes without a percentage, or display an indeterminate progress bar.
Streaming JSON Lines (NDJSON)
This pattern is increasingly common with AI APIs, real-time feeds, and event streaming. The server sends newline-delimited JSON objects, and you process each one as it arrives:
async function* streamNDJSON(url, signal) {
const response = await fetch(url, { signal });
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
// Keep the last incomplete line in the buffer
buffer = lines.pop() || '';
for (const line of lines) {
if (line.trim()) {
yield JSON.parse(line);
}
}
}
// Process any remaining data
if (buffer.trim()) {
yield JSON.parse(buffer);
}
}This is how you build responsive interfaces for LLM token streaming, live log tailing, and real-time dashboards. The data appears on screen the moment it arrives, not after the entire response has buffered. Your users will feel the difference.
Streaming Uploads - "Streaming Request Bodies: The Feature That Almost Nobody Knows Exists"
If streaming responses blew your mind, streaming request bodies will really get you. Yes, you can send a ReadableStream as the body of a fetch request. This means you can start uploading data before you even have all of it. Think about that for a moment - you can pipe data from one source directly into an HTTP request without buffering the entire payload in memory.
Streaming request bodies are supported in Chromium-based browsers (Chrome, Edge, Opera, Brave) and Safari 17+. Firefox support landed in version 133 (late 2024). If you need to support older Firefox versions, you will need to fall back to buffered uploads. Always feature-detect.
const stream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('First chunk\n'));
controller.enqueue(new TextEncoder().encode('Second chunk\n'));
controller.enqueue(new TextEncoder().encode('Third chunk\n'));
controller.close();
}
});
fetch('/api/upload', {
method: 'POST',
body: stream,
headers: {
'Content-Type': 'text/plain'
},
// Required: streaming uploads need HTTP/2 or duplex: 'half'
duplex: 'half'
});The duplex: 'half' option is crucial and easy to miss. It tells the browser that you want to start sending the request body before the response headers arrive. Without it, the browser will try to buffer the entire stream before sending, which defeats the purpose entirely.
Practical Use Case: Large File Upload with Progress
Here is a pattern that solves one of the oldest frustrations in web development - tracking upload progress without XMLHttpRequest:
async function uploadFileWithProgress(file, onProgress) {
let uploaded = 0;
const total = file.size;
const progressStream = new TransformStream({
transform(chunk, controller) {
uploaded += chunk.byteLength;
onProgress({
uploaded,
total,
percentage: Math.round((uploaded / total) * 100)
});
controller.enqueue(chunk);
}
});
const body = file.stream().pipeThrough(progressStream);
const response = await fetch('/api/upload', {
method: 'POST',
body,
duplex: 'half',
headers: {
'Content-Type': file.type,
'Content-Length': file.size.toString()
}
});
return response.json();
}The combination of File.stream(), TransformStream, and streaming fetch bodies means you can upload gigabyte-sized files with real-time progress tracking and near-zero memory overhead. No XMLHttpRequest required.
Piping Data Between Sources
The real elegance emerges when you combine streams. You can read from one fetch response and pipe directly into another fetch request - creating a proxy-like behaviour entirely in the browser:
const source = await fetch('/api/export/large-dataset');
const uploadResponse = await fetch('/api/import/dataset', {
method: 'POST',
body: source.body, // Pipe the ReadableStream directly
duplex: 'half',
headers: {
'Content-Type': 'application/json'
}
});This pattern is incredibly powerful for data migration tools, ETL pipelines running in the browser, and any scenario where you need to move large amounts of data between APIs without buffering everything in memory. The data flows through your application like water through a pipe - it never pools, it never overflows, it just moves.
The Response Object - "Response Is Not Just .json(): The Full API Surface You Are Ignoring"
Quick quiz. How many methods does the Response object have for reading the body? If you said two - .json() and .text() - you are in the majority, and you are missing half the picture. The Response object provides six distinct ways to consume its body, and each one exists for a reason.
The Six Body Methods
response.json()- Parses the body as JSON. You know this one.response.text()- Returns the body as a plain string. Useful for HTML, XML, CSV, or any text format.response.blob()- Returns a Blob object. Essential for handling binary data like images, PDFs, or zip files that you want to display or trigger as downloads.response.arrayBuffer()- Returns raw binary data as an ArrayBuffer. Use this when you need to manipulate bytes directly - think audio processing, WebAssembly, or cryptographic operations.response.formData()- Parses the body as FormData. Rarely used but invaluable when proxying multipart form submissions or working with APIs that returnmultipart/form-data.response.body- The ReadableStream itself, as we covered in section 03.
The Body Can Only Be Read Once
This is the gotcha that bites everyone eventually. The response body is a stream, and once consumed, it is gone. Try to call .json() twice and you get a TypeError: body stream already read. This is where .clone() becomes essential.
const response = await fetch('/api/data');
// Clone BEFORE reading the body
const cloned = response.clone();
// Now you can read both independently
const jsonData = await response.json();
const rawText = await cloned.text();
console.log('Parsed:', jsonData);
console.log('Raw:', rawText);Clone when you need to read the body in multiple formats, when you want to cache a response while also using it, or when you need to log the raw response body for debugging while still parsing it normally. Always clone before consuming the body.
Creating Responses from Scratch
The Response constructor is not just for Service Workers. You can create synthetic responses for testing, mocking, or building middleware-like patterns:
// Create a JSON response
const mockResponse = new Response(
JSON.stringify({ user: 'Divyansh', role: 'admin' }),
{
status: 200,
headers: { 'Content-Type': 'application/json' }
}
);
// Create a response from a Blob (e.g., for image handling)
const imageBlob = new Blob([imageData], { type: 'image/png' });
const imageResponse = new Response(imageBlob, {
headers: { 'Content-Type': 'image/png' }
});Understanding the full Response surface is what separates developers who use fetch from developers who understand fetch. Every one of these methods unlocks patterns that would otherwise require third-party libraries or ugly workarounds.
Caching - "Fetch Meets the Cache API: Offline-First Without a Framework"
There is a beautifully underappreciated synergy between the Fetch API and the Cache API. They were literally designed to work together - both deal in Request and Response objects, and both live in the same web platform ecosystem. Yet most developers reach for workbox or similar libraries without understanding what the platform gives them for free.
The Cache API lets you programmatically store and retrieve HTTP responses, keyed by their requests. It is not the browser's HTTP cache - it is a separate, fully controllable storage mechanism that persists across sessions and works offline.
// Open (or create) a named cache
const cache = await caches.open('api-v1');
// Store a response
const response = await fetch('/api/config');
await cache.put('/api/config', response.clone());
// Retrieve it later - even offline
const cached = await cache.match('/api/config');
if (cached) {
const data = await cached.json();
console.log('From cache:', data);
}Notice the .clone() call. Because the response body can only be read once, you must clone it before caching if you also want to use the original response.
Strategy: Stale-While-Revalidate
This is the caching strategy that gives you the best of both worlds - instant responses from cache while silently updating in the background:
async function fetchWithSWR(url) {
const cache = await caches.open('swr-cache');
const cachedResponse = await cache.match(url);
// Start the network request regardless
const networkPromise = fetch(url).then(response => {
if (response.ok) {
cache.put(url, response.clone());
}
return response;
});
if (cachedResponse) {
// Return cached version immediately,
// network update happens in the background
networkPromise.catch(() => {}); // Swallow network errors silently
return cachedResponse;
}
// No cache - wait for network
return networkPromise;
}The Cache API shares the browser's storage quota with IndexedDB and other storage mechanisms. Chrome allocates up to 80% of total disk space per origin. Safari is more conservative and may evict caches under storage pressure. Always implement cache size management - delete old caches and set reasonable expiration policies.
Service Worker Integration
The Cache API truly shines inside a Service Worker, where you can intercept every fetch request and decide how to handle it:
// Inside service-worker.js
self.addEventListener('fetch', (event) => {
// Only cache GET requests for our API
if (event.request.method !== 'GET') return;
if (!event.request.url.includes('/api/')) return;
event.respondWith(
caches.match(event.request).then(cached => {
const networkFetch = fetch(event.request).then(response => {
const cache = caches.open('api-runtime');
cache.then(c => c.put(event.request, response.clone()));
return response;
});
return cached || networkFetch;
})
);
});The combination of Fetch, Cache API, and Service Workers gives you a complete offline-first architecture with zero framework dependencies. The platform handles the hard parts - serialisation, storage, matching - and you supply the strategy. Most applications would benefit enormously from even a basic implementation of these patterns, yet they remain criminally underused in the wild.
Configuration Deep Dive - "Credentials, CORS, and the Modes Nobody Configures"
Open any codebase and look at the fetch calls. Ninety-nine percent of them specify method, headers, and body. Maybe credentials: 'include' if someone got burned by cookies not being sent. But the fetch RequestInit object has a rich set of options that control fundamental HTTP behaviours, and ignoring them does not mean they are not affecting your requests.
The mode Option
This controls whether cross-origin requests are allowed and what parts of the response you can access:
// Default: 'cors' - allows cross-origin requests with CORS headers
fetch('https://api.example.com/data', { mode: 'cors' });
// 'same-origin' - reject any cross-origin requests entirely
fetch('/api/data', { mode: 'same-origin' });
// 'no-cors' - allows cross-origin requests but gives you an
// opaque response you cannot read (useful for pinging, beacons)
fetch('https://analytics.example.com/track', { mode: 'no-cors' });
// 'navigate' - only used by HTML navigation (you will rarely set this)Setting mode: 'no-cors' does NOT bypass CORS. It lets the request go through but gives you an opaque response with status 0 and no readable body. It exists for fire-and-forget scenarios like analytics pings and cache warming - not for reading cross-origin data. If you are using it to "fix" CORS errors, you are doing it wrong.
The credentials Option
This determines whether cookies and auth headers are sent with the request:
// 'same-origin' (default) - only send credentials for same-origin requests
fetch('/api/user', { credentials: 'same-origin' });
// 'include' - always send credentials, even cross-origin
fetch('https://api.example.com/user', { credentials: 'include' });
// 'omit' - never send credentials
fetch('/api/public', { credentials: 'omit' });This is the one that trips up every developer at least once. Your API returns a Set-Cookie header, but the cookie never appears in subsequent requests. The fix is almost always credentials: 'include' for cross-origin requests, paired with the correct Access-Control-Allow-Credentials: true response header on the server.
The redirect Option
Controls how HTTP redirects (301, 302, 307, 308) are handled:
// 'follow' (default) - automatically follow redirects
fetch('/old-url', { redirect: 'follow' });
// 'error' - treat redirects as network errors
fetch('/api/resource', { redirect: 'error' });
// 'manual' - don't follow, return a special 'opaqueredirect' response
fetch('/api/resource', { redirect: 'manual' });The manual mode is useful when you need to detect redirects - for example, checking if an authentication endpoint redirects to a login page, or implementing custom redirect logic in a Service Worker.
The referrerPolicy Option
This controls the Referer header sent with the request. For privacy-sensitive applications or APIs where you do not want to leak the full URL of the page making the request, this matters:
fetch('/api/data', {
referrerPolicy: 'no-referrer' // Send no Referer header at all
// Other options:
// 'origin' - Send only the origin (no path)
// 'strict-origin-when-cross-origin' - Default in most browsers
// 'same-origin' - Only send for same-origin requests
});These are not obscure options for edge cases. They control security-critical HTTP behaviours. Understanding and intentionally configuring them is what separates a secure application from one that accidentally leaks credentials or exposes sensitive referrer data.
Error Handling - "Fetch Does Not Throw on 404: The Error Model Everyone Gets Wrong"
This is, without exaggeration, the single most common source of bugs in code that uses the Fetch API. Let me state it plainly because it bears repeating until it sinks in:
fetch() only rejects its promise on network failures. A 404, a 500, a 403 - these all resolve successfully. The promise fulfils. Your .then() runs. Your data is garbage.
I have lost count of the number of production bugs I have seen caused by this misunderstanding. Here is the pattern that looks right but is broken:
// This code has a bug. Can you spot it?
try {
const response = await fetch('/api/user/123');
const user = await response.json();
displayUser(user); // This runs even on a 404
} catch (error) {
showErrorMessage(error); // This only catches network failures
}If the server returns a 404 with a JSON body like {"error": "User not found"}, the code happily parses it and passes it to displayUser(). Worse, if the server returns a 500 with an HTML error page, response.json() throws a SyntaxError because HTML is not valid JSON. Now your error message says "Unexpected token < in JSON" instead of something useful.
The Correct Pattern
async function fetchJSON(url, options = {}) {
const response = await fetch(url, options);
if (!response.ok) {
// response.ok is true for status 200-299
const errorBody = await response.text();
throw new FetchError(
`${response.status} ${response.statusText}: ${url}`,
response.status,
errorBody
);
}
return response.json();
}
class FetchError extends Error {
constructor(message, status, body) {
super(message);
this.name = 'FetchError';
this.status = status;
this.body = body;
}
}The response.ok property is a simple boolean that returns true when the HTTP status is in the range 200-299. It was added specifically to address this common mistake. Always check it. Build it into your wrapper functions. Make it impossible to forget.
Distinguishing Error Types
In production, you need to handle different failure modes differently. Network errors deserve retry logic. 401s need to trigger re-authentication. 429s should back off. 5xx errors might warrant a different user message than 4xx errors.
async function resilientFetch(url, options = {}) {
let response;
try {
response = await fetch(url, options);
} catch (error) {
if (error.name === 'AbortError') {
throw error; // Let cancellations propagate
}
if (error.name === 'TypeError') {
// Network failure, DNS error, server unreachable
throw new NetworkError(`Network request failed: ${url}`, error);
}
throw error;
}
if (response.status === 401) {
await refreshAuthToken();
return resilientFetch(url, options); // Retry once
}
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After');
const delay = retryAfter ? parseInt(retryAfter, 10) * 1000 : 5000;
await new Promise(r => setTimeout(r, delay));
return resilientFetch(url, options); // Respect rate limit
}
if (!response.ok) {
const body = await response.text();
throw new FetchError(
`HTTP ${response.status}: ${url}`,
response.status,
body
);
}
return response;
}The Fetch API's decision not to throw on HTTP errors was intentional - it gives you full control over error semantics. But "full control" is a double-edged sword. Without the right wrapper, it means silent failures. Build the guard rails once, use them everywhere.
Fetch vs Axios - "Fetch vs Axios in 2026: An Honest Reckoning"
Every year, someone publishes a "you do not need Axios anymore" article. And every year, the comment section erupts into a holy war. Let me try to give you an honest, experience-based take instead of tribal loyalty to either side.
Where Fetch Has Caught Up
In 2026, many of the historical arguments for Axios have evaporated. The Fetch API now has:
- AbortController for request cancellation - no longer a pain point
- AbortSignal.timeout() for built-in timeout support
- Native availability everywhere - browsers, Node.js 18+, Deno, Bun, Cloudflare Workers
- Streaming support that Axios fundamentally cannot match (Axios buffers everything)
- Service Worker integration - Axios requests cannot be intercepted by service workers in the same clean way
Where Axios Still Wins
Let us be honest about what Axios does better, even today:
// Global request/response transformation is clean in Axios
axios.interceptors.request.use(config => {
config.headers.Authorization = `Bearer ${getToken()}`;
return config;
});
axios.interceptors.response.use(
response => response.data, // Auto-unwrap data
error => {
if (error.response?.status === 401) {
return refreshAndRetry(error.config);
}
return Promise.reject(error);
}
);Interceptors remain Axios's killer feature. The ability to globally transform requests and responses, handle auth token refresh, add logging, and modify behaviour without touching individual call sites is genuinely powerful. Fetch has no equivalent - you have to build it yourself.
Automatic JSON handling is another convenience. Axios automatically serialises request bodies and parses response bodies. With fetch, you manually JSON.stringify the body, set the Content-Type header, check response.ok, and call response.json(). It is not hard, but it is repetitive.
Upload progress via onUploadProgress works reliably across all environments in Axios. The fetch streaming upload approach is more powerful but has more limited browser support.
The Verdict
Use native fetch when: you are building a library, working in edge/serverless environments, need streaming, or want minimal bundle size. Use Axios when: you have a large application with complex interceptor needs, need upload progress across all browsers, or your team is already productive with it. Never use either without a wrapper layer - raw fetch and raw Axios calls scattered through a codebase are equally unmaintainable.
The honest truth is that the gap has narrowed dramatically. For new projects starting today, I default to native fetch with a thin wrapper. For existing codebases using Axios productively, migrating away brings almost no tangible benefit. The worst choice is no choice at all - scattered, unwrapped fetch calls with no consistent error handling, no cancellation, and no retry logic.
Production Wrapper - "Building a Production-Grade Fetch Wrapper: Putting It All Together"
Everything we have covered in this article - cancellation, streaming, error handling, timeouts, retries - needs to come together in a single, reusable abstraction. Here is my approach to building a fetch wrapper that I would actually ship in a production application. No unnecessary abstractions, no framework-specific code, just solid defaults with escape hatches.
class ApiClient {
#baseURL;
#defaultHeaders;
#timeout;
#maxRetries;
constructor({
baseURL = '',
headers = {},
timeout = 10000,
maxRetries = 2
} = {}) {
this.#baseURL = baseURL;
this.#defaultHeaders = headers;
this.#timeout = timeout;
this.#maxRetries = maxRetries;
}
async request(endpoint, options = {}) {
const {
method = 'GET',
headers = {},
body,
params,
timeout = this.#timeout,
retries = this.#maxRetries,
signal,
...rest
} = options;
// Build URL with query parameters
const url = new URL(endpoint, this.#baseURL);
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
url.searchParams.set(key, String(value));
}
});
}
// Merge headers
const mergedHeaders = {
'Content-Type': 'application/json',
...this.#defaultHeaders,
...headers
};
// Serialise body if needed
const serialisedBody = body && typeof body === 'object'
&& !(body instanceof FormData)
&& !(body instanceof ReadableStream)
&& !(body instanceof Blob)
? JSON.stringify(body)
: body;
// Remove Content-Type for FormData (browser sets boundary)
if (body instanceof FormData) {
delete mergedHeaders['Content-Type'];
}
// Combine user signal with timeout signal
const timeoutSignal = AbortSignal.timeout(timeout);
const combinedSignal = signal
? AbortSignal.any([signal, timeoutSignal])
: timeoutSignal;
const fetchOptions = {
method,
headers: mergedHeaders,
body: serialisedBody,
signal: combinedSignal,
...rest
};
return this.#executeWithRetry(
url.toString(), fetchOptions, retries
);
}
async #executeWithRetry(url, options, retriesLeft) {
try {
const response = await fetch(url, options);
if (response.status === 429 && retriesLeft > 0) {
const retryAfter = response.headers.get('Retry-After');
const delay = retryAfter
? parseInt(retryAfter, 10) * 1000
: 1000 * (this.#maxRetries - retriesLeft + 1);
await new Promise(r => setTimeout(r, delay));
return this.#executeWithRetry(url, options, retriesLeft - 1);
}
if (response.status >= 500 && retriesLeft > 0) {
const delay = 1000 * Math.pow(2, this.#maxRetries - retriesLeft);
await new Promise(r => setTimeout(r, delay));
return this.#executeWithRetry(url, options, retriesLeft - 1);
}
if (!response.ok) {
const errorBody = await response.text().catch(() => '');
throw new ApiError(
`${response.status} ${response.statusText}`,
response.status,
errorBody,
url
);
}
return response;
} catch (error) {
if (error instanceof ApiError) throw error;
if (error.name === 'AbortError') throw error;
if (error.name === 'TimeoutError') throw error;
// Network error - retry if possible
if (retriesLeft > 0) {
const delay = 1000 * Math.pow(2, this.#maxRetries - retriesLeft);
await new Promise(r => setTimeout(r, delay));
return this.#executeWithRetry(url, options, retriesLeft - 1);
}
throw new ApiError(
`Network error: ${error.message}`,
0,
'',
url
);
}
}
// Convenience methods
get(endpoint, options) {
return this.request(endpoint, { ...options, method: 'GET' });
}
async getJSON(endpoint, options) {
const res = await this.get(endpoint, options);
return res.json();
}
post(endpoint, body, options) {
return this.request(endpoint, { ...options, method: 'POST', body });
}
put(endpoint, body, options) {
return this.request(endpoint, { ...options, method: 'PUT', body });
}
delete(endpoint, options) {
return this.request(endpoint, { ...options, method: 'DELETE' });
}
}
class ApiError extends Error {
constructor(message, status, body, url) {
super(message);
this.name = 'ApiError';
this.status = status;
this.body = body;
this.url = url;
}
get isClientError() { return this.status >= 400 && this.status < 500; }
get isServerError() { return this.status >= 500; }
get isNetworkError() { return this.status === 0; }
}Using the Wrapper
const api = new ApiClient({
baseURL: 'https://api.example.com',
headers: { Authorization: `Bearer ${token}` },
timeout: 8000,
maxRetries: 3
});
// Simple GET
const users = await api.getJSON('/users', {
params: { page: 1, limit: 20 }
});
// POST with cancellation
const controller = new AbortController();
const result = await api.post('/users',
{ name: 'Divyansh', role: 'engineer' },
{ signal: controller.signal }
);
// Stream a large response
const response = await api.get('/export/dataset');
const reader = response.body.getReader();
// ... process chunksA good fetch wrapper is not about adding features. It is about making the right thing the default and the wrong thing impossible. Every fetch call through this wrapper automatically gets timeout handling, retry logic, proper error throwing, and JSON serialisation.
The wrapper above is roughly 80 lines of code - far less than the ~14KB you would add by pulling in Axios. It handles timeouts, retries with exponential backoff, rate limit respect, proper error classification, automatic JSON serialisation, query parameter building, and signal composition. It does not handle everything - there is no interceptor system, no request deduplication, no built-in caching layer. Those are features you can add when you actually need them, not before. Start with this foundation, extend it when the need arises, and you will have a fetch layer that is genuinely production-ready without a single external dependency.
Related essays
- Frontend Reliability · Web Performance
Every Browser Retry You Add Is A Small DDoS Attack. Stop Calling It Reliability.
Retries keep a single request alive, but at production scale they can turn an outage into a traffic multiplier. The browser needs a retry policy, not a setTimeout loop.
- JavaScript · Browser Internals
setTimeout(fn, 0) Is Not Zero. In A Background Tab, Your 100ms Timer Runs Once A Minute.
The delay you pass to setTimeout is a minimum. Measured in Chromium, Firefox, WebKit and Node: the 4ms nesting clamp, hidden tabs throttled to 1 second, Chrome's once-a-minute intensive throttling after 5 minutes, a 30-day timer that fires instantly, and what to use instead.
- JavaScript · Browser Internals
JavaScript Reads "2026-10-06" As UTC And "2026-10-06T00:00" As Local Time. Most Off-By-One Date Bugs Start There.
new Date("2026-10-06") is UTC midnight, new Date("2026-10-06T00:00") is local midnight. Tested in Chromium, Firefox and WebKit across three time zones: where the day slips, why Indian teams rarely see it, "06-10-2026" becoming 10 June, DST arithmetic, and what Temporal fixes.