Skip to main content
Dublin Library

The Publishing Project

The Fetch Ecosystem

 

When the Fetch API first arrived, it provided a modern, Promise-based alternative to the clunky XMLHttpRequest. Today, in 2026, Fetch has evolved far beyond a single method. It is now a comprehensive ecosystem of APIs designed to handle massive background downloads, optimize resource loading, and ensure data consistency across unreliable networks.

This post explores the complete suite of Fetch-related APIs available in modern browsers, providing TypeScript implementations to help you build resilient web applications.

The Core Fetch API #

The Core Fetch API remains the foundation for network requests in the browser and Node.js. It revolves around the fetch() method and its associated objects: Request, Response, and Headers. Modern implementations also integrate seamlessly with the Streams API, allowing you to process data chunks as they arrive rather than waiting for the entire payload.

Fetching Data #

By default, fetch() makes a GET request to retrieve data from a specified URL.

interface UserData {
  id: string;
  name: string;
  email: string;
}

async function fetchUserData(userId: string): Promise<UserData> {
  try {
    const response = await fetch(`/api/users/${userId}`);

    if (!response.ok) {
      throw new Error(`HTTP error! Status: ${response.status}`);
    }

    const data = (await response.json()) as UserData;
    return data;
  } catch (error) {
    console.error('Failed to fetch user data:', error);
    throw error;
  }
}

Async/Await and Error Handling #

This guide relies on async/await syntax instead of traditional promise chains (.then().catch()) to handle Fetch requests. We use this approach for several architectural reasons:

  • Improved Readability: async/await allows asynchronous code to look and behave like synchronous code. You read the control flow straight down rather than tracing through nested callbacks.
  • Unified Error Handling: A single try...catch block handles both synchronous errors (like a JSON parsing failure or a manually thrown Error) and asynchronous errors (like a network failure).
  • Simplified Variable Scoping: All resolved values live in the same local function scope. In a promise chain, passing a variable from the first .then() to the third requires awkward scope workarounds.
  • Clearer Debugging: Modern browser developer tools provide cleaner stack traces for async/await functions, pointing directly to the specific line inside the try block rather than internal browser microtask queues.

When managing the success and failure of async/await operations, you use the try, catch, and finally blocks:

  • try: Contains the network request and data parsing code that might fail.
  • catch: Contains the error-handling logic. It only executes if a step in the try block fails or throws an error.
  • finally: Contains code that executes regardless of whether the try block succeeded or the catch block triggered.

When to use finally: Use the finally clause for vital cleanup operations. Common use cases include hiding loading spinners, re-enabling submission buttons, closing database connections, or releasing resource locks (as demonstrated in the Streaming Downloads section with reader.releaseLock()).

The Two Error Paths #

The most common misunderstanding when working with fetch() is that a failed HTTP request does not mean a rejected Promise. fetch() separates errors into two distinct paths, and conflating them leads to bugs where your application silently swallows server errors.

Path 1 — HTTP errors (!response.ok): the Promise resolves

When a server responds with a 4xx or 5xx status code, fetch() considers its job done. It successfully completed a network round-trip and got a response back, which is all it promises to do. The returned Promise fulfills with a Response object whose ok property is false and status holds the numeric code, such as 404 or 500.

This means your catch block is never reached. If you don't explicitly check response.ok, your code will proceed as if the request succeeded and likely fail later when trying to parse an error payload as valid data.

Path 2 — Network-level failures: the Promise rejects

The Promise only rejects when the browser cannot complete the HTTP transaction at all, before a response is received. Common causes include:

  • No network connection (device is offline)
  • DNS resolution failure
  • CORS policy violation
  • Request cancelled via AbortController (throws AbortError)
  • Invalid URL
  • SSL/TLS certificate error

These rejections land in your catch block as a TypeError (for network failures and CORS errors) or a DOMException with the name AbortError (for cancellations).

HTTP status codes are not all the same

Treating every non-ok status identically hides important information. Each status class signals a different problem with a different correct response:

Status Meaning Correct client action
401 Unauthorized Missing or invalid credentials Redirect to login or refresh the access token
403 Forbidden Authenticated but not allowed Show a permission-denied message; do not retry
404 Not Found The resource does not exist Show a "not found" UI; do not retry
409 Conflict State mismatch (e.g. duplicate resource) Inform the user and let them resolve the conflict
422 Unprocessable Entity Validation failed Read the error body and surface field-level messages
429 Too Many Requests Rate limited Back off and retry after the Retry-After header value
500 Internal Server Error Bug on the server Log the details, show a generic message; optionally retry
503 Service Unavailable Server temporarily down Retry with exponential backoff

Putting it together (production pattern)

class HttpError extends Error {
  constructor(
    public readonly status: number,
    public readonly statusText: string,
    public readonly body: string
  ) {
    super(`HTTP ${status} (${statusText})`);
    this.name = 'HttpError';
  }
}

async function fetchWithProperErrorHandling(url: string): Promise<unknown> {
  try {
    const response = await fetch(url);

    // Path 1: Promise resolved, but the server reported an error.
    // Without this check, any 4xx or 5xx silently continues as a success.
    if (!response.ok) {
      const body = await response.text();
      throw new HttpError(response.status, response.statusText, body);
    }

    return await response.json();
  } catch (error) {
    // Path 2: This catch block handles two kinds of errors:
    //   a) The HttpError we manually threw above after checking !response.ok.
    //   b) A genuine network-level failure where fetch() itself rejected.
    //      In that case, error is a TypeError (e.g. "Failed to fetch") or
    //      a DOMException with name "AbortError" if the request was cancelled.
    if (error instanceof DOMException && error.name === 'AbortError') {
      console.error('Request was cancelled.');
    } else if (error instanceof TypeError) {
      // TypeError means the fetch itself couldn't run —
      // no response was ever received (offline, DNS failure, CORS block).
      console.error('Network failure — no response received:', error.message);
    } else if (error instanceof HttpError) {
      // A response *was* received; the server reported an HTTP error.
      // Handle each status class with the appropriate action.
      switch (true) {
        case error.status === 401:
          console.error('Unauthorized — redirect to login.');
          break;
        case error.status === 403:
          console.error('Forbidden — user lacks permission. Do not retry.');
          break;
        case error.status === 404:
          console.error('Not found — resource does not exist. Do not retry.');
          break;
        case error.status === 429: {
          // Respect Retry-After if the server sends it
          console.error('Rate limited — back off before retrying.');
          break;
        }
        case error.status >= 400 && error.status < 500:
          // Other 4xx: client sent a bad request; retrying without changes won't help.
          console.error(`Client error ${error.status}: ${error.body}`);
          break;
        case error.status >= 500:
          // 5xx: server-side failure; safe to retry with backoff.
          console.error(`Server error ${error.status} — consider retrying:`, error.body);
          break;
      }
    }
    throw error;
  }
}

Notice the split between 4xx and 5xx at the end of the switch. Client errors (4xx) almost always mean retrying the identical request is pointless — the server already told you what is wrong with it. Server errors (5xx) are transient by nature, making them candidates for automatic retry with exponential backoff.

Exponential Backoff With Retry-After #

You can skip this section on your first read. Exponential backoff is a production-hardening pattern, not a requirement for understanding core Fetch error handling.

Here is a concrete retry utility that handles both cases:

  • If the server sends a Retry-After header (common with 429 and sometimes 503), it waits exactly that long.
  • If no header exists, it falls back to exponential backoff with jitter.

Production pattern: Use this in apps where reliability under rate limits and transient outages matters.

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function parseRetryAfterMs(retryAfter: string | null): number | null {
  if (!retryAfter) return null;

  // Retry-After can be either seconds ("120") or an HTTP date.
  const seconds = Number(retryAfter);
  if (!Number.isNaN(seconds)) {
    return Math.max(0, seconds * 1000);
  }

  const dateMs = Date.parse(retryAfter);
  if (Number.isNaN(dateMs)) return null;

  return Math.max(0, dateMs - Date.now());
}

function computeExponentialDelayMs(attempt: number, baseMs: number, capMs: number): number {
  const expDelay = Math.min(capMs, baseMs * 2 ** (attempt - 1));
  const jitter = Math.floor(Math.random() * 250); // avoid synchronized retries
  return expDelay + jitter;
}

async function fetchWithRetry(url: string, init?: RequestInit): Promise<Response> {
  const maxAttempts = 5;
  const baseDelayMs = 500;
  const maxDelayMs = 10_000;

  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
    try {
      const response = await fetch(url, init);

      if (response.ok) return response;

      // Retry only transient statuses.
      const retryableStatus = response.status === 429 || response.status >= 500;
      if (!retryableStatus || attempt === maxAttempts) {
        throw new Error(`HTTP ${response.status} (${response.statusText})`);
      }

      // Explicitly read the Retry-After header from the HTTP response.
      const retryAfterHeader = response.headers.get('Retry-After');

      // If the header exists, honor it. Otherwise, use exponential backoff.
      const delayMs = retryAfterHeader !== null
        ? parseRetryAfterMs(retryAfterHeader) ?? computeExponentialDelayMs(attempt, baseDelayMs, maxDelayMs)
        : computeExponentialDelayMs(attempt, baseDelayMs, maxDelayMs);

      await sleep(delayMs);
      continue;
    } catch (error) {
      // Teaching-safe behavior:
      // - Retry only genuine network failures (TypeError).
      // - Do not retry AbortError (usually user-initiated cancellation).
      // - Do not retry non-network errors thrown from above (for example 4xx).
      if (error instanceof DOMException && error.name === 'AbortError') {
        throw error;
      }

      if (!(error instanceof TypeError)) {
        throw error;
      }

      if (attempt === maxAttempts) throw error;

      const delayMs = computeExponentialDelayMs(attempt, baseDelayMs, maxDelayMs);
      await sleep(delayMs);
    }
  }

  throw new Error('Exhausted retry attempts.');
}

A useful mental model: think of fetch() as a courier. A TypeError or AbortError means the courier never made the trip. A resolved Promise with response.ok === false means the courier returned with a letter whose specific contents — "wrong address", "access denied", "try again later", or "we broke something" — determine what you should do next.

Uploading Content #

While retrieving data is common, you often need to send data to a server. To upload content, configure the fetch request with a method (such as POST or PUT) and include a body. When sending JSON data, remember to explicitly set the Content-Type header so the server knows how to parse the incoming stream.

interface ProfileData {
  bio: string;
  avatarUrl: string;
}

interface UploadResponse {
  success: boolean;
  message: string;
}

async function uploadUserProfile(userId: string, profileData: ProfileData): Promise<UploadResponse> {
  try {
    const response = await fetch(`/api/users/${userId}/profile`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(profileData),
    });

    if (!response.ok) {
      throw new Error(`HTTP error! Status: ${response.status}`);
    }

    const result = (await response.json()) as UploadResponse;
    return result;
  } catch (error) {
    console.error('Failed to upload profile:', error);
    throw error;
  }
}

Fetch with Streams: Uploads and Downloads #

Standard fetch operations buffer the entire request or response in memory before handing the data back to your application. By combining the Fetch API with the Streams API, you process data chunk-by-chunk as it arrives over the network.

Why this is useful: Streaming dramatically reduces memory consumption and improves perceived performance (Time to First Byte).

Common Use Cases:

  • Downloads: Parsing massive datasets (like multi-gigabyte CSVs), rendering real-time AI text generation, or displaying custom progress bars for large files.
  • Uploads: Sending live audio/video recordings directly to a server, or uploading large log files without causing out-of-memory crashes on the client.

Streaming Downloads #

To stream a download, access the ReadableStream provided by response.body and read chunks using a reader. Notice the use of the finally block to guarantee the reader's lock releases, preventing memory leaks even if the stream gets interrupted.

async function streamDownload(url: string): Promise<void> {
  const response = await fetch(url);

  if (!response.body) {
    throw new Error('ReadableStream not supported in this browser.');
  }

  const reader = response.body.getReader();
  const decoder = new TextDecoder('utf-8');

  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      // value is a Uint8Array
      const chunk = decoder.decode(value, { stream: true });
      console.log('Received chunk:', chunk);
      // Process the chunk
    }
  } finally {
    // Ensures the lock is released whether the stream finishes or errors out
    reader.releaseLock();
  }
}

Streaming Uploads #

To stream an upload, pass a ReadableStream directly to the body of the fetch request. You must also include the duplex: 'half' property in the request configuration.

The duplex property dictates how the browser manages the timing of the request and response streams:

  • Half-duplex ('half'): The client finishes sending the entire request body before it expects the server to start sending the response body. This matches the traditional request-response model of the web.
  • Full-duplex ('full'): The client and server can send and receive data simultaneously over the same connection.

Currently, the Fetch specification mandates that whenever you use a ReadableStream as a request body, you must explicitly set duplex: 'half'. If you omit this property, the browser throws a TypeError.

This strict requirement exists because reliable full-duplex streaming is difficult to guarantee across all network protocols (like HTTP/1.1 versus HTTP/2 or HTTP/3) and intermediary proxies. By forcing developers to explicitly declare the stream as half-duplex, the browser avoids unexpected buffering behaviors and ensures cross-platform compatibility.

// Define a RequestInit interface extension to include
// duplex since older TS DOM library versions might lack it.
interface StreamRequestInit extends RequestInit {
  duplex?: 'half';
}

async function streamUpload(url: string, streamGenerator: () => AsyncGenerator<Uint8Array>): Promise<unknown> {
  const readableStream = new ReadableStream<Uint8Array>({
    async start(controller) {
      for await (const chunk of streamGenerator()) {
        controller.enqueue(chunk);
      }
      controller.close();
    }
  });

  const options: StreamRequestInit = {
    method: 'POST',
    headers: {
      'Content-Type': 'application/octet-stream',
    },
    body: readableStream,
    duplex: 'half', // Required for request streams
  };

  const response = await fetch(url, options);
  return response.json();
}

AbortController: Canceling Requests #

Network requests should not hang indefinitely. The AbortController API allows you to cancel ongoing fetch requests, preventing memory leaks and avoiding unnecessary network usage when a user navigates away from a page.

async function fetchWithTimeout<T>(url: string, timeoutMs: number = 5000): Promise<T | undefined> {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const response = await fetch(url, { signal: controller.signal });
    clearTimeout(timeoutId);
    return (await response.json()) as T;
  } catch (error) {
    if (error instanceof Error && error.name === 'AbortError') {
      console.error('Fetch request timed out.');
    } else {
      console.error('Fetch error:', error);
    }
    return undefined;
  }
}

How the Timeout Pattern Works #

Here is a step-by-step breakdown of how the AbortController limits response times:

  1. Initialize the Controller: Calling new AbortController() creates a controller instance containing a signal property and an abort() method.
  2. Start the Timer: The setTimeout function starts a countdown (defaulting to 5,000 milliseconds). If the timer expires, it executes controller.abort().
  3. Connect the Signal: Passing { signal: controller.signal } into the fetch() options links the network request to the controller. If abort() is called, the browser immediately terminates the request.
  4. Clean Up: If the fetch request finishes successfully before the timer runs out, clearTimeout(timeoutId) prevents the abort() method from firing unnecessarily in the background.
  5. Handle the Error: An aborted request throws an AbortError instead of returning a response. The catch block intercepts this specific error, allowing you to handle timeouts gracefully without treating them as standard network failures (like a disconnected offline state).

Fetch Priority API #

The Fetch Priority API allows developers to hint to the browser which resources matter most. By setting the priority option (high, low, or auto), you optimize the Largest Contentful Paint (LCP) and prevent analytics scripts from blocking critical data.

interface AppState {
  status: string;
}

// High priority for critical application state
async function fetchCriticalData(): Promise<AppState> {
  // Note: priority is included in standard RequestInit in modern TS DOM lib
  const response = await fetch('/api/critical-state', { priority: 'high' } as RequestInit);
  return (await response.json()) as AppState;
}

// Low priority for background telemetry
function sendTelemetry(data: Record<string, unknown>): void {
  fetch('/api/telemetry', {
    method: 'POST',
    body: JSON.stringify(data),
    priority: 'low'
  } as RequestInit);
}

Fetch Metadata API #

The Fetch Metadata API is a security feature managed automatically by the browser. When you make a fetch request, the browser attaches Sec-Fetch-* headers to describe the context of the request. Servers use these headers to reject malicious cross-origin requests (like CSRF attacks).

You don't write client-side code for this; instead, you configure your server to read headers like:

  • Sec-Fetch-Site (cross-site, same-origin, same-site, none)
  • Sec-Fetch-Mode (cors, navigate, no-cors, same-origin, websocket)
  • Sec-Fetch-Dest (audio, document, image, script)

Storage Quotas and Cache Management #

While storage limits become most obvious when using the Background Fetch API for large files, these considerations apply universally across the entire Fetch ecosystem. Whether you are caching a Response from a core fetch() call, storing offline data via Periodic Sync, or queueing an outbox message for Background Sync, you are consuming device storage.

Here is how local data storage interacts with your available space:

  • Shared Origin Quota: Browsers allocate a single storage pool for your entire origin. The Cache Storage API, IndexedDB, and the Background Fetch API all share this exact same quota.
  • Proactive Checks: For large operations, you can provide hints (like the downloadTotal property in Background Fetch). The browser uses this value to calculate if your origin has enough available quota before it begins downloading. If the total exceeds your remaining space, the browser rejects the request to prevent filling the user's disk.
  • Storage Eviction: If your domain runs out of space, storage operations will throw a QuotaExceededError. Furthermore, if the user's device experiences global storage pressure, the browser might automatically evict your domain's cached data to free up space, unless you explicitly request persistent storage via the Permissions API.

You can proactively check how much space your domain uses and how much quota the browser grants you using the Storage Manager API (navigator.storage). This API is widely supported across all major modern browsers, including Chrome, Edge, Firefox, and Safari. Because it deals with sensitive storage capabilities, it requires a secure context and only runs when your site is served over HTTPS or localhost.

async function checkStorageSpace(): Promise<void> {
  if (navigator.storage && navigator.storage.estimate) {
    const estimate: StorageEstimate = await navigator.storage.estimate();

    // Fallbacks to 0 if usage or quota are undefined
    const usage = estimate.usage ?? 0;
    const quota = estimate.quota ?? 0;

    const usageMB = (usage / (1024 * 1024)).toFixed(2);
    const quotaMB = (quota / (1024 * 1024)).toFixed(2);

    console.log(`Using ${usageMB} MB out of ${quotaMB} MB.`);
  } else {
    console.warn('StorageManager API is not supported or the site is not secure.');
  }
}

Advanced Appendix #

Background Fetch API #

When you need to download a massive file, like a high-definition movie or a large machine learning model, standard fetch requests fail if the user closes the tab. The Background Fetch API solves this by delegating the download to the browser itself, allowing it to continue independently of the web page. The browser also provides a native progress UI to the user.

Background Fetch requires a registered Service Worker. Because users can close web pages at any time, the browser needs an independent background process to manage long-running tasks. The Service Worker stays active in the background to listen for backgroundfetchsuccess, backgroundfetchfail, or backgroundfetchclick events. Once a download completes, the Service Worker safely stores the files (typically using the Cache Storage API) so they are available the next time the user opens the application.

// In your main application code
async function downloadPodcast(episodeId: string, fileUrls: string[]): Promise<void> {
  if (!('BackgroundFetchManager' in self)) {
    console.warn('Background Fetch API not supported.');
    return;
  }

  const registration = await navigator.serviceWorker.ready;

  try {
    // Type assertion may be necessary depending on your TS configuration
    const bgFetch = await (registration as any).backgroundFetch.fetch(
      `podcast-${episodeId}`,
      fileUrls,
      {
        title: 'Downloading Podcast Episode',
        icons: [{ src: '/icons/podcast.png', sizes: '192x192', type: 'image/png' }],
        downloadTotal: 60 * 1024 * 1024 // 60 MB
      }
    );
    console.log('Background fetch started:', bgFetch.id);
  } catch (error) {
    console.error('Failed to start background fetch:', error);
  }
}

Background Sync & Periodic Sync APIs #

While Background Fetch handles large files, the Background Sync APIs handle data consistency.

  • Background Sync: Defers a network request (like sending a chat message) until the user has a stable internet connection.
  • Periodic Background Sync: Wakes up the service worker at regular intervals to fetch fresh data (like updating news headlines) before the user even opens the app.
// Main application: Register a sync event
async function registerMessageSync(): Promise<void> {
  const registration = await navigator.serviceWorker.ready;
  if ('sync' in registration) {
    try {
      await (registration as any).sync.register('sync-outbox');
      console.log('Background sync registered.');
    } catch (error) {
      console.error('Background sync registration failed:', error);
    }
  }
}
// Service Worker: Listen for the sync event
self.addEventListener('sync', (event: any) => {
  if (event.tag === 'sync-outbox') {
    event.waitUntil(flushOutboxQueue());
  }
});

async function flushOutboxQueue(): Promise<void> {
  // Logic to fetch stored messages from IndexedDB and POST them to your API
}

Conclusion #

The Fetch ecosystem has matured into a powerful suite of tools. By combining the Core Fetch API with Priority Hints, AbortControllers, Streams, and Background fetching techniques, developers can build applications that are fast, resilient to network drops, and respectful of system resources.

Edit on Github