typescript 130 lines · 3 tabs

HTTP client timeout with AbortController (fetch)

Shared by codesnips Jan 2026
3 tabs
export class TimeoutError extends Error {
  constructor(public readonly ms: number) {
    super(`Request timed out after ${ms}ms`);
    this.name = "TimeoutError";
  }
}

function linkSignals(external: AbortSignal | undefined, controller: AbortController): () => void {
  if (!external) return () => {};
  if (external.aborted) {
    controller.abort(external.reason);
    return () => {};
  }
  const onAbort = () => controller.abort(external.reason);
  external.addEventListener("abort", onAbort, { once: true });
  return () => external.removeEventListener("abort", onAbort);
}

export async function withTimeout(
  input: RequestInfo | URL,
  init: RequestInit & { timeoutMs: number; signal?: AbortSignal }
): Promise<Response> {
  const { timeoutMs, signal, ...rest } = init;
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  const unlink = linkSignals(signal, controller);

  try {
    return await fetch(input, { ...rest, signal: controller.signal });
  } catch (err) {
    if (err instanceof DOMException && err.name === "AbortError" && !signal?.aborted) {
      throw new TimeoutError(timeoutMs);
    }
    throw err;
  } finally {
    clearTimeout(timer);
    unlink();
  }
}
3 files · typescript Explain with highlit

A common source of hung requests is that fetch has no built-in timeout: without intervention a stalled connection can leave a promise pending forever. This snippet builds a small HTTP client that layers deadline enforcement and cancellation on top of fetch using the standard AbortController, then shows how a React component wires an in-flight request to the component lifecycle.

In withTimeout helper, AbortController is the core primitive. The function creates a controller, arms a setTimeout that calls controller.abort() after ms, and passes controller.signal down to fetch. When the timer fires, fetch rejects with a DOMException whose name is AbortError; the helper catches that and rethrows a typed TimeoutError so callers can distinguish a deadline breach from a genuine failure. The finally block always calls clearTimeout, which matters because a leaked timer can hold the event loop open or fire spuriously on a reused controller.

The helper also honors an external signal. Because a single fetch accepts only one signal, linkSignals bridges an optional caller-provided AbortSignal to the internal controller, so either the timeout or an upstream cancellation aborts the request. The listener is registered with { once: true } and cleaned up to avoid accumulating handlers across retries.

In ApiClient, request composes these pieces and adds bounded retries via retryWithBackoff. Only idempotent conditions are retried — a timeout or a 5xx — while a 4xx response throws an HttpError immediately, since retrying client errors wastes budget. Each attempt gets a fresh timeout, and if the caller's signal is already aborted the client fails fast rather than starting work.

In useApiResource hook, the pattern is completed on the client side: an AbortController is created per effect run and aborted in the cleanup function. This cancels the request when the component unmounts or when dependencies change, preventing the classic "setState on an unmounted component" race and avoiding wasted bandwidth on stale requests. The hook filters out AbortError so a deliberate cancellation is not surfaced as a user-facing error. Together these files show the trade-off: AbortController gives cooperative cancellation, but the caller is responsible for wiring timers, cleanup, and error classification correctly.


Related snips

typescript
export interface RetryOptions {
  retries: number;
  baseMs: number;
  maxMs: number;
  signal?: AbortSignal;
  onRetry?: (attempt: number, delay: number, err: unknown) => void;

Exponential backoff with jitter for retries

typescript reliability retry
by codesnips 2 tabs
typescript
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'

const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
  timeout: 15000,

Axios API client with interceptors

react axios api
by Maya Patel 1 tab
typescript
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

interface FilterState {
  search: string
  category: string | null

Zustand for lightweight state management

react zustand state-management
by Maya Patel 2 tabs
javascript
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static values = {
    url: String,
    delay: { type: Number, default: 800 },

Stimulus: autosave draft with Turbo-friendly requests

rails stimulus hotwire
by codesnips 3 tabs
typescript
import React from "react";

type FallbackProps = {
  error: Error;
  reset: () => void;
};

React Error Boundary + error reporting hook

react frontend error-boundary
by codesnips 3 tabs
javascript
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
  openAnalyzer: true,
});

/** @type {import('next').NextConfig} */

Next.js bundle analyzer for targeted performance work

nextjs performance tooling
by codesnips 4 tabs

Share this code

Here's the card — post it anywhere.

HTTP client timeout with AbortController (fetch) — share card
Link copied