Aborting Uploads with AbortController and Timeouts

Give every upload attempt its own AbortController, cancel it on a stall clock that progress events keep resetting rather than on a total-duration deadline, and abort with a reason object so the catch block can tell a user cancel from a timeout.

Cancellation is the part of browser timeout and retry logic that most teams get half right: the Cancel button works, and then a 900 MB upload on a hotel Wi-Fi connection dies at 78% because someone hard-coded a 120-second deadline. This article covers the wiring that separates those two cases, and it assumes you already know the request-shaping basics from upload fundamentals and browser APIs.

When to use this approach

  • You upload files large enough that no single wall-clock deadline is correct for every user — anything over roughly 20 MB on mixed mobile and desktop networks.
  • You need a Cancel button that actually closes the socket, not one that hides a spinner while the bytes keep flowing.
  • You retry failed attempts and need the retry loop to distinguish “the user gave up” (stop) from “the connection went quiet” (back off and try again). If you only ever send small payloads on a stable link, a bare AbortSignal.timeout(30_000) is enough and the rest of this page is over-engineering.

Prerequisites

  1. An evergreen browser or Node 20.3+. AbortSignal.any() landed in Chrome 116, Firefox 124, Safari 17.4 and Node 20.3; signal.throwIfAborted() and signal.reason are older and universally available.
  2. TypeScript with "lib": ["DOM", "ES2022"], or plain ESM if you strip the annotations.
  3. An upload endpoint that returns per-part URLs, typically from S3 presigned URL workflows, plus ExposeHeaders: ["ETag"] in the bucket CORS rules.
  4. A server route (or direct SDK access) that can issue AbortMultipartUpload, because the browser cannot clean up after itself.

Total duration is the wrong clock

A total-duration timeout answers the question “has this taken too long?” The question you actually care about is “has anything happened recently?” Those diverge the moment file size and bandwidth vary between users. A 900 MB file at 8 Mbit/s needs fifteen minutes of healthy transfer; the same code path on office fibre finishes in ninety seconds. Any fixed cap that survives the slow user is so generous it lets a dead socket hang for a quarter of an hour before the UI reacts.

A stall clock inverts the test. Arm a timer for 20 seconds; every time bytes move, clear it and arm it again. A healthy upload never lets the timer expire, no matter how long it runs. A TCP connection that black-holes after a Wi-Fi handover trips it 20 seconds later, deterministically, on both the fibre user and the 3G user.

Total-duration deadline versus an idle stall clock Two timelines for the same large upload. The top lane uses a 120 second total deadline and cuts off a healthy transfer at 78 percent. The bottom lane resets a 20 second idle timer on every progress event and only aborts after the connection goes silent. Same 900 MB upload, two deadline models Total duration ≤ 120 s bytes still flowing — 78% done cut off at 120 s Idle stall ≤ 20 s progress event every 2 s resets the clock socket silent abort: stalled A stall clock forgives a slow link; a total-duration clock punishes it.
The stall clock only fires when nothing has moved, so the same 20-second threshold is correct on fibre and on 3G.

Keep a total cap as well — just set it as a circuit breaker rather than a deadline. Thirty minutes is a reasonable ceiling for a browser tab; past that you want the resumable upload flow to take over rather than a single long-lived request.

Implementation

One factory owns all three cancellation sources for a single attempt: the user, the stall clock, and the hard cap. It hands back a signal to pass to fetch and a progress() function to call whenever bytes move.

export type AbortKind = "user" | "stall" | "cap";

/** A DOMException so generic library code still sees err.name, plus our own tag. */
function abortReason(kind: AbortKind, message: string) {
  const name = kind === "user" ? "AbortError" : "TimeoutError";
  return Object.assign(new DOMException(message, name), { kind });
}

/** AbortSignal.any() with a fallback for Safari < 17.4 and Node < 20.3. */
function anySignal(signals: AbortSignal[]): AbortSignal {
  if (typeof AbortSignal.any === "function") return AbortSignal.any(signals);
  const merged = new AbortController();
  for (const s of signals) {
    if (s.aborted) {
      merged.abort(s.reason);
      break;
    }
    // { signal } auto-removes the listener once `merged` aborts — no manual cleanup.
    s.addEventListener("abort", () => merged.abort(s.reason), {
      once: true,
      signal: merged.signal,
    });
  }
  return merged.signal;
}

export interface UploadAbort {
  signal: AbortSignal;
  progress: () => void;
  cancel: () => void;
  dispose: () => void;
}

/** Build one of these PER ATTEMPT. Never reuse it across a retry. */
export function createUploadAbort(stallMs = 20_000, hardCapMs = 30 * 60_000): UploadAbort {
  const startedAt = performance.now();
  const user = new AbortController();
  const stall = new AbortController();
  let timer: ReturnType<typeof setTimeout> | undefined;

  const arm = () => {
    clearTimeout(timer);
    timer = setTimeout(() => {
      stall.abort(abortReason("stall", `no upload progress for ${stallMs} ms`));
    }, stallMs);
  };

  const signal = anySignal([user.signal, stall.signal, AbortSignal.timeout(hardCapMs)]);
  signal.addEventListener("abort", () => clearTimeout(timer), { once: true });
  arm();

  return {
    signal,
    progress: () => {
      if (!signal.aborted) arm();
    },
    cancel: () => {
      const ms = Math.round(performance.now() - startedAt);
      user.abort(abortReason("user", `cancelled by user after ${ms} ms`));
    },
    dispose: () => clearTimeout(timer),
  };
}

/** Upload presigned parts sequentially; a finished part counts as a progress tick. */
export async function uploadParts(file: File, partUrls: string[], ctl: UploadAbort) {
  const partSize = Math.ceil(file.size / partUrls.length);
  const etags: string[] = [];
  try {
    for (let i = 0; i < partUrls.length; i++) {
      ctl.signal.throwIfAborted();
      const start = i * partSize;
      const body = file.slice(start, Math.min(start + partSize, file.size));
      const res = await fetch(partUrls[i], { method: "PUT", body, signal: ctl.signal });
      if (!res.ok) throw new Error(`part ${i + 1}: HTTP ${res.status}`);
      const etag = res.headers.get("ETag");
      if (!etag) throw new Error(`part ${i + 1}: ETag not exposed by bucket CORS`);
      etags.push(etag);
      ctl.progress();
    }
    return etags;
  } finally {
    ctl.dispose();
  }
}

Line-by-line on the parts that matter

  • abortReason() builds a real DOMException rather than a plain object. Anything downstream that only knows the standard contract still reads err.name === "AbortError", while your own code reads err.kind. A plain { kind: "stall" } works as an abort reason too — the platform accepts any value — but it breaks every library that checks err.name.
  • anySignal() merges the three sources into one signal that you pass to fetch exactly once. AbortSignal.any() propagates the reason of whichever source fired first, which is the whole mechanism this page relies on. The fallback branch reproduces that behaviour and uses addEventListener(..., { signal: merged.signal }) so the listeners unregister themselves.
  • AbortSignal.timeout(hardCapMs) is the only source here that is not a controller, because nothing ever needs to reset it. Its reason is a browser-generated TimeoutError with no kind property — that absence is how you recognise the hard cap in the catch block.
  • arm() runs after the signal is built, so if the composite signal is already aborted the abort listener clears the timer immediately and you do not leak a 30-minute setTimeout into a closed tab.
  • progress() guards on signal.aborted so a late-arriving progress event cannot re-arm the timer after cancellation and resurrect the watchdog.
  • ctl.signal.throwIfAborted() at the top of each loop iteration is what makes a Cancel between parts instant. Without it the loop would fire off part 12 and let fetch reject it, which costs a round trip of TLS setup on some browsers.
  • file.slice(start, end) is the same zero-copy byte-range slicing used everywhere else; slicing does not read the file, so an abort leaves nothing to garbage-collect except the Blob handle.
  • finally { ctl.dispose() } clears the pending timer on the success path too. Forget it and a completed upload keeps a timer alive for up to 20 seconds, which shows up as a phantom abort if you reuse the kit.

Feeding the stall clock from real progress events

fetch still reports nothing about request-body progress unless you send a ReadableStream body with duplex: "half", which is HTTP/2-only and unsupported in Firefox. So the tick above is coarse: one per completed part. With 8 MB parts on a 1 Mbit/s uplink a part takes 64 seconds, and a 20-second stall clock would abort a perfectly healthy transfer.

Two fixes, and you usually want both. Size parts so that the slowest link you support finishes one inside the stall window, and use XMLHttpRequest where you need byte-level ticks — its upload.progress event fires roughly every 50 ms while the request body is being written. Bridging XHR to the same AbortSignal keeps one cancellation model across both transports.

export function putWithProgress(url: string, body: Blob, ctl: UploadAbort): Promise<string> {
  return new Promise((resolve, reject) => {
    if (ctl.signal.aborted) return reject(ctl.signal.reason);

    const xhr = new XMLHttpRequest();
    xhr.open("PUT", url, true);
    xhr.upload.addEventListener("progress", (e) => {
      if (e.lengthComputable) ctl.progress(); // every ~50 ms while bytes leave the socket
    });

    const onAbort = () => xhr.abort();
    ctl.signal.addEventListener("abort", onAbort, { once: true });
    xhr.addEventListener("loadend", () => ctl.signal.removeEventListener("abort", onAbort));

    // xhr.abort() fires this; re-throw the SAME reason fetch would have thrown.
    xhr.addEventListener("abort", () => reject(ctl.signal.reason));
    xhr.addEventListener("error", () => reject(new Error("network error")));
    xhr.addEventListener("load", () => {
      if (xhr.status >= 200 && xhr.status < 300) resolve(xhr.getResponseHeader("ETag") ?? "");
      else reject(new Error(`HTTP ${xhr.status}`));
    });

    xhr.send(body);
  });
}

The loadend listener removing onAbort matters more than it looks: if the kit outlives the request — and it does, because it spans every part — each completed part would otherwise leave a dead listener attached to the signal. Two hundred parts, two hundred listeners, and Chrome logs a MaxListenersExceededWarning equivalent only in Node, so in the browser it just silently grows.

Stall watchdog lifecycle A progress event calls kick, which clears and re-arms a twenty second timer. If the next tick arrives first the loop repeats; if the timer fires first the controller aborts with a stall reason. Stall watchdog lifecycle upload.progress e.loaded increased progress() clearTimeout, re-arm timer armed 20 000 ms next tick beats the deadline 20 s of silence abort(stall reason) Only moving bytes keep the attempt alive. Elapsed time never triggers the abort.
Every progress event cancels the pending timer and starts a new one; only silence reaches the abort branch.

Telling a cancel from a timeout in the catch block

fetch rejects with the signal’s reason, not with a generic error — that has been the spec behaviour since 2021 and is implemented everywhere. So the value you catch is exactly the object you passed to abort(), which is why building a structured reason pays off.

export function classifyAbort(err: unknown): AbortKind | "error" {
  if (typeof err !== "object" || err === null) return "error";
  const e = err as { name?: string; kind?: AbortKind };
  if (e.kind) return e.kind;                       // our own reason survived AbortSignal.any
  if (e.name === "TimeoutError") return "cap";     // AbortSignal.timeout(), no kind attached
  if (e.name === "AbortError") return "user";      // a signal someone else created
  return "error";
}

try {
  await uploadParts(file, partUrls, ctl);
} catch (err) {
  switch (classifyAbort(err)) {
    case "user":
      break;                                        // silent: no toast, no error metric
    case "stall":
      await retryWithBackoff();                     // new controller, resume from last ETag
      break;
    case "cap":
      showResumeLater();                            // 30 minutes gone; persist and offer resume
      break;
    default:
      reportError(err);                             // 4xx/5xx or a genuine network failure
  }
}
Classifying a rejected upload promise A decision tree from the catch block into three branches: a user cancel that is silent, a stall that retries with backoff, and a hard cap that offers a resume later. What rejected this upload? catch (err) kind === "user" name: AbortError Cancel button pressed stay silent, no retry kind === "stall" name: TimeoutError socket went quiet retry with backoff no kind property name: TimeoutError 30 min hard cap persist, offer resume Match on name and your own tag — never on the message string.
Three abort sources, three different responses — and only one of them deserves an error metric.

The ordering in classifyAbort is deliberate. Check kind first, because AbortSignal.any() forwards the original reason untouched and your tag is the most specific information available. Only fall back to name for reasons you did not construct. Counting user cancels as failures is how upload dashboards end up reporting a 12% error rate that engineering cannot reproduce.

Configuration gotchas

A controller is single-use — reusing one makes retry #2 fail in two milliseconds. Once abort() has been called, signal.aborted stays true forever and any fetch given that signal rejects before opening a socket. Chrome reports Uncaught (in promise) DOMException: signal is aborted without reason; Firefox says AbortError: The operation was aborted. The tell is the timing and the Network panel: no request row at all, and the promise settles faster than any real request could. Construct a fresh createUploadAbort() inside the retry loop, exactly as exponential backoff for failed chunks does per attempt.

AbortSignal.timeout() starts counting the moment you create it, not when the request begins. Pre-building one signal per part in a map() over 40 part URLs means part 40’s ten-minute budget started ten minutes before it was needed. The symptom is diagnostic: early parts succeed, tail parts fail with TimeoutError: signal timed out in a tightening pattern. Create the signal inside the attempt, or use the stall clock, which has no absolute reference point.

AbortSignal.any() is newer than the rest of the API. On Safari 17.3, Chrome 115 and Node 20.2 you get TypeError: AbortSignal.any is not a function at runtime — TypeScript will not warn you, because the DOM lib types have carried the declaration since well before the browsers shipped it. Use the anySignal() fallback above. Also note that a composite signal is kept alive by its sources, so a page-level “cancel everything” controller that lives for the session accumulates one dependent signal per upload; let each kit fall out of scope when its attempt ends.

Aborting does not roll back a partially written S3 multipart upload. Every part that returned a 200 before the abort is still in the bucket, still billed at standard storage rates, and invisible to ListObjectsV2. The browser cannot clean up — AbortMultipartUpload needs credentials it does not have. Call your server’s cleanup route from the catch block and add a lifecycle backstop, as described in S3 lifecycle rules for temporary uploads. An aborted single PUT is safer: S3 refuses a body shorter than Content-Length, so no partial object appears — but a reverse proxy that buffers to disk may keep the temp file until its own cleanup runs, which is one more reason to prefer direct-to-cloud uploads for very large files.

Aborting during response reading throws at a different await. If you abort after the headers arrive, the fetch() promise has already resolved; the rejection surfaces from res.json() or res.text() instead. Wrap both awaits in the same try or a stall abort during a slow response body will escape as an unhandled rejection.

Verification

This runs in any browser console with no server, and proves both halves of the contract — ticks keep the signal alive, silence kills it.

const ctl = createUploadAbort(150, 60_000); // 150 ms stall window for a fast test
const t0 = performance.now();

const ticker = setInterval(() => ctl.progress(), 50);
await new Promise((r) => setTimeout(r, 600));
console.assert(!ctl.signal.aborted, "a ticked signal must survive past the stall window");

clearInterval(ticker);
await new Promise((r) => setTimeout(r, 260));
console.assert(ctl.signal.aborted, "must abort ~150 ms after the last tick");
console.log(ctl.signal.reason.name, ctl.signal.reason.kind, ctl.signal.reason.message);
// TimeoutError stall no upload progress for 150 ms
console.log("elapsed", Math.round(performance.now() - t0), "ms"); // ~860 ms

Against a real endpoint, throttle to a custom DevTools profile with 1 kbit/s upload and watch the Network row flip to (canceled) with a Status of (failed) net::ERR_ABORTED roughly one stall window after the throttle engages. Then confirm nothing was left behind server-side:

aws s3api list-multipart-uploads --bucket my-uploads \
  --query 'Uploads[].{Key:Key,Initiated:Initiated}' --output table
# Expect an empty result. Any row here is an upload your abort path failed to clean up.

Frequently Asked Questions

Does calling abort() actually stop the bytes leaving the machine?

Yes for the request body still in flight — the browser resets the underlying stream and closes the connection, and DevTools shows the transfer size frozen at whatever had already been written. Data already buffered in the kernel socket or sitting in an intermediate proxy may still reach the origin, which is why an aborted upload can still create server-side state.

Can one AbortController cancel several parallel part uploads?

Yes, and that is the normal pattern: pass the same signal to every concurrent fetch and one abort() rejects them all with the same reason. What you must not do is reuse that controller for the retry — build a new kit per attempt, as covered in resuming uploads after network loss.

Why does my catch block see AbortError when I passed a custom reason?

Because something in the chain aborted a different signal. AbortSignal.any() forwards the reason of whichever source fired first, so a bare controller.abort() somewhere else — a React useEffect cleanup is the usual culprit — produces the browser’s default AbortError with no kind. Log err.kind ?? err.name at the boundary to find which source really fired.

Should I use AbortSignal.timeout() or a manual setTimeout?

Use AbortSignal.timeout() for a fixed ceiling that never needs resetting, since it is one expression and the timer is managed for you. Use a manual setTimeout plus a controller whenever the deadline must be cleared and re-armed, which is every stall clock, and whenever you want to attach your own reason.

How does this compare to xhr.timeout for the same job?

xhr.timeout is a total-duration cap on the whole exchange with no way to reset it, so it has the exact failure mode described at the top of this page — see fixing XMLHttpRequest timeout errors for large files. Keep XHR for its progress events, drive cancellation from an AbortSignal, and leave xhr.timeout unset.