Detecting Stalled Uploads with a Progress Watchdog

Replace (or supplement) the total-request timeout with an inactivity timer: reset it on every progress event or acknowledged chunk, abort the request through an AbortController when no bytes have moved for a window such as 20–30 seconds, and hand the abort to your retry logic as a “stalled” error distinct from a network failure — so a slow-but-healthy 2 GB upload runs as long as it needs, and a dead connection is noticed in seconds instead of minutes.

A fixed timeout cannot be right for uploads. Set it to two minutes and a 400 MB video on a 5 Mbit/s uplink always fails; set it to an hour and a request whose connection silently died in a lift sits there for an hour, showing a frozen bar, before anything retries. The question a timeout should ask is not “has this taken too long?” but “has this stopped making progress?”. This page is part of browser timeout and retry logic in upload fundamentals and browser APIs. It pairs with aborting uploads with AbortController and timeouts, which covers the cancellation plumbing it relies on.

When to use this approach

  • Uploads vary widely in size and connection speed, so no single deadline fits them all.
  • Users on mobile networks see uploads freeze with no error — the classic half-open connection after a network change.
  • You already retry failed chunks and want stalls to enter that path quickly.

Prerequisites

  1. An upload path that exposes progress: XMLHttpRequest.upload.onprogress, a counting TransformStream on a streaming body, or chunk-by-chunk completion.
  2. AbortController for cancelling the in-flight request.
  3. A retry policy that distinguishes stalls from other errors — see implementing exponential backoff for failed chunks.

Total timeout versus inactivity timeout

A fixed deadline versus an inactivity watchdog on two uploads A slow healthy upload keeps making progress for eight minutes; a two-minute fixed timeout kills it, while the watchdog lets it finish. A stalled upload stops sending bytes at one minute; the fixed timeout notices only at two minutes, while the watchdog aborts at one minute and twenty seconds. Deadline asks "how long?"; watchdog asks "still moving?" slow but healthy bytes flowing steadily for 8 min fixed 2-min timeout kills it watchdog: completes stalled no bytes move (half-open socket) watchdog aborts at 1:20 fixed timeout: 2:00 0 8 min Watchdog window here: 20 s. It never fires while bytes move, and fires promptly when they stop.
The watchdog is generous to slow connections and impatient with dead ones — the opposite of a fixed deadline's trade-off.

Implementation

A small watchdog class, and an XHR upload that uses it. The same watchdog works with chunked fetch by calling kick() after each acknowledged chunk.

export class StallError extends Error {
  constructor(readonly idleMs: number, readonly bytesSent: number) {
    super(`upload stalled: no progress for ${Math.round(idleMs / 1000)} s at ${bytesSent} bytes`);
    this.name = "StallError";
  }
}

/** Calls onStall once if kick() is not called within idleMs. */
export class Watchdog {
  private timer: ReturnType<typeof setTimeout> | null = null;
  private lastKick = performance.now();
  constructor(private idleMs: number, private readonly onStall: (idleMs: number) => void) {}

  /** Change the window, e.g. from a first-byte grace period to the steady-state value. */
  setIdle(ms: number): void { this.idleMs = ms; this.kick(); }

  start(): void { this.kick(); }
  kick(): void {
    this.lastKick = performance.now();
    if (this.timer) clearTimeout(this.timer);
    this.timer = setTimeout(() => this.onStall(performance.now() - this.lastKick), this.idleMs);
  }
  stop(): void { if (this.timer) clearTimeout(this.timer); this.timer = null; }
}

export interface WatchedUploadOptions {
  idleMs?: number;              // no-progress window before we give up on this attempt
  firstByteMs?: number;         // longer grace before the first progress event (TLS, preflight)
  signal?: AbortSignal;         // user cancellation
  onProgress?: (sent: number, total: number) => void;
}

export function watchedUpload(url: string, body: Blob, opts: WatchedUploadOptions = {}): Promise<number> {
  const idleMs = opts.idleMs ?? 20_000;
  const firstByteMs = opts.firstByteMs ?? 45_000;

  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    let sent = 0;
    let started = false;

    const dog = new Watchdog(firstByteMs, (idle) => {
      xhr.abort();
      reject(new StallError(idle, sent));
    });

    xhr.open("PUT", url);
    xhr.setRequestHeader("Content-Type", body.type || "application/octet-stream");
    xhr.upload.onprogress = (e) => {
      if (e.loaded > sent) {
        sent = e.loaded;
        if (!started) { started = true; dog.setIdle(idleMs); } else { dog.kick(); }
        opts.onProgress?.(e.loaded, e.total);
      }
    };
    // After the body is fully sent, the server may take time to respond; keep watching.
    xhr.upload.onload = () => dog.kick();
    xhr.onload = () => {
      dog.stop();
      xhr.status >= 200 && xhr.status < 300 ? resolve(xhr.status) : reject(new Error(`HTTP ${xhr.status}`));
    };
    xhr.onerror = () => { dog.stop(); reject(new TypeError("network error")); };
    xhr.onabort = () => { dog.stop(); };
    opts.signal?.addEventListener("abort", () => {
      dog.stop(); xhr.abort(); reject(new DOMException("Aborted", "AbortError"));
    }, { once: true });

    dog.start();
    xhr.send(body);
  });
}

// Usage with retry: stalls are retryable, user aborts are not.
export async function uploadWithRetry(url: string, file: File, attempts = 5): Promise<number> {
  for (let i = 0; ; i++) {
    try {
      return await watchedUpload(url, file, { onProgress: (s, t) => console.log(`${((100 * s) / t).toFixed(1)}%`) });
    } catch (err) {
      const retryable = err instanceof StallError || err instanceof TypeError;
      if (!retryable || i + 1 >= attempts) throw err;
      await new Promise((r) => setTimeout(r, Math.random() * Math.min(30_000, 1000 * 2 ** i)));
    }
  }
}

Line-by-line on the parameters that matter

  • kick() only when loaded increases. Browsers can fire progress events with an unchanged loaded value. Resetting the timer on those would keep a stalled request alive indefinitely.
  • A longer firstByteMs. Before the first progress event, the browser may be resolving DNS, completing TLS, and waiting for a CORS preflight. Using the steady-state idle window here would abort healthy uploads on slow first connections.
  • upload.onload kicks once more. After the last byte is sent, a server that scans or thumbnails before responding produces no upload progress. Without this kick, a slow server response looks like a stall. If your server can take longer than the idle window after the body, raise the window for that phase or respond early.
  • StallError as its own type. Retry policy treats stalls as retryable (the connection died, a new one will likely work) but analytics should count them separately from network errors: a rising stall rate usually means a proxy or mobile network is eating connections.
  • Jittered backoff between attempts. Many clients stalled by the same network event should not retry in lockstep.

Choosing the idle window

The window must be longer than the longest pause a healthy upload can have, and shorter than users’ patience. Healthy pauses come from TCP congestion recovery, radio power-state changes on phones and Wi-Fi roaming, and they rarely exceed ten seconds.

Distribution of gaps between progress events on healthy mobile uploads Most gaps between progress events on healthy uploads are under half a second. A small tail extends to about 8 seconds during radio handovers, and almost none exceed 15 seconds. A 20 second window sits safely beyond the tail. Gaps between progress events, healthy mobile uploads idle window 20 s <0.5 s 2 s 8 s 20 s Log the longest gap per healthy upload in production and set the window a little beyond its 99.9th percentile.
Healthy uploads pause briefly and often; a gap of twenty seconds is almost always a dead connection.

Why connections stall without failing

A stall is usually a connection that has died without either side being told. When a phone moves from Wi-Fi to cellular, its IP address changes; packets for the old connection go nowhere, and no reset arrives because the path that would carry it no longer exists. The browser’s TCP stack keeps retransmitting with growing backoff and, depending on the operating system, may not declare the connection dead for many minutes. From JavaScript, the request is simply “in progress” with no events.

Middleboxes cause the same symptom. Corporate proxies and mobile carrier gateways buffer upload bodies, sometimes until the whole request has arrived; the browser sees fast progress to 100% and then nothing while the proxy forwards it. Others drop idle flows after a fixed interval. HTTP/2 and HTTP/3 help — QUIC can migrate a connection across networks — but only when both ends support it and the migration succeeds.

None of this is visible to the page as an error, which is why a watchdog is needed: it turns the absence of events into an event. Combined with chunked uploads, the cost of a stall becomes one chunk re-sent after a short wait, instead of a frozen bar and a user who gives up.

Configuration gotchas

Uploads through a corporate proxy abort at 100%. The proxy buffers the whole body and forwards it before the server responds, so the “after body” phase can be long. Kick on upload.onload and give that phase its own, larger window, or have the server respond before heavy processing.

Background tabs trigger false stalls. Browsers throttle timers in background tabs, and mobile browsers freeze them entirely; the watchdog may fire late, or the upload itself may pause. Pause the watchdog on visibilitychange to hidden and restart it on visible, so a frozen tab is not counted as a stall.

Stalls on every chunk after a network change. The browser keeps reusing a dead HTTP/2 connection from its pool for new requests. Aborting the stalled request usually makes the browser discard that connection; if it does not, a short delay before retrying lets the connection pool notice.

The watchdog fires during the CORS preflight. A preflight to a slow or distant upload origin can take seconds. The firstByteMs grace covers it; if not, cache the preflight with Access-Control-Max-Age.

Where the watchdog sits in the retry loop

Watchdog feeding the retry loop An attempt starts and the watchdog runs. Progress events reset it. If it fires, the request is aborted with a stall error, which the retry loop treats as retryable and schedules a new attempt after jittered backoff. Success or a non-retryable error ends the loop. Stall → abort → retry, automatically attempt n watchdog armed progress kick() each event idle 20 s abort → StallError backoff jittered wait attempt n+1 (resumes from the server's offset if chunked)
The watchdog does not retry; it converts silence into an error the retry loop already knows how to handle.

Verification

  1. DevTools → Network → throttling “Slow 3G”: a 50 MB upload should complete with no stall, however long it takes.
  2. Start an upload, then set throttling to “Offline” mid-transfer: within about the idle window the console should show a StallError, followed by a retry.
  3. Unit-test the watchdog with fake timers:
import { vi, it, expect } from "vitest";
import { Watchdog } from "./watchdog.ts";

it("fires only after idleMs without kicks", () => {
  vi.useFakeTimers();
  const fired: number[] = [];
  const dog = new Watchdog(20_000, (idle) => fired.push(idle));
  dog.start();
  vi.advanceTimersByTime(15_000); dog.kick();
  vi.advanceTimersByTime(15_000);
  expect(fired).toHaveLength(0);
  vi.advanceTimersByTime(6_000);
  expect(fired).toHaveLength(1);
});

Frequently Asked Questions

Should I drop the total timeout entirely?

Keep a generous ceiling as a backstop — say, the file size divided by a pessimistic minimum speed, plus a margin — so a pathological case cannot run forever. The watchdog does the real work; the ceiling catches bugs in it.

How does this work with fetch, which has no progress events?

For chunked uploads, kick the watchdog after each chunk and use a per-chunk idle window sized to chunk size and expected speed. For streaming bodies, kick from the counting TransformStream. For a single non-streaming fetch, there is nothing to observe — use XHR or chunks.

Can the server detect stalls too?

Yes, and it should: a request body that stops arriving holds server resources. Set a body read timeout (nginx client_body_timeout, Node’s requestTimeout) so the server closes stalled connections; the client’s watchdog and retry then pick up the pieces.