Browser Timeout & Retry Logic for File Uploads

An upload that hangs is worse than an upload that fails: the socket stays open, the progress bar freezes at 97%, and the user reloads the tab — which quietly starts a second write against your storage backend. This guide gives every attempt a deadline the browser will actually enforce, classifies what went wrong before deciding anything, and retries on a schedule that recovers from transient faults without turning a ten-second backend wobble into a self-inflicted outage.

Timeout and retry behaviour is the part of upload fundamentals and browser APIs that only shows its bugs in production, on a train, on a 3G tether, behind a corporate proxy. The defaults are all wrong in the same direction: fetch() has no timeout at all, XMLHttpRequest.timeout defaults to 0 (disabled), and every proxy between the browser and your origin has its own clock that you did not set and cannot see from JavaScript.

What this guide covers:

  • The four independent clocks that can kill one upload, and which one you actually control
  • Turning AbortController into a two-phase deadline (headers vs. transfer) with a typed error
  • Classifying responses and exceptions into succeed / retry / fail before any delay is computed
  • Full-jitter backoff, a retry budget in both attempts and wall-clock time, and honouring Retry-After
  • A per-endpoint circuit breaker so a regional outage does not become a retry storm
  • Verifying all of it deterministically with fake timers and DevTools throttling

Prerequisites

  • [ ] Node 20+ for the test runner, and a browser with AbortController plus AbortSignal.timeout() (Chrome 103+, Firefox 100+, Safari 16+)
  • [ ] TypeScript 5.x with lib: ["DOM", "DOM.Iterable", "ES2022"]AbortSignal.timeout and Error.cause need ES2022
  • [ ] A server that accepts an idempotency key and reconciles repeated writes, as described in retrying fetch uploads with idempotency keys
  • [ ] The measured timeouts of every hop in front of your origin (CDN, load balancer, reverse proxy, function runtime) — guesses here produce unclassifiable failures
  • [ ] Somewhere to persist attempt state across a reload, typically IndexedDB — see persisting upload state in IndexedDB
  • [ ] CORS already working end to end, including preflight, per fixing CORS preflight errors on S3 uploads

How it works

The four clocks on one upload attempt

A single fetch() that uploads 25 MB is not one operation with one duration. It is a connection handshake, an optional CORS preflight, a long body write, a period where the server is doing work and sending nothing, and finally a response read. Different timers own different parts of that span, they are configured in different systems, and the first one to fire decides what your error handler sees.

Four timeout scopes over the phases of one upload request A horizontal lane splits one upload into TCP and TLS setup, CORS preflight, request body transfer, server work and response read. Four brackets underneath show which span each timeout watches: the browser abort signal covers everything, nginx client_body_timeout covers the body transfer, nginx proxy_read_timeout covers server work, and Cloudflare's 524 covers server work into the response. Four clocks on one upload attempt TCP + TLS preflight request body bytes server work read reply AbortSignal.timeout() / xhr.timeout — whole attempt nginx client_body_timeout (gap between reads) nginx proxy_read_timeout Cloudflare 524 after 100 s
Only the top bracket is yours. The other three fire in infrastructure you configure elsewhere, and whichever fires first determines whether the browser sees a status code or an opaque abort.

The browser deliberately gives JavaScript one lever, not four. There is no connect timeout, no write timeout, and no idle-socket timeout exposed to page code; there is a signal you can abort whenever you like. Everything else — separating “we never got headers” from “headers arrived but the body stalled” — you build yourself out of timers around the promise.

That single lever is also the only one that works for direct-to-storage uploads. When the browser PUTs straight to S3 or GCS there is no nginx and no CDN in the path to rescue you, so the client-side deadline is the only deadline. When the upload goes through your own API, the proxy chain will usually answer first with a real status code, which is far more useful to a retry decision than an AbortError.

Two failure classes that look identical from JavaScript

fetch() rejects with exactly one error type for every network-layer problem, and the message differs per engine:

  • Chrome and Edge: TypeError: Failed to fetch
  • Firefox: TypeError: NetworkError when attempting to fetch resource.
  • Safari: TypeError: Load failed

That same rejection covers DNS failure, TCP reset, TLS failure, the device going offline, a blocked mixed-content request, an extension cancelling the request, and — critically — every CORS misconfiguration. The response never reaches page JavaScript, so there is no status code, no headers, and no way to tell “the tunnel died” from “your Access-Control-Allow-Origin is wrong”. The DevTools console shows the underlying reason (net::ERR_CONNECTION_TIMED_OUT, net::ERR_NETWORK_CHANGED, net::ERR_INTERNET_DISCONNECTED) but that string is not readable from script.

Aborts are distinguishable, and the distinction matters:

  • A user pressing Cancel gives you DOMException with name === 'AbortError' and, in Chrome, the message signal is aborted without reason unless you pass a reason.
  • AbortSignal.timeout(ms) aborts with a DOMException whose name is TimeoutError, message signal timed out.

Retrying an AbortError the user asked for is a bug. Retrying a TimeoutError is usually correct. That is reason enough to always abort with an explicit reason object, which is what the implementation below does. The mechanics of composing user-cancel and deadline signals are covered in depth in aborting uploads with AbortController and timeouts.

The uncertainty window

When your deadline fires, you know one thing: the browser stopped waiting. You do not know whether the server received all the bytes, whether it finished writing the object, or whether the 201 was already on the wire when the socket closed. For a 25 MB upload over a slow link, the window in which the request has succeeded server-side but failed client-side can be several seconds wide.

Every retry therefore has to be safe to repeat. Two mechanisms make that true, and you want both: a stable idempotency key so the server can recognise the repeat and return the original result, and content-addressed object keys so a duplicate write lands on the same bytes. Once retries are idempotent, an aggressive retry policy costs bandwidth and nothing else; without it, an aggressive policy costs you duplicate rows, duplicate charges, and duplicate transcoding jobs.

Step-by-step implementation

Step 1: Give every attempt an explicit deadline

The wrapper below enforces two separate deadlines. headersTimeoutMs covers connection setup through the first response byte — the phase where a dead route or an overloaded origin shows up. totalTimeoutMs covers the whole attempt including reading the response body, so a server that sends headers and then stalls cannot pin the request open forever.

The critical structural detail is that the response body is consumed inside the deadline scope. A wrapper that returns the Response and clears its timers in a finally block has already stopped protecting you by the time the caller calls .json().

export type TimeoutPhase = 'headers' | 'transfer';

export class UploadTimeoutError extends Error {
  readonly phase: TimeoutPhase;
  constructor(phase: TimeoutPhase, ms: number) {
    super(`Upload attempt exceeded the ${phase} deadline of ${ms} ms`);
    this.name = 'UploadTimeoutError';
    this.phase = phase;
  }
}

export interface DeadlineOptions extends RequestInit {
  /** Connection open through first response byte. */
  headersTimeoutMs?: number;
  /** Whole attempt, including reading the response body. */
  totalTimeoutMs?: number;
  /** Caller-owned cancellation, e.g. a Cancel button. */
  externalSignal?: AbortSignal;
}

export async function fetchWithDeadline<T>(
  url: string,
  options: DeadlineOptions,
  consume: (response: Response) => Promise<T>,
): Promise<T> {
  const {
    headersTimeoutMs = 30_000,
    totalTimeoutMs = 120_000,
    externalSignal,
    ...init
  } = options;

  const controller = new AbortController();
  // Abort with a *reason* so the catch block can tell cancel from timeout.
  const onExternalAbort = () => controller.abort(externalSignal?.reason);
  externalSignal?.addEventListener('abort', onExternalAbort, { once: true });

  const headersTimer = setTimeout(
    () => controller.abort(new UploadTimeoutError('headers', headersTimeoutMs)),
    headersTimeoutMs,
  );
  const totalTimer = setTimeout(
    () => controller.abort(new UploadTimeoutError('transfer', totalTimeoutMs)),
    totalTimeoutMs,
  );

  try {
    const response = await fetch(url, { ...init, signal: controller.signal });
    // Headers are in: only the total deadline still applies.
    clearTimeout(headersTimer);
    return await consume(response);
  } catch (error) {
    const reason: unknown = controller.signal.reason;
    if (reason instanceof UploadTimeoutError) throw reason;
    throw error;
  } finally {
    clearTimeout(headersTimer);
    clearTimeout(totalTimer);
    externalSignal?.removeEventListener('abort', onExternalAbort);
  }
}

Call it with a consumer that reads what you actually need — for an upload endpoint that is usually a small JSON envelope:

interface UploadReceipt {
  objectKey: string;
  etag: string;
}

const receipt = await fetchWithDeadline<UploadReceipt>(
  '/api/uploads',
  {
    method: 'POST',
    body: formData,
    headersTimeoutMs: 20_000,
    totalTimeoutMs: 90_000,
  },
  async (response) => {
    if (!response.ok) {
      throw new HttpError(response.status, await response.text());
    }
    return (await response.json()) as UploadReceipt;
  },
);

On a route that black-holes traffic, the console shows the headers deadline firing at 20 s rather than the tab spinning indefinitely:

UploadTimeoutError: Upload attempt exceeded the headers deadline of 20000 ms
    at Timeout._onTimeout (upload/deadline.ts:41:26)

Size totalTimeoutMs from the payload rather than picking a round number. A defensible formula is bytes / floorBytesPerSecond * 1000 + fixedOverheadMs, with a floor of roughly 200 kB/s for mobile and 15 s of overhead for handshakes and server work. A 25 MB upload then gets 25_000_000 / 200_000 * 1000 + 15_000 = 140 s. A 5 MB chunk gets 40 s. Hard-coding 30 s for both is how you get a chunked upload that can never succeed on a train — the same failure mode diagnosed for the legacy API in fixing XMLHttpRequest timeout errors for large files.

Step 2: Classify the failure before deciding anything

Classification is a pure function. Given a response or a thrown error, it returns one of three decisions and nothing else — no delays, no side effects, no logging. Keeping it pure is what makes the policy testable, and it stops the “is this retriable?” logic from being smeared across three call sites.

Retry decision path from attempt to outcome An attempt feeds a classify step. A 2xx completes. Retriable codes and timeouts go to a budget check, which either loops back to a new attempt or gives up while keeping state. Permanent 4xx responses fail fast without a retry. Retry decision path Attempt N fetch + deadline Classify status or error 2xx — done commit, clear state 408 · 429 · 5xx · timeout back off, then retry Budget check attempts and elapsed 4xx — fail fast surface to the user Give up, keep state 2xx 4xx budget left → retry exhausted
Classification happens once, before any delay is computed — which is why the budget check sits on the retry edge rather than inside the error handler.
export type Decision =
  | { action: 'succeed' }
  | { action: 'retry'; reason: string; retryAfterMs: number | null }
  | { action: 'fail'; reason: string };

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

/** Transient by contract. 413 and 507 are deliberately absent. */
const RETRIABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);

/** S3 and GCS answer a stalled socket with a 400 that IS retriable. */
const RETRIABLE_S3_CODES = ['RequestTimeout', 'InternalError', 'SlowDown'];

export function parseRetryAfter(header: string | null, now = Date.now()): number | null {
  if (header === null || header.trim() === '') return null;
  const seconds = Number(header);
  if (Number.isFinite(seconds)) return Math.max(0, Math.round(seconds * 1000));
  const at = Date.parse(header);
  if (Number.isNaN(at)) return null;
  return Math.max(0, at - now);
}

export function classifyResponse(status: number, body: string, retryAfter: string | null): Decision {
  if (status >= 200 && status < 300) return { action: 'succeed' };
  if (RETRIABLE_STATUS.has(status)) {
    return {
      action: 'retry',
      reason: `status ${status}`,
      retryAfterMs: parseRetryAfter(retryAfter),
    };
  }
  if (status === 400 && RETRIABLE_S3_CODES.some((code) => body.includes(`<Code>${code}</Code>`))) {
    return { action: 'retry', reason: 'storage transient 400', retryAfterMs: null };
  }
  return { action: 'fail', reason: `status ${status}` };
}

export function classifyError(error: unknown): Decision {
  if (error instanceof UploadTimeoutError) {
    return { action: 'retry', reason: `${error.phase} timeout`, retryAfterMs: null };
  }
  if (error instanceof DOMException && error.name === 'TimeoutError') {
    return { action: 'retry', reason: 'signal timed out', retryAfterMs: null };
  }
  if (error instanceof DOMException && error.name === 'AbortError') {
    // The user pressed Cancel. Never retry this.
    return { action: 'fail', reason: 'cancelled by user' };
  }
  if (error instanceof TypeError) {
    // "Failed to fetch" / "NetworkError…" / "Load failed" — indistinguishable.
    return { action: 'retry', reason: 'network error', retryAfterMs: null };
  }
  return { action: 'fail', reason: String(error) };
}

Three choices here are worth arguing about. Retrying a bare TypeError means a permanently broken CORS configuration will consume the full retry budget on every upload — that is the price of the browser refusing to tell you the difference, and it is why the budget must be bounded in wall-clock time as well as attempts. Excluding 413 and 507 is deliberate: neither gets better by waiting, and both need a different response from the UI, which handling 413 and 507 errors during uploads works through. Including a specific 400 looks wrong until the first time you see S3 answer a stalled PUT with <Code>RequestTimeout</Code><Message>Your socket connection to the server was not read from or written to within the timeout period.</Message>.

Step 3: Compute the delay with full jitter

Naive exponential backoff synchronises clients. If 4,000 browsers all fail against the same 503 at the same moment, they will all retry at 1 s, then 2 s, then 4 s, hammering the recovering service in tight waves and re-tripping it. Adding a small random offset to a fixed delay barely helps — the waves get slightly blurred edges. Full jitter, which picks uniformly from [0, exponential], flattens them into a genuinely uniform arrival rate.

Full-jitter delay windows per retry attempt A bar chart of six retry attempts. Each bar spans the full jitter window from zero up to the exponential ceiling of 0.5, 1, 2, 4, 8 and 15 seconds, with a dot marking the mean delay at half the ceiling. A dashed line marks the 15 second cap. Delay window: 500 ms base, factor 2, 15 s cap full-jitter window mean delay 0 s 4 s 8 s 12 s 16 s cap 15 s 0.5s 1s 2s 4s 8s 15s 1 2 3 4 5 6 retry attempt
Each bar is the range a full-jitter delay is drawn from, not a fixed wait — the mean is half the ceiling, so the expected total wait across six attempts is about 15 s rather than 30 s.
export type JitterMode = 'none' | 'full' | 'equal';

export interface BackoffConfig {
  baseMs: number;
  factor: number;
  capMs: number;
  jitter: JitterMode;
}

export const DEFAULT_BACKOFF: BackoffConfig = {
  baseMs: 500,
  factor: 2,
  capMs: 15_000,
  jitter: 'full',
};

/**
 * @param attempt 1-based index of the attempt that just failed.
 * @param random  injectable for deterministic tests.
 */
export function backoffDelay(
  attempt: number,
  config: BackoffConfig = DEFAULT_BACKOFF,
  random: () => number = Math.random,
): number {
  const ceiling = Math.min(config.capMs, config.baseMs * config.factor ** (attempt - 1));
  switch (config.jitter) {
    case 'none':
      return Math.round(ceiling);
    case 'equal':
      return Math.round(ceiling / 2 + random() * (ceiling / 2));
    case 'full':
      return Math.round(random() * ceiling);
    default:
      return Math.round(ceiling);
  }
}

Dumping the schedule with a fixed random source makes the shape obvious:

attempt 1  ceiling   500ms  full-jitter draw   183ms
attempt 2  ceiling  1000ms  full-jitter draw   642ms
attempt 3  ceiling  2000ms  full-jitter draw   911ms
attempt 4  ceiling  4000ms  full-jitter draw  3120ms
attempt 5  ceiling  8000ms  full-jitter draw  4408ms
attempt 6  ceiling 15000ms  full-jitter draw 11097ms

The same arithmetic applies per chunk when you are uploading in parts, but the accounting is different — a chunked job needs a shared budget so five chunks failing simultaneously do not multiply the retry volume by five. That coordination problem is worked through in implementing exponential backoff for failed chunks.

Step 4: Drive the loop with a two-dimensional budget

An attempt count alone is not a budget. Five attempts against a 90-second deadline is a 7.5-minute worst case, which is far longer than any user will watch a progress bar. Bound both: maxAttempts stops runaway loops, maxElapsedMs stops slow ones.

export interface RetryPolicy extends BackoffConfig {
  maxAttempts: number;
  maxElapsedMs: number;
  respectRetryAfter: boolean;
  retryAfterCapMs: number;
}

export const DEFAULT_POLICY: RetryPolicy = {
  ...DEFAULT_BACKOFF,
  maxAttempts: 5,
  maxElapsedMs: 180_000,
  respectRetryAfter: true,
  retryAfterCapMs: 60_000,
};

function sleep(ms: number, signal?: AbortSignal): Promise<void> {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(resolve, ms);
    signal?.addEventListener(
      'abort',
      () => {
        clearTimeout(timer);
        reject(signal.reason);
      },
      { once: true },
    );
  });
}

export interface AttemptContext {
  attempt: number;
  idempotencyKey: string;
  signal: AbortSignal | undefined;
}

export async function withRetry<T>(
  run: (ctx: AttemptContext) => Promise<T>,
  idempotencyKey: string,
  policy: RetryPolicy = DEFAULT_POLICY,
  signal?: AbortSignal,
): Promise<T> {
  const startedAt = Date.now();
  let lastReason = 'no attempt made';

  for (let attempt = 1; attempt <= policy.maxAttempts; attempt += 1) {
    try {
      return await run({ attempt, idempotencyKey, signal });
    } catch (error) {
      const decision: Decision =
        error instanceof HttpError
          ? classifyResponse(error.status, error.body, error.retryAfter)
          : classifyError(error);
      if (decision.action !== 'retry') throw error;
      lastReason = decision.reason;

      const serverDelay =
        policy.respectRetryAfter && decision.retryAfterMs !== null
          ? Math.min(decision.retryAfterMs, policy.retryAfterCapMs)
          : 0;
      const delay = Math.max(serverDelay, backoffDelay(attempt, policy));

      const elapsed = Date.now() - startedAt;
      if (attempt === policy.maxAttempts || elapsed + delay > policy.maxElapsedMs) {
        throw new Error(
          `Upload gave up after ${attempt} attempt(s) in ${elapsed} ms: ${lastReason}`,
          { cause: error },
        );
      }

      console.warn(
        `upload retry ${attempt}/${policy.maxAttempts} in ${delay} ms — ${lastReason}`,
      );
      await sleep(delay, signal);
    }
  }

  throw new Error(`Upload exhausted its retry budget: ${lastReason}`);
}

A run against an origin that is throttling then recovers looks like this in the console:

upload retry 1/5 in 287 ms — status 503
upload retry 2/5 in 1000 ms — status 429     ← Retry-After: 1 won the max()
upload retry 3/5 in 2743 ms — headers timeout
POST /api/uploads 201 (4.9 s total, 4 attempts)

Note Math.max(serverDelay, backoffDelay(...)) rather than preferring one or the other. Retry-After: 1 on the third consecutive failure should not reset you to a one-second cadence; the backoff floor keeps the schedule monotonic while the server’s instruction can still push a delay up.

Step 5: Make each retry safe to repeat

The idempotency key must be stable across retries and across a page reload, otherwise a user who refreshes mid-upload creates a second logical write. Derive it once per file and persist it alongside the upload state.

interface PendingUpload {
  idempotencyKey: string;
  fileName: string;
  size: number;
  lastModified: number;
}

const STORE_PREFIX = 'upload-key:';

function fingerprint(file: File): string {
  return `${file.name}:${file.size}:${file.lastModified}`;
}

export function idempotencyKeyFor(file: File): string {
  const storageKey = STORE_PREFIX + fingerprint(file);
  const existing = window.localStorage.getItem(storageKey);
  if (existing !== null) return existing;

  const created = crypto.randomUUID();
  const record: PendingUpload = {
    idempotencyKey: created,
    fileName: file.name,
    size: file.size,
    lastModified: file.lastModified,
  };
  window.localStorage.setItem(storageKey, created);
  window.localStorage.setItem(`${storageKey}:meta`, JSON.stringify(record));
  return created;
}

export function releaseIdempotencyKey(file: File): void {
  const storageKey = STORE_PREFIX + fingerprint(file);
  window.localStorage.removeItem(storageKey);
  window.localStorage.removeItem(`${storageKey}:meta`);
}

Wire the pieces together — deadline inside classification inside the retry loop:

export async function uploadFile(file: File, signal?: AbortSignal): Promise<UploadReceipt> {
  const key = idempotencyKeyFor(file);

  const receipt = await withRetry<UploadReceipt>(
    async ({ attempt, idempotencyKey }) => {
      const body = new FormData();
      body.append('file', file, file.name);

      return fetchWithDeadline<UploadReceipt>(
        '/api/uploads',
        {
          method: 'POST',
          body,
          headers: {
            'Idempotency-Key': idempotencyKey,
            'Upload-Attempt': String(attempt),
          },
          headersTimeoutMs: 20_000,
          totalTimeoutMs: Math.round((file.size / 200_000) * 1000) + 15_000,
          externalSignal: signal,
        },
        async (response) => {
          if (!response.ok) {
            throw new HttpError(
              response.status,
              await response.text(),
              response.headers.get('Retry-After'),
            );
          }
          return (await response.json()) as UploadReceipt;
        },
      );
    },
    key,
    DEFAULT_POLICY,
    signal,
  );

  releaseIdempotencyKey(file);
  return receipt;
}

Idempotency-Key and Upload-Attempt are both non-simple headers, so they force a CORS preflight on cross-origin uploads. That is fine against your own API with a long Access-Control-Max-Age, but it is a real cost against object storage — see the first gotcha below.

Configuration reference

Key Type Default Effect
headersTimeoutMs number 30000 Aborts if no response headers arrive. Catches dead routes and saturated origins. Set below your gateway’s response timeout only if you would rather not see its status code.
totalTimeoutMs number 120000 Whole-attempt ceiling including body read. Derive from payload size, never hard-code.
externalSignal AbortSignal undefined User-initiated cancel. Classified as fail, never retried.
baseMs number 500 Ceiling for attempt 1. Below ~200 ms you retry inside the same congestion event.
factor number 2 Growth per attempt. 1.5 gives a gentler ramp for chatty chunked jobs.
capMs number 15000 Maximum ceiling. Above ~30 s users assume the app has frozen.
jitter 'none' | 'full' | 'equal' 'full' 'full' draws from [0, ceiling]; 'equal' from [ceiling/2, ceiling]; 'none' for tests only.
maxAttempts number 5 Hard attempt ceiling. Includes the first attempt.
maxElapsedMs number 180000 Wall-clock ceiling across all attempts, checked before sleeping.
respectRetryAfter boolean true Honour Retry-After on 429 and 503. Applied as a floor, via max().
retryAfterCapMs number 60000 Clamp for hostile or mistaken Retry-After values (a date six hours out is not unheard of).
breakerThreshold number 5 Consecutive failures per endpoint before the circuit opens.
breakerCooldownMs number 30000 How long the circuit stays open before allowing one probe.
idempotencyKey string required Stable per logical upload, persisted across reloads.

Matching the budget to the infrastructure below you

Your client-side numbers only make sense relative to what is in front of your origin. These are the defaults that catch teams out most often:

Hop Default deadline What the browser sees when it fires Knob
fetch() none Request hangs until the tab is closed AbortSignal.timeout() / AbortController
XMLHttpRequest 0 — disabled ontimeout never fires xhr.timeout
Cloudflare 100 s to first origin byte HTTP 524 with an HTML error page Raise on Enterprise, or answer faster
nginx client_body_timeout 60 s between successive reads Connection closed, often surfaces as TypeError client_body_timeout
nginx proxy_read_timeout 60 s HTTP 504 Gateway Time-out proxy_read_timeout
AWS ALB idle timeout 60 s HTTP 504 with no target response logged idle_timeout.timeout_seconds
API Gateway (REST) 29 s integration 504 with {"message":"Endpoint request timed out"} Not raisable by default
Direct PUT to S3 no gateway in the path 400 RequestTimeout on a stalled socket Client deadline only

The rule that follows: make the client’s headers deadline slightly longer than the nearest upstream response timeout. If nginx will answer 504 at 60 s, aborting at 45 s means you throw away the only informative signal in the chain and replace it with an untyped abort. Set 70 s instead and let the 504 arrive; then your logs distinguish “gateway gave up on the app” from “the network vanished”.

The exception is direct-to-storage uploads, where nothing upstream will ever answer. There the client deadline is the whole safety net, and it should be sized from bytes and a pessimistic floor bandwidth. If your gateway limits are the actual constraint rather than the timeouts, raising Nginx and Cloudflare upload size limits covers the body-size side of the same configuration.

Edge cases and gotchas

CORS preflight spends budget before a single byte moves

Adding Idempotency-Key makes the request non-simple, so the browser sends an OPTIONS first. The preflight is a full round trip against the same congested network, and it happens inside your headersTimeoutMs window. On a 700 ms RTT mobile link, a preflight plus the POST costs 1.4 s before the body starts.

Worse, a failed preflight surfaces as the same opaque TypeError as a dropped connection, so your retry loop will burn its whole budget on a configuration error. Two mitigations: set Access-Control-Max-Age to 86400 so the preflight is cached for the session (Chrome caps it at 2 hours regardless), and keep the custom header count to one. Sending the idempotency key as a query parameter avoids the preflight entirely at the cost of leaking it into access logs.

Retry storms after a regional outage

When a whole availability zone fails, every client fails simultaneously and every client starts retrying simultaneously. Full jitter spreads the arrivals but does not reduce their total volume — with five attempts each, ten thousand clients still deliver fifty thousand requests to a service that is trying to come back up. A circuit breaker caps that: after N consecutive failures against an endpoint, stop trying entirely for a cooldown, then let exactly one probe through.

Circuit breaker states for an upload endpoint Three states in a row. Closed moves to open after five consecutive failures. Open moves to half-open after a cooldown. Half-open returns to open if the probe fails, or back to closed if the probe succeeds. Circuit breaker states, per endpoint CLOSED requests flow OPEN reject immediately HALF-OPEN one probe upload 5 failures cooldown probe fails probe succeeds → close Held in memory per origin; a page reload resets it to CLOSED.
The half-open state is the whole point: one probe decides for everyone, so a recovering origin is never hit by the full retry population at once.
type BreakerState = 'CLOSED' | 'OPEN' | 'HALF_OPEN';

export class UploadCircuitOpenError extends Error {
  readonly retryAtMs: number;
  constructor(retryAtMs: number) {
    super(`Upload endpoint circuit is open for another ${retryAtMs} ms`);
    this.name = 'UploadCircuitOpenError';
    this.retryAtMs = retryAtMs;
  }
}

export class UploadCircuitBreaker {
  #states = new Map<string, { state: BreakerState; failures: number; openedAt: number }>();

  constructor(
    private readonly threshold = 5,
    private readonly cooldownMs = 30_000,
  ) {}

  #entryFor(endpoint: string) {
    let entry = this.#states.get(endpoint);
    if (entry === undefined) {
      entry = { state: 'CLOSED', failures: 0, openedAt: 0 };
      this.#states.set(endpoint, entry);
    }
    return entry;
  }

  async run<T>(endpoint: string, task: () => Promise<T>): Promise<T> {
    const entry = this.#entryFor(endpoint);

    if (entry.state === 'OPEN') {
      const waited = Date.now() - entry.openedAt;
      if (waited < this.cooldownMs) throw new UploadCircuitOpenError(this.cooldownMs - waited);
      entry.state = 'HALF_OPEN';
    }

    try {
      const result = await task();
      entry.state = 'CLOSED';
      entry.failures = 0;
      return result;
    } catch (error) {
      // A user cancel is not evidence that the endpoint is unhealthy.
      if (error instanceof DOMException && error.name === 'AbortError') throw error;
      entry.failures += 1;
      if (entry.state === 'HALF_OPEN' || entry.failures >= this.threshold) {
        entry.state = 'OPEN';
        entry.openedAt = Date.now();
        console.warn(`circuit OPEN for ${endpoint} after ${entry.failures} failure(s)`);
      }
      throw error;
    }
  }
}

Wrap withRetry, not the individual attempt — otherwise the breaker counts every retry inside one logical upload and trips on a single unlucky file.

fetch cannot see a stalled upload body

fetch() exposes no upload progress. If the connection dies mid-body in a way that never resets the socket, nothing resolves and nothing rejects until your total deadline fires — potentially 140 s of a frozen progress bar. XMLHttpRequest still wins here because xhr.upload.onprogress gives you byte-level visibility, and it is why so much production upload code is still XHR-based.

The modern replacement is a request body built from a ReadableStream, which lets you count bytes as they are pulled and implement a stall timeout (no bytes for 15 s) distinct from a total timeout. It requires HTTP/2 and duplex: 'half', and it is covered in uploading with ReadableStream request bodies. Until you adopt it, treat a long headers deadline as a stand-in for a stall detector and be honest in the UI: show elapsed time rather than a percentage that has not moved, as discussed in showing accurate time-remaining estimates.

Tab suspension freezes your timers, not the socket

A background tab gets its timers throttled to roughly one wake per minute, and on mobile the page may be frozen or discarded outright. A setTimeout scheduled for a 4 s backoff can therefore fire 60 s later, by which point the upload session may have expired server-side. Two defences: check Date.now() after every sleep rather than trusting the timer, and listen for visibilitychange to re-evaluate whether a retry is still worth attempting when the tab comes back.

async function sleepUntil(deadlineMs: number, signal?: AbortSignal): Promise<void> {
  let remaining = deadlineMs - Date.now();
  while (remaining > 0) {
    await sleep(Math.min(remaining, 1_000), signal);
    remaining = deadlineMs - Date.now();
  }
}

Polling in one-second slices costs nothing and makes the wait accurate to within a second even when the tab was frozen for a minute.

Presigned URLs can expire mid-retry

A presigned PUT signed for 900 s and a retry budget of 180 s look compatible until the user backgrounds the tab between attempts. When the retry finally fires, S3 answers 403 with <Code>AccessDenied</Code><Message>Request has expired</Message> — a permanent-looking failure caused entirely by timing.

Treat expiry as a distinct decision: on a 403 whose body contains Request has expired, re-mint the URL instead of failing. That requires your retry callback to be able to fetch a fresh signature, which is an argument for passing an async URL provider rather than a fixed string. Signing lifetimes and the trade-offs around them belong to S3 presigned URL workflows.

The 400 you must retry, and the 200 you must not trust

Two asymmetries bite in production. S3 returns 400 RequestTimeout when a socket stalls, which your status-code table will classify as permanent unless you special-case it — that is what RETRIABLE_S3_CODES above is for. In the other direction, some proxies return 200 with an error document when the origin timed out, so a body-shape check (typeof receipt.objectKey === 'string') belongs in your consumer function, not only in tests.

Aborted uploads keep the Blob alive

An aborted fetch does not release the File reference held by the FormData you built. Keep a FormData per attempt inside the retry callback — as the implementation above does — rather than constructing it once and reusing it across attempts. Reusing a FormData also breaks on some engines because the underlying stream is already consumed, producing TypeError: Failed to execute 'fetch' on 'Window': Request body is already used.

For chunked uploads the same rule applies per slice: create the Blob view with file.slice() at the moment you need it and drop the reference on success. Blob.slice() is a zero-copy view, so this costs nothing — the mechanics are in slicing large files with Blob.slice.

Retry-After can be a date, and it can be absurd

Retry-After: Wed, 21 Oct 2026 07:28:00 GMT is valid and appears from CDNs and rate limiters. Parsing it as a number yields NaN; passing NaN to setTimeout schedules it for the next tick, which means a rate-limited client hammers the endpoint at full speed. The parseRetryAfter implementation above handles both forms and clamps with retryAfterCapMs so a six-hour instruction does not silently wedge the upload.

Verification

Start with the deterministic part. Inject the random source and the schedule becomes a pure function you can assert on:

import { describe, expect, it, vi } from 'vitest';
import { backoffDelay, DEFAULT_BACKOFF, parseRetryAfter } from './backoff.js';
import { DEFAULT_POLICY, withRetry } from './retry.js';
import { HttpError } from './classify.js';

describe('backoffDelay', () => {
  it('doubles the ceiling and respects the cap', () => {
    const alwaysMax = () => 0.999_999;
    const ceilings = [1, 2, 3, 4, 5, 6].map((n) => backoffDelay(n, DEFAULT_BACKOFF, alwaysMax));
    expect(ceilings).toEqual([500, 1000, 2000, 4000, 8000, 15_000]);
  });

  it('can return a near-zero delay under full jitter', () => {
    expect(backoffDelay(4, DEFAULT_BACKOFF, () => 0)).toBe(0);
  });

  it('keeps equal jitter in the upper half of the window', () => {
    const config = { ...DEFAULT_BACKOFF, jitter: 'equal' as const };
    expect(backoffDelay(3, config, () => 0)).toBe(1000);
    expect(backoffDelay(3, config, () => 1)).toBe(2000);
  });
});

describe('parseRetryAfter', () => {
  it('reads delta-seconds', () => {
    expect(parseRetryAfter('30')).toBe(30_000);
  });

  it('reads an HTTP-date relative to now', () => {
    const now = Date.parse('2026-07-26T10:00:00Z');
    expect(parseRetryAfter('Sun, 26 Jul 2026 10:00:45 GMT', now)).toBe(45_000);
  });

  it('returns null for junk instead of NaN', () => {
    expect(parseRetryAfter('soon')).toBeNull();
  });
});

describe('withRetry timing', () => {
  it('stops once the elapsed budget would be exceeded', async () => {
    vi.useFakeTimers();
    const attempts = vi.fn(async () => {
      throw new HttpError(503, '', null);
    });
    const promise = withRetry(attempts, 'key-1', {
      ...DEFAULT_POLICY,
      jitter: 'none',
      maxElapsedMs: 3_000,
    }).catch((error: Error) => error.message);
    await vi.runAllTimersAsync();
    expect(await promise).toContain('gave up after');
    expect(attempts.mock.calls.length).toBeLessThan(DEFAULT_POLICY.maxAttempts);
    vi.useRealTimers();
  });
});

Then prove the behaviour in a real browser, where the interesting failures live:

  1. Slow link. DevTools → Network → Throttling → Add custom profile at 50 kb/s up. A 5 MB chunk needs about 13 minutes at that rate, so your total deadline should fire and the retry should be classified transfer timeout — not network error.
  2. Black hole. DevTools → Network → Request blocking, pattern */api/uploads. Chrome fails these instantly with net::ERR_BLOCKED_BY_CLIENT, which exercises the TypeError branch and lets you watch the full backoff schedule run in the console in under 20 seconds.
  3. Mid-flight disconnect. Start a large upload, then toggle Network → Offline while the body is transferring. Confirm you get one TypeError per attempt, that the sleeps happen, and that flipping back online lets the next attempt succeed — the recovery path that resuming uploads after network loss builds on.
  4. Idempotency, server-side. Prove that a repeat is genuinely free before you trust the retry loop with real data:
KEY=$(uuidgen)
for i in 1 2 3; do
  curl -sS -o /dev/null -w '%{http_code} %{time_total}s\n' \
    -X POST http://localhost:3000/api/uploads \
    -H "Idempotency-Key: $KEY" \
    -F "file=@./fixtures/sample-25mb.bin"
done

All three calls must return the same status and the same objectKey, and your storage bucket must contain exactly one object afterwards. If the second call is dramatically faster than the first, the server is replaying a cached receipt — which is exactly what you want.

  1. Breaker behaviour. Point the endpoint at a port with nothing listening, run six uploads, and confirm the sixth fails with UploadCircuitOpenError in under a millisecond rather than working through another backoff schedule.

Frequently Asked Questions

Why not just use AbortSignal.timeout() and skip the wrapper?

AbortSignal.timeout() is the right primitive but it only gives you one deadline for the whole attempt, and the reason it aborts with is a generic TimeoutError that cannot tell you whether the connection never opened or the response body stalled halfway. The wrapper adds a second phase boundary and a typed error carrying that phase, which is what makes the classification step in aborting uploads with AbortController and timeouts able to log something actionable.

Should the client deadline be shorter or longer than my gateway’s?

Longer, by 10–20%, whenever there is a gateway that will answer with a real status code. A 504 from nginx tells you the origin was too slow; an abort at 45 s tells you nothing and looks identical to a dead Wi-Fi link in your error tracker. The exception is a direct-to-storage PUT, where no gateway exists and the client deadline is the only one in the system.

Is retrying a TypeError: Failed to fetch safe when it might be a CORS bug?

It is safe but wasteful. The browser deliberately hides the distinction, so every upload against a misconfigured origin will burn its full budget before failing. Bound maxElapsedMs tightly enough that the wasted time is a few seconds, and treat a spike in network error classifications with zero successes as a configuration alarm rather than a connectivity one.

How many attempts is the right number?

Five, with full jitter and a 15 s cap, recovers from essentially every transient fault worth recovering from — the marginal success rate of attempts six through ten is close to zero and the marginal load on a struggling backend is not. If you need more resilience than that, add resumability so a later session can continue, rather than more attempts inside one session.

Do chunked uploads need a different policy?

Yes, in one specific way: the retry budget must be shared across chunks rather than applied per chunk, or a job with twenty parts multiplies your worst-case request volume by twenty. Keep the per-chunk deadline proportional to the chunk size, keep one elapsed-time budget for the whole job, and pause the entire job when the circuit opens.