Fixing XMLHttpRequest Timeout Errors for Large File Uploads

Stop treating xhr.timeout as a fixed number: arm it before send(), then push it forward on every xhr.upload.progress event so it becomes an idle deadline, and re-arm it with a separate response budget the moment the last byte leaves the browser.

Almost every XHR timeout bug on a large upload comes from one of two mistakes. Either xhr.timeout was never set — it defaults to 0, which means never — or it was set to a single number that is simultaneously too short for a 900 MB file on hotel Wi-Fi and too long for a socket that died three minutes ago. This page fixes both, and shows how to tell the browser’s own deadline apart from the gateway deadlines described in browser timeout and retry logic.

When to use this approach

  • You need byte-level upload progress. This is still the only reason to reach for XMLHttpRequest in 2026: fetch() has no upload progress event, and its streaming alternative in tracking upload progress with a TransformStream requires HTTP/2 and duplex: 'half'.
  • You are debugging an existing XHR uploader and cannot rewrite it this sprint. Everything below is additive — no API change for callers.
  • You want deadlines that keep running in a backgrounded tab. xhr.timeout lives in the network stack, so it fires on schedule even when Chrome throttles setTimeout to once per minute. If you are starting fresh and do not need progress, use the AbortSignal approach in aborting uploads with AbortController and timeouts instead.

Prerequisites

  1. Any evergreen browser. xhr.timeout and xhr.upload have been universally supported since IE10; nothing here needs a polyfill.
  2. TypeScript with "lib": ["DOM", "ES2022"], or plain ESM if you delete the type annotations.
  3. An upload endpoint you can point at, plus Access-Control-Expose-Headers: ETag in its CORS response if you upload cross-origin and want to read the storage ETag back.
  4. Node 20+ if you want to run the stall server in the verification section.

What xhr.timeout actually measures

The timer starts when you call send() and it does not pause. It spans DNS resolution, the TCP handshake, the TLS handshake, any CORS preflight, the entire request body upload, however long the server thinks about it, and the download of the response body. One number covers all of it.

That is the root problem. The upload phase scales with file size and bandwidth; the response phase scales with what your backend does after the bytes land — a virus scan, a checksum, a transcode enqueue. A single deadline that is generous enough for a 400 MB body on a 3G link is far too generous for the two seconds your API should take to reply.

XMLHttpRequest event order across one upload A timeline from send to load. Upload progress events fire repeatedly while bytes move, then stop. A silent gap follows while the server processes the body, and only then does the download progress and load event arrive. The xhr.timeout window spans the entire timeline including the silent gap. One deadline, two very different phases send() upload.progress ×N progress loadstart upload.load load server processing — no events fire xhr.timeout covers this whole span, silence included Re-arm it at upload.load and the two phases get separate budgets
The silent stretch between the last upload event and the first response event is invisible to progress handlers but fully inside the timeout window.

The fix is a property of the spec that almost nobody uses: the timeout setter has no state restriction. You may assign it after send(), and Blink and Gecko restart the pending timer against the new value relative to the original send time. So xhr.timeout = elapsed + 20000 on every progress event converts a total deadline into a sliding idle deadline, with no JavaScript timer to be throttled.

Why the progress bar reaches 100% before the server has your file

xhr.upload.progress reports bytes handed to the network layer, not bytes acknowledged by the server. On a fast link the kernel send buffer swallows the tail of the file instantly — Linux autotunes net.ipv4.tcp_wmem up to 4 MB by default — so loaded === total fires while megabytes are still in flight and the origin has written nothing.

Reported progress versus bytes the server has read Two bars for the same moment in a 40 megabyte upload. The upper bar, driven by upload progress events, is completely full. The lower bar shows the server has read only 37.7 megabytes; the remaining 3.4 megabytes sit in the kernel send buffer and in flight. Same instant, two views of a 40 MB upload What xhr.upload.progress reports loaded 41943040 of total 41943040 — the UI says done true server position What the origin has actually read 37.7 MB written and acknowledged 3.4 MB The gap is the kernel send buffer plus packets in flight — commonly 1 to 4 MB. A stalled origin can therefore freeze at exactly 100% with no further events. Success is readyState 4 with a 2xx status — never a full progress bar.
Because the last few megabytes are buffered, a progress-driven idle clock goes blind exactly when the response phase begins — which is why that phase needs its own budget.

This also explains the classic support ticket: “it sticks at 100% and then fails”. The upload finished, the server is chewing, and the single deadline expired during the silence. Showing users an honest state here is the job of accurate time-remaining estimates; surviving it is the job of the code below.

Implementation

One function, no dependencies. It arms an idle deadline from progress events and swaps in a response budget at upload.load, while a hard ceiling stops a maliciously slow trickle from keeping the request alive forever.

export interface XhrUploadOptions {
  /** No progress for this long aborts the attempt. Default 20 s. */
  stallWindowMs?: number;
  /** Budget for the server after the last byte leaves the browser. Default 60 s. */
  responseWindowMs?: number;
  /** Absolute ceiling for the whole attempt, whatever progress says. Default 30 min. */
  hardCeilingMs?: number;
  headers?: Record<string, string>;
  withCredentials?: boolean;
  signal?: AbortSignal;
  onProgress?: (loaded: number, total: number) => void;
}

export type UploadPhase = 'connect' | 'upload' | 'response';

export class XhrUploadError extends Error {
  constructor(
    message: string,
    readonly phase: UploadPhase,
    readonly kind: 'timeout' | 'network' | 'abort' | 'http',
    readonly status: number,
  ) {
    super(message);
    this.name = 'XhrUploadError';
  }
}

export function xhrUpload(
  url: string,
  body: Blob | FormData,
  options: XhrUploadOptions = {},
): Promise<{ status: number; body: string; etag: string | null }> {
  const stallWindowMs = options.stallWindowMs ?? 20_000;
  const responseWindowMs = options.responseWindowMs ?? 60_000;
  const hardCeilingMs = options.hardCeilingMs ?? 30 * 60_000;

  return new Promise((resolve, reject) => {
    if (options.signal?.aborted) {
      reject(new XhrUploadError('aborted before send', 'connect', 'abort', 0));
      return;
    }

    const xhr = new XMLHttpRequest();
    const startedAt = performance.now();
    let phase: UploadPhase = 'connect';

    // Slide the deadline forward without a JS timer. The spec places no state
    // restriction on the timeout setter, and Blink and Gecko restart the pending
    // timer against the new value the moment it is assigned in flight.
    const extend = (windowMs: number) => {
      const elapsed = performance.now() - startedAt;
      xhr.timeout = Math.min(Math.round(elapsed + windowMs), hardCeilingMs);
    };

    xhr.open('PUT', url, true); // async: the timeout setter throws on sync XHR
    xhr.responseType = 'text';
    xhr.withCredentials = options.withCredentials ?? false;
    for (const [name, value] of Object.entries(options.headers ?? {})) {
      xhr.setRequestHeader(name, value); // legal only between open() and send()
    }
    extend(stallWindowMs); // covers DNS, TLS and any preflight

    xhr.upload.addEventListener('progress', (event: ProgressEvent) => {
      phase = 'upload';
      extend(stallWindowMs); // bytes moved, so push the deadline out
      if (event.lengthComputable) options.onProgress?.(event.loaded, event.total);
    });

    // Fires only on a clean end of the request body — unlike upload.loadend,
    // which also fires after a timeout or an abort.
    xhr.upload.addEventListener('load', () => {
      phase = 'response';
      extend(responseWindowMs);
    });

    xhr.addEventListener('progress', () => extend(responseWindowMs));

    xhr.addEventListener('load', () => {
      if (xhr.status >= 200 && xhr.status < 300) {
        resolve({
          status: xhr.status,
          body: xhr.responseText,
          etag: xhr.getResponseHeader('ETag'),
        });
      } else {
        reject(new XhrUploadError(
          `HTTP ${xhr.status} ${xhr.statusText}`, phase, 'http', xhr.status,
        ));
      }
    });

    xhr.addEventListener('timeout', () => {
      const elapsed = Math.round(performance.now() - startedAt);
      const window = phase === 'response' ? responseWindowMs : stallWindowMs;
      reject(new XhrUploadError(
        `silent for ${window} ms in the ${phase} phase (${elapsed} ms elapsed)`,
        phase, 'timeout', 0,
      ));
    });

    xhr.addEventListener('error', () => {
      reject(new XhrUploadError(
        'transport failure — DNS, TLS, CORS or a reset socket', phase, 'network', 0,
      ));
    });

    xhr.addEventListener('abort', () => {
      reject(new XhrUploadError('aborted by the caller', phase, 'abort', 0));
    });

    options.signal?.addEventListener('abort', () => xhr.abort(), { once: true });
    xhr.send(body);
  });
}

Walking the critical lines

xhr.open(method, url, true) — the third argument must be true. On a synchronous request from a document, assigning timeout throws immediately, and you get the exception rather than the upload.

extend(stallWindowMs) before send() — this is the connect-phase guard. If DNS hangs or the TLS handshake never completes, no progress event ever fires, and this is the only deadline standing between your user and an infinite spinner.

Math.min(..., hardCeilingMs) — once elapsed time passes the ceiling, extend() assigns a timeout value smaller than the elapsed time and Blink fires timeout on the next tick. That is the intended behaviour: a connection dribbling 40 bytes a second would otherwise reset the idle clock forever.

xhr.upload.load rather than upload.loadendloadend fires after a timeout and after an abort too, so using it would relabel a failed upload as a response-phase failure and re-arm a deadline on a dead request.

phase in the error — this single field is what turns your error tracker from “uploads fail sometimes” into “uploads fail in the response phase against eu-west-1 at 14:05”. Feed it into the classifier and backoff schedule described in implementing exponential backoff for failed chunks rather than reimplementing a retry loop here.

Retrying means constructing a new XMLHttpRequest. Calling open() again on an aborted instance is legal, but your upload listeners survive and will fire twice per event. When you retry a body that the server may already have partially stored, pair it with the deduplication described in retrying fetch uploads with idempotency keys — the mechanism is identical for XHR.

Configuration reference

Key Type Default Effect
xhr.timeout number (ms) 0 0 means never. The timer runs from send() and spans preflight, upload, server think time and response download.
stallWindowMs number 20000 Silence tolerated between upload progress events. Below ~10 s you will abort healthy uploads on lossy mobile links.
responseWindowMs number 60000 Server budget after upload.load. Set it 10–20% above your gateway’s timeout so its 504 arrives instead of an untyped abort.
hardCeilingMs number 1800000 Absolute cap. Size it as bytes ÷ floor bandwidth × 2; at a 250 KB/s floor a 400 MB file needs ~27 min.
xhr.withCredentials boolean false Sends cookies. Forces the preflight to answer Access-Control-Allow-Credentials: true and forbids a wildcard origin.
xhr.responseType string '' 'text' keeps responseText readable; 'json' makes responseText throw, which hides error bodies from your logs.
xhr.upload.onprogress handler Throttled by the UA to roughly one event per 50 ms, so it is a liveness signal, not a byte-accurate meter.

Which hop dropped the connection

Before you change a single number, work out whose deadline fired. Three independent parties can end the request, and only one of them is xhr.timeout.

Three deadlines on one upload path The browser, the reverse proxy and the origin application each enforce their own deadline. The browser produces status zero with no headers, the proxy produces a 504 or 524 with an HTML body, and the origin produces a 5xx or a reset connection. Whose deadline fired? Browser xhr.timeout (default 0) fires: ontimeout status 0, no headers nginx / Cloudflare proxy_read_timeout 60s fires: 504 or 524 status 504, HTML body Origin app server.requestTimeout fires: 5xx or reset status 500 or status 0 The shortest deadline wins — and only the middle one explains itself. Keep the client budget 10–20% above the gateway so the 504 reaches your logs. A direct-to-storage PUT has no middle box: your deadline is the only one.
Raising a single threshold in isolation usually just moves the failure to the next hop; align the three, or remove the middle one entirely.

Removing the middle box is often the cheapest fix available. Uploading straight to object storage with presigned URLs generated by AWS SDK v3 deletes both the proxy and the origin deadline from the path. If you must keep the proxy, raising Nginx and Cloudflare upload size limits covers the body-size knobs that sit next to the timeout knobs in the same config block.

Telling the three zero-status endings apart

xhr.status === 0 at readyState 4 is not one condition. It is three, and the response object looks identical in all of them — only the event that fired tells you which happened.

Decision tree for a zero status XMLHttpRequest From a done request with status zero, three branches: the timeout event means the deadline elapsed, the error event means a transport or CORS failure, and the abort event means the caller cancelled. Each branch names the appropriate action. Three endings, one status code readyState 4 status 0, empty response timeout event fired error event fired abort event fired Your deadline elapsed. Widen the window, or slice the file smaller. DNS, TLS, CORS or a reset socket. Retry once, then read the console. Your abort() or a page navigation. Never retry this one. Branch on the event, never on the status code — all three report zero.
Code that inspects only `xhr.status` cannot distinguish a user cancel from a CORS misconfiguration, and will happily retry both.

Configuration gotchas

Timeouts cannot be set for synchronous requests

Chrome throws Uncaught DOMException: Failed to set the 'timeout' property on 'XMLHttpRequest': Timeouts cannot be set for synchronous requests made from a document. Firefox words it as InvalidAccessError: synchronous XMLHttpRequests do not support timeout and responseType. The cause is always a third argument of false on open(), or an omitted one in old code where a linter rewrote the call. Pass true.

net::ERR_CONNECTION_TIMED_OUT is not your timeout

This one fires the error event, not timeout, and it comes from Chrome’s transport layer giving up on a TCP handshake that was never answered — a security group, a firewall drop, or a DNS record pointing at a dead address. Raising xhr.timeout changes nothing. Its neighbours are equally specific: net::ERR_CONNECTION_RESET means the peer sent an RST mid-body, and net::ERR_EMPTY_RESPONSE almost always means the origin closed the connection because your body exceeded a size limit, which surfaces server-side as the errors covered in handling 413 and 507 errors during uploads.

A 504 arrives before ontimeout ever runs

If nginx logs upstream timed out (110: Connection timed out) while reading response header from upstream, its proxy_read_timeout (60 s by default) beat your client. Cloudflare does the same at 100 s with a 524 HTML page. That is the good outcome — you get a status code and a request ID. Keep responseWindowMs above the gateway value deliberately so this signal survives; shortening the client budget below it throws away the only diagnosis in the chain.

Six sockets per origin, and abandoned XHRs hold theirs

Chrome allows six concurrent HTTP/1.1 connections per origin. An XMLHttpRequest you stopped listening to still owns its socket until it completes or times out, so a component that unmounts without calling abort() leaks one connection per upload. The symptom is not an error: the seventh upload sits in readyState 1 with no progress events, and the DevTools Timing tab shows minutes of Stalled. Always abort on unmount, and note that this cap is per origin — HTTP/2 to the same host removes it entirely.

Non-safelisted headers spend the budget before any bytes move

Adding Content-Range or X-Upload-Id via setRequestHeader() makes the request non-simple, so the browser sends an OPTIONS preflight first. That round trip happens inside the timeout window, and on a cold connection to a distant region it can cost 300–600 ms before the first byte of the body is sent. If the preflight itself fails you get an error event with status 0 and a console message beginning Access to XMLHttpRequest at ... has been blocked by CORS policy — see fixing CORS preflight errors on S3 uploads.

xhr.timeout = 0 is not “use the browser default”

There is no browser default. Zero means the request waits indefinitely, and the only thing that will eventually kill it is the operating system’s TCP keepalive — on Linux that is tcp_keepalive_time at 7200 seconds plus nine probes. Users see a spinner for two hours. If you take one line from this page, make it an explicit non-zero timeout on every XHR you own.

Verification

Run a server that misbehaves on demand. This one stops reading the body after N bytes, or delays its response, so you can prove each phase deadline independently.

import { createServer } from 'node:http';

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const server = createServer(async (req, res) => {
  res.setHeader('Access-Control-Allow-Origin', '*');
  res.setHeader('Access-Control-Allow-Methods', 'PUT,OPTIONS');
  res.setHeader('Access-Control-Allow-Headers', 'content-type,x-upload-id');
  res.setHeader('Access-Control-Expose-Headers', 'ETag');
  if (req.method === 'OPTIONS') {
    res.writeHead(204);
    res.end();
    return;
  }

  const params = new URL(req.url, 'http://127.0.0.1').searchParams;
  const stallAfter = Number(params.get('stallAfter') ?? '0');
  const thinkMs = Number(params.get('thinkMs') ?? '0');
  let received = 0;

  await new Promise((resolve) => {
    req.on('data', (chunk) => {
      received += chunk.length;
      // Stop draining: the TCP window closes and the browser's send buffer fills.
      if (stallAfter > 0 && received >= stallAfter) req.pause();
    });
    req.on('end', resolve);
    req.on('close', resolve);
  });

  if (req.destroyed) {
    console.log(`client gave up after ${received} bytes`);
    return;
  }
  await sleep(thinkMs);
  res.writeHead(200, { ETag: '"stall-server"', 'Content-Type': 'application/json' });
  res.end(JSON.stringify({ received }));
});

server.listen(3000, () => console.log('stall server on http://127.0.0.1:3000'));

Then run three checks from the page console, each of which must fail in a named way:

  1. Response-phase deadline. xhrUpload('http://127.0.0.1:3000/u?thinkMs=90000', file, { responseWindowMs: 5000 }) must reject with phase: 'response' roughly five seconds after progress hits 100% — not after 90 seconds.
  2. Upload-phase stall. xhrUpload('http://127.0.0.1:3000/u?stallAfter=1048576', file, { stallWindowMs: 8000 }) with a file of at least 32 MB must reject with phase: 'upload'. Progress will freeze somewhere past 1 MB — the extra megabytes are the send buffer from the second diagram.
  3. Background-tab survival. Start check 2, switch to another tab immediately, and come back after a minute. The rejection timestamp must still be about eight seconds after the stall. A setTimeout-driven abort would have drifted to the next throttled tick.

Confirm the server itself is healthy with curl before blaming the browser:

head -c 33554432 /dev/urandom > /tmp/sample-32mb.bin
curl -sS -X PUT --data-binary @/tmp/sample-32mb.bin \
  -o /dev/null -w 'status=%{http_code} upload=%{time_pretransfer}s total=%{time_total}s\n' \
  'http://127.0.0.1:3000/u?thinkMs=2000'

Finally, prove the sliding deadline actually slides in your target browsers — it is the one behaviour here that rests on implementation detail rather than a MUST in the spec:

const xhr = new XMLHttpRequest();
xhr.open('PUT', 'http://127.0.0.1:3000/u?thinkMs=6000', true);
xhr.timeout = 2000;
xhr.addEventListener('load', () => console.log('extended past the original 2 s deadline'));
xhr.addEventListener('timeout', () => console.error('this engine ignores in-flight timeout changes'));
xhr.send(new Blob([new Uint8Array(1024)]));
setTimeout(() => { xhr.timeout = 20000; }, 1000);

If you see the load line, in-flight extension works and the uploader above is safe. If you see the error line, keep the same structure but drive the abort from a setTimeout you clear on each progress event, accepting the background-tab drift.

Frequently Asked Questions

Does xhr.timeout include the CORS preflight?

Yes. The timer starts at send(), and the OPTIONS round trip happens before the request body is even queued, so a slow preflight eats your connect-phase budget. On a cross-region endpoint that is 300–600 ms of your window gone before a single byte of the file moves.

Why does ontimeout never fire even though the upload obviously hung?

Two causes account for nearly all of it. Either xhr.timeout was left at its default of 0, which disables the deadline entirely, or the failure was a transport error such as net::ERR_CONNECTION_TIMED_OUT, which dispatches error rather than timeout. Attach handlers for timeout, error and abort separately and you will never be in doubt again.

Is chunking a better fix than raising the timeout?

Usually, yes. Splitting the file with Blob.slice turns one 30-minute request into forty 45-second requests, each of which can fail and be retried in isolation. The deadline stops depending on file size, which means one honest number works for every user on every connection.

Should I call xhr.abort() inside ontimeout?

No. The user agent has already terminated the request and set readyState to 4 before dispatching timeout, so abort() there is a no-op that adds noise to stack traces. The one place you do need it is your own cancel path and component teardown, where the socket really is still open.

Does a timed-out upload leave rubbish in my bucket?

A single PUT that never completed stores nothing — S3 only materialises an object on a complete body. A timed-out part of a multipart upload does persist and is billed until you clean it up, which is what expiring incomplete multipart uploads automatically exists to handle.