Fossils💻 CodingImplement Fetch API Wrapper & AbortController
🦖DinosaurJavaScriptFetchNetworkingPolyfill

Implement Fetch API Wrapper & AbortController

Building a production-grade fetch wrapper tests understanding of the Request/Response model, timeout handling, retry logic, and AbortController — the patterns every senior engineer uses daily.

Implement Fetch API Wrapper & AbortController

Interview Question: "Build a fetch wrapper with timeout, retry, and cancellation support."

Minimal XMLHttpRequest-Based Fetch Polyfill

function simpleFetch(url, options = {}) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open(options.method || 'GET', url);
 
    if (options.headers) {
      Object.entries(options.headers).forEach(([key, value]) => {
        xhr.setRequestHeader(key, value);
      });
    }
 
    xhr.onload = () => {
      const response = {
        ok: xhr.status >= 200 && xhr.status < 300,
        status: xhr.status,
        statusText: xhr.statusText,
        headers: parseHeaders(xhr.getAllResponseHeaders()),
        json: () => Promise.resolve(JSON.parse(xhr.responseText)),
        text: () => Promise.resolve(xhr.responseText),
      };
      resolve(response);
    };
 
    xhr.onerror = () => reject(new TypeError('Network request failed'));
    xhr.ontimeout = () => reject(new TypeError('Network request timed out'));
 
    xhr.send(options.body ?? null);
  });
}
 
function parseHeaders(raw) {
  const headers = new Map();
  raw.trim().split('\r\n').forEach(line => {
    const [key, ...rest] = line.split(': ');
    headers.set(key.toLowerCase(), rest.join(': '));
  });
  return {
    get: (name) => headers.get(name.toLowerCase()),
    has: (name) => headers.has(name.toLowerCase()),
  };
}

Production Fetch Wrapper

interface FetchOptions extends RequestInit {
  timeout?: number;
  retries?: number;
  retryDelay?: number | ((attempt: number) => number);
  onRetry?: (error: Error, attempt: number) => void;
}
 
async function fetchWithRetry(
  url: string,
  options: FetchOptions = {}
): Promise<Response> {
  const {
    timeout = 10_000,
    retries = 3,
    retryDelay = (attempt) => Math.min(1000 * 2 ** attempt, 30_000),
    onRetry,
    ...fetchOptions
  } = options;
 
  let lastError: Error;
 
  for (let attempt = 0; attempt <= retries; attempt++) {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), timeout);
 
    if (fetchOptions.signal) {
      fetchOptions.signal.addEventListener('abort', () => controller.abort());
    }
 
    try {
      const response = await fetch(url, {
        ...fetchOptions,
        signal: controller.signal,
      });
 
      clearTimeout(timeoutId);
 
      if (!response.ok && attempt < retries && response.status >= 500) {
        throw new Error(`HTTP ${response.status}`);
      }
 
      return response;
    } catch (error) {
      clearTimeout(timeoutId);
      lastError = error as Error;
 
      if (error instanceof DOMException && error.name === 'AbortError') {
        if (fetchOptions.signal?.aborted) throw error;
      }
 
      if (attempt < retries) {
        const delay = typeof retryDelay === 'function'
          ? retryDelay(attempt)
          : retryDelay;
        onRetry?.(lastError, attempt + 1);
        await new Promise(r => setTimeout(r, delay));
      }
    }
  }
 
  throw lastError!;
}

AbortController Implementation

class SimpleAbortController {
  constructor() {
    this.signal = new SimpleAbortSignal();
  }
 
  abort(reason) {
    this.signal._abort(reason);
  }
}
 
class SimpleAbortSignal {
  constructor() {
    this.aborted = false;
    this.reason = undefined;
    this._listeners = [];
  }
 
  _abort(reason) {
    if (this.aborted) return;
    this.aborted = true;
    this.reason = reason ?? new DOMException('The operation was aborted', 'AbortError');
    this._listeners.forEach(fn => fn());
  }
 
  addEventListener(type, listener) {
    if (type === 'abort') this._listeners.push(listener);
  }
 
  removeEventListener(type, listener) {
    if (type === 'abort') {
      this._listeners = this._listeners.filter(fn => fn !== listener);
    }
  }
 
  throwIfAborted() {
    if (this.aborted) throw this.reason;
  }
 
  static timeout(ms) {
    const controller = new SimpleAbortController();
    setTimeout(() => controller.abort(
      new DOMException('The operation timed out', 'TimeoutError')
    ), ms);
    return controller.signal;
  }
}

Usage Patterns

Cancelling on Component Unmount

function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState(null);
 
  useEffect(() => {
    const controller = new AbortController();
 
    fetchWithRetry(`/api/users/${userId}`, {
      signal: controller.signal,
      retries: 2,
      timeout: 5000,
    })
      .then(res => res.json())
      .then(setUser)
      .catch(err => {
        if (err.name !== 'AbortError') console.error(err);
      });
 
    return () => controller.abort();
  }, [userId]);
 
  return user ? <div>{user.name}</div> : <div>Loading...</div>;
}

Race Pattern — First Response Wins

async function fetchFastest(urls) {
  const controller = new AbortController();
 
  try {
    const result = await Promise.any(
      urls.map(url =>
        fetch(url, { signal: controller.signal }).then(res => {
          if (!res.ok) throw new Error(`${res.status}`);
          return res.json();
        })
      )
    );
    controller.abort();
    return result;
  } catch {
    throw new Error('All requests failed');
  }
}

Common Mistakes

  • Not clearing the timeout after successful fetch (memory leak)
  • Retrying on 4xx errors (client errors shouldn't be retried)
  • Not forwarding the external AbortSignal to the internal controller
  • Forgetting to check error.name === 'AbortError' before retrying (abort shouldn't trigger retry)
  • Using linear retry delays instead of exponential backoff with jitter