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
XMLHttpRequestin 2026:fetch()has no upload progress event, and its streaming alternative in tracking upload progress with a TransformStream requires HTTP/2 andduplex: '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.timeoutlives in the network stack, so it fires on schedule even when Chrome throttlessetTimeoutto once per minute. If you are starting fresh and do not need progress, use theAbortSignalapproach in aborting uploads with AbortController and timeouts instead.
Prerequisites
- Any evergreen browser.
xhr.timeoutandxhr.uploadhave been universally supported since IE10; nothing here needs a polyfill. - TypeScript with
"lib": ["DOM", "ES2022"], or plain ESM if you delete the type annotations. - An upload endpoint you can point at, plus
Access-Control-Expose-Headers: ETagin its CORS response if you upload cross-origin and want to read the storage ETag back. - 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.
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.
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.loadend — loadend 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.
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.
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:
- Response-phase deadline.
xhrUpload('http://127.0.0.1:3000/u?thinkMs=90000', file, { responseWindowMs: 5000 })must reject withphase: 'response'roughly five seconds after progress hits 100% — not after 90 seconds. - 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 withphase: 'upload'. Progress will freeze somewhere past 1 MB — the extra megabytes are the send buffer from the second diagram. - 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.