Engineering Diaries · JavaScript
§
JavaScriptWeb APIsFetch API

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.

Section 01

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:

THE PATTERN EVERYONE WRITES
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.

2015
Year Fetch API shipped in browsers
~90%
Developers who only use basic GET/POST
12+
Configurable options in the Request init object

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.

Section 02

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.

BASIC ABORT CONTROLLER USAGE
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.

SEARCH WITH CANCELLATION
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:

FETCH WITH TIMEOUT
// Abort automatically after 5 seconds
const response = await fetch('/api/slow-endpoint', {
  signal: AbortSignal.timeout(5000)
});

Pattern 3: React Cleanup on Unmount

REACT USEEFFECT WITH CANCELLATION
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]);
KEY INSIGHT

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.

Section 03

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.

READING A RESPONSE AS A STREAM
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.

DOWNLOAD PROGRESS WITH FETCH
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;
}
Why Content-Length Matters

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:

PROCESSING NDJSON STREAMS
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.

Section 04

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.

BROWSER SUPPORT NOTE

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.

SENDING A READABLESTREAM AS REQUEST BODY
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:

FILE UPLOAD WITH PROGRESS TRACKING
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:

PIPE FROM ONE FETCH TO ANOTHER
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.

Section 05

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.

6
Body reading methods on Response
1
Chance to read the body (without .clone())
5
Response type values

The Six Body Methods

  1. response.json() - Parses the body as JSON. You know this one.
  2. response.text() - Returns the body as a plain string. Useful for HTML, XML, CSV, or any text format.
  3. 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.
  4. 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.
  5. response.formData() - Parses the body as FormData. Rarely used but invaluable when proxying multipart form submissions or working with APIs that return multipart/form-data.
  6. 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.

USING RESPONSE.CLONE()
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);
WHEN TO CLONE

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:

CREATING SYNTHETIC RESPONSES
// 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.

Section 06

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.

BASIC CACHE API USAGE
// 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:

STALE-WHILE-REVALIDATE PATTERN
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;
}
Cache Storage Limits

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:

SERVICE WORKER FETCH HANDLER
// 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.

Section 07

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:

FETCH MODE OPTIONS
// 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)
THE NO-CORS TRAP

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:

CREDENTIALS OPTIONS
// '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:

REDIRECT OPTIONS
// '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:

REFERRER POLICY OPTIONS
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.

Section 08

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:

THE BROKEN PATTERN
// 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

PROPER FETCH ERROR HANDLING
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;
  }
}
Why response.ok Exists

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.

GRANULAR ERROR HANDLING
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.

Section 09

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.

~50M
Weekly npm downloads for Axios
0 KB
Bundle cost of native fetch
~14 KB
Minified size of Axios (gzipped)

Where Fetch Has Caught Up

In 2026, many of the historical arguments for Axios have evaporated. The Fetch API now has:

  1. AbortController for request cancellation - no longer a pain point
  2. AbortSignal.timeout() for built-in timeout support
  3. Native availability everywhere - browsers, Node.js 18+, Deno, Bun, Cloudflare Workers
  4. Streaming support that Axios fundamentally cannot match (Axios buffers everything)
  5. 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:

AXIOS INTERCEPTORS - STILL UNMATCHED
// 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

My Rule of Thumb for 2026

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.

Section 10

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.

PRODUCTION FETCH WRAPPER - FULL IMPLEMENTATION
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

USAGE EXAMPLES
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 chunks

A 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.

· · ·
Written by Divyansh Singh. Published April 2026. All code examples have been tested and verified. Views expressed are the author's own.