Fetch API Advanced: Abort, Retry, Timeout, and Middleware

WEB STANDARDS & BROWSER APIS By TryzTech Team
JavaScriptFetch APIFrontendHTTPWeb Development

Table of Contents

Introduction

The Fetch API is the standard way to make HTTP requests in modern browsers and Node.js. For a quick GET request or a simple form submission, the built-in API is enough. Production applications, however, need more: the ability to cancel in-flight requests, enforce timeouts, retry transient failures safely, attach authentication headers consistently, and log errors without duplicating logic in every call site.

Libraries like Axios and Ky solve some of these problems out of the box, but understanding how to implement them with native Fetch gives you full control, zero extra dependencies, and patterns that transfer to any HTTP client.

This article walks through the three capabilities that separate prototype fetch code from production-ready HTTP clients: abort, retry, and custom middleware.

Why Basic fetch Is Not Enough

A bare fetch(url) call has several gaps that become painful in real applications:

  • No built-in timeout. A slow or hanging server can leave a request pending indefinitely.
  • No automatic retry. Transient network failures require manual handling at every call site.
  • No request cancellation by default. Navigating away or typing a new search query does not stop the previous request unless you wire it up.
  • No middleware layer. Cross-cutting concerns like auth tokens, base URLs, and error normalization get copy-pasted across files.
  • No distinction between network errors and HTTP errors. A 404 and a DNS failure both return a Response object—or throw, depending on the failure type.

These gaps are not flaws in Fetch itself. Fetch is intentionally minimal. The patterns in this article fill those gaps without abandoning the native API.

Request Cancellation with AbortController

AbortController is the standard mechanism for cancelling fetch requests. It works in browsers and in Node.js 18+.

Basic cancellation

const controller = new AbortController();

fetch('/api/users', { signal: controller.signal })
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => {
    if (error.name === 'AbortError') {
      console.log('Request was cancelled');
      return;
    }
    throw error;
  });

// Cancel the request
controller.abort();

When abort() is called, the fetch promise rejects with a DOMException named AbortError. Always check for this name rather than comparing error messages, which vary across environments.

Cancelling on component unmount

In React, Svelte, or Vue, tie the controller lifecycle to the component:

useEffect(() => {
  const controller = new AbortController();

  fetch('/api/dashboard', { signal: controller.signal })
    .then(res => res.json())
    .then(setData)
    .catch(err => {
      if (err.name !== 'AbortError') setError(err);
    });

  return () => controller.abort();
}, []);

This prevents state updates on unmounted components—a common source of warnings and subtle bugs in SPAs.

Sharing a signal across multiple requests

One AbortController can cancel several related requests at once:

const controller = new AbortController();
const signal = controller.signal;

const usersPromise = fetch('/api/users', { signal });
const ordersPromise = fetch('/api/orders', { signal });

// Cancel both if the user navigates away
controller.abort();

Combining external signals

Use AbortSignal.any() (widely supported in modern browsers) to merge a timeout signal with a user cancellation signal:

const userController = new AbortController();
const timeoutSignal = AbortSignal.timeout(5000);

const signal = AbortSignal.any([
  userController.signal,
  timeoutSignal,
]);

fetch('/api/search?q=fetch', { signal });

If either signal aborts, the fetch is cancelled.

Adding Timeouts to Fetch Requests

Fetch has no timeout option in its init object, but AbortSignal.timeout() provides a clean solution:

async function fetchWithTimeout(url, options = {}, timeoutMs = 8000) {
  const response = await fetch(url, {
    ...options,
    signal: AbortSignal.timeout(timeoutMs),
  });
  return response;
}

Handling timeout errors

Timeout aborts also produce AbortError. Distinguish them by checking whether your user controller triggered the abort:

try {
  const response = await fetch('/api/data', {
    signal: AbortSignal.timeout(3000),
  });
} catch (error) {
  if (error.name === 'AbortError') {
    throw new Error('Request timed out after 3 seconds');
  }
  throw error;
}

Timeouts vs slow responses

A timeout aborts the client-side wait, not necessarily the server-side processing. The server may still complete the work even after the client gives up. For mutating operations (POST, PUT, DELETE), combine timeouts with idempotency keys on the server so that a retried request does not create duplicate side effects.

Retry Strategies That Do Not Make Things Worse

Retries improve reliability for transient failures, but careless retries can amplify load during outages or create duplicate records.

When to retry

Retry is appropriate for:

  • network errors (connection reset, DNS failure)
  • HTTP 408 (Request Timeout)
  • HTTP 429 (Too Many Requests), respecting Retry-After
  • HTTP 502, 503, 504 (gateway and service unavailable errors)

Avoid retrying:

  • HTTP 400, 401, 403, 404, 422 (client errors—the request itself is wrong)
  • HTTP 409 (conflict—retrying may worsen the situation)
  • non-idempotent POST requests without server-side idempotency protection

Exponential backoff with jitter

async function fetchWithRetry(url, options = {}, config = {}) {
  const {
    maxRetries = 3,
    baseDelayMs = 300,
    maxDelayMs = 5000,
    retryOn = [408, 429, 502, 503, 504],
  } = config;

  let lastError;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch(url, options);

      if (response.ok || !retryOn.includes(response.status)) {
        return response;
      }

      lastError = new Error(`HTTP ${response.status}`);

      if (attempt === maxRetries) break;

      const retryAfter = response.headers.get('Retry-After');
      const delay = retryAfter
        ? Number(retryAfter) * 1000
        : Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);

      const jitter = Math.random() * 100;
      await new Promise(resolve => setTimeout(resolve, delay + jitter));
    } catch (error) {
      if (error.name === 'AbortError') throw error;

      lastError = error;
      if (attempt === maxRetries) break;

      const delay = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }

  throw lastError;
}

Jitter prevents thundering herd problems when many clients retry at the same instant after a brief outage.

Respecting AbortSignal during retries

Pass the same signal into every retry attempt. If the user cancels or a parent timeout fires, stop retrying immediately:

for (let attempt = 0; attempt <= maxRetries; attempt++) {
  if (options.signal?.aborted) {
    throw new DOMException('Aborted', 'AbortError');
  }
  // ... fetch attempt
}

Building Custom Fetch Middleware

Middleware wraps fetch to add cross-cutting behavior without modifying every call site. The pattern mirrors Express middleware or Axios interceptors.

The middleware signature

Each middleware receives the URL, options, and a next function that calls the next layer:

function createFetchPipeline(middlewares) {
  const baseFetch = globalThis.fetch.bind(globalThis);

  const dispatch = (index) => (url, options) => {
    if (index >= middlewares.length) {
      return baseFetch(url, options);
    }
    const middleware = middlewares[index];
    return middleware(url, options, dispatch(index + 1));
  };

  return dispatch(0);
}

Example: base URL middleware

function withBaseUrl(baseUrl) {
  return (url, options, next) => {
    const fullUrl = url.startsWith('http') ? url : `${baseUrl}${url}`;
    return next(fullUrl, options);
  };
}

Example: auth header middleware

function withAuth(getToken) {
  return async (url, options, next) => {
    const token = await getToken();
    const headers = new Headers(options.headers);
    headers.set('Authorization', `Bearer ${token}`);
    return next(url, { ...options, headers });
  };
}

Example: logging middleware

function withLogging() {
  return async (url, options, next) => {
    const start = performance.now();
    try {
      const response = await next(url, options);
      console.info(`[fetch] ${options.method ?? 'GET'} ${url} → ${response.status} (${Math.round(performance.now() - start)}ms)`);
      return response;
    } catch (error) {
      console.error(`[fetch] ${options.method ?? 'GET'} ${url} → failed (${Math.round(performance.now() - start)}ms)`, error);
      throw error;
    }
  };
}

Middleware runs in the order it is registered. Place auth and base URL middleware early; logging and retry middleware typically wrap the inner fetch call.

A Composable HTTP Client

Combine middleware, timeout, and retry into a single client object:

const api = createFetchPipeline([
  withBaseUrl('https://api.example.com'),
  withAuth(() => localStorage.getItem('access_token')),
  withLogging(),
  withRetry({ maxRetries: 2 }),
]);

// Usage
const response = await api('/v1/users/me', {
  headers: { Accept: 'application/json' },
});
const user = await response.json();

For retry middleware, wrap next instead of wrapping fetch globally:

function withRetry(config) {
  return (url, options, next) => {
    return fetchWithRetry(url, { ...options, fetchFn: () => next(url, options) }, config);
  };
}

Alternatively, implement retry inside a middleware that calls next in a loop—keeping retry logic co-located with the pipeline rather than scattered across the codebase.

TypeScript tip

Define a shared FetchFn type so middleware stays type-safe:

type FetchFn = (url: string | URL | Request, options?: RequestInit) => Promise<Response>;
type Middleware = (url: string, options: RequestInit, next: FetchFn) => Promise<Response>;

Error Handling and Response Parsing

Fetch only rejects on network failure. HTTP error statuses (4xx, 5xx) return a resolved Response. Handle both explicitly:

async function parseResponse(response) {
  if (!response.ok) {
    const body = await response.text().catch(() => '');
    throw new HttpError(response.status, body, response);
  }

  const contentType = response.headers.get('content-type') ?? '';
  if (contentType.includes('application/json')) {
    return response.json();
  }
  return response.text();
}

class HttpError extends Error {
  constructor(status, body, response) {
    super(`HTTP ${status}`);
    this.name = 'HttpError';
    this.status = status;
    this.body = body;
    this.response = response;
  }
}

Centralizing parsing prevents every call site from repeating if (!response.ok) checks and makes error boundaries in UI frameworks easier to implement.

Common Mistakes in Production Fetch Code

Ignoring AbortError in catch blocks

Treating cancellation like a real error triggers unnecessary error toasts and retry logic. Filter AbortError early.

Retrying non-idempotent POST requests blindly

A timeout on a payment or order creation request may mean the server already processed it. Retrying without an idempotency key can duplicate the operation.

Creating a new AbortController per retry

If you create a fresh controller for each retry attempt, you lose the user’s cancellation signal. Reuse the original signal across attempts.

Not cloning the response before reading the body

response.json() consumes the body stream. If middleware and the caller both need the body, call response.clone() first.

Hardcoding retry counts for all endpoints

Read-heavy GET requests can tolerate more retries than write operations. Configure retry policy per route or per middleware instance.

Forgetting about CORS preflight

Custom headers added by auth middleware trigger preflight OPTIONS requests. Ensure your server handles them, or batch header injection to avoid unnecessary preflights on simple requests.

A Resilient Fetch Checklist

Cancellation and lifecycle

  • Long-running requests accept an AbortSignal.
  • Components abort in-flight requests on unmount.
  • AbortError is handled separately from real failures.

Timeouts

  • Every request has a defined timeout.
  • Timeout duration matches the expected server response time.
  • Mutating requests use idempotency keys when retries are possible.

Retries

  • Only transient failures are retried.
  • Backoff includes jitter.
  • Retry-After headers are respected for 429 responses.
  • Retry loop checks signal.aborted before each attempt.

Middleware and structure

  • Auth, base URL, and logging are centralized in middleware.
  • HTTP errors are normalized into a consistent error type.
  • Response parsing handles JSON, text, and empty bodies.

FAQ

Should I use Fetch or a library like Axios?

Fetch is sufficient for most applications when you add the patterns in this article. Reach for a library when you need features like automatic request/response transforms, built-in upload progress, or a mature interceptor ecosystem without writing your own pipeline.

Does AbortController work in Node.js?

Yes, in Node.js 18 and later. The fetch, AbortController, and AbortSignal.timeout() APIs are available globally.

Can I cancel a fetch after the response headers arrive?

abort() stops the request at any stage—during connection, while receiving headers, or while streaming the body. However, the server may have already completed processing.

How many retries is enough?

For most client-side applications, two or three retries with exponential backoff is a reasonable default. Increase retries only for idempotent read operations where the cost of failure is high and the server can handle the extra load.

Is it safe to retry POST requests?

Only when the server supports idempotency keys or the operation is naturally idempotent. Otherwise, a timeout followed by a retry can create duplicate records.

How does this compare to the Streams API for large downloads?

For large file downloads, combine fetch with ReadableStream and AbortSignal to cancel mid-stream. The abort and middleware patterns in this article apply equally; add progress tracking by reading the stream in chunks.

Conclusion

The Fetch API gives you a solid foundation. Abort, retry, and middleware turn that foundation into a client that behaves correctly under real-world conditions—slow networks, impatient users, and servers that occasionally fail.

Start with cancellation for any request tied to UI state. Add timeouts next. Introduce retry only for idempotent or protected operations. Wrap cross-cutting logic in middleware so your call sites stay clean.

These patterns are small individually, but together they eliminate an entire class of production bugs that basic fetch code leaves open.

What is the most common fetch-related bug you have seen in production? Timeouts, missing abort on unmount, or retry without idempotency? Share your experience in the comments.

Keep reading within the same topic.

Don't Miss Out

Get the latest tech articles, tips, and insights delivered to your inbox.