Showing Accurate Time-Remaining Estimates

Smooth the throughput with an exponentially weighted moving average, discard the first second of slow start, clamp the displayed number so it only ever counts down, quantise it into coarse labels, and refuse to show any number at all when the measured variance says you do not know.

Every progress bar eventually grows a “time remaining” label, and almost every one of them is computed as bytes remaining divided by the average speed so far. That formula is wrong in a specific, reproducible way, and this page fixes it with a small estimator class you can drop into the transfer clock described in real-time upload progress events, the topic that sits inside frontend UX, chunking and progress tracking.

When to use this approach

  • Your uploads run long enough that a user will read the label and plan around it — roughly anything over 30 seconds, which on a typical 5 Mbit/s uplink means files over about 20 MB.
  • Your users are on networks that change speed mid-transfer: mobile handovers, hotel Wi-Fi, VPN reconnects, or an office link that gets busy at 09:00.
  • You already report byte-level progress and now need a number rather than a bar. If your uploads finish in under ten seconds, skip all of this and show a percentage; the estimator will spend most of its life in the warm-up state anyway.

Prerequisites

  1. Node 20+ or an evergreen browser. The code uses #private class fields and ??; no dependencies, no timers.
  2. A byte counter that only ever moves forward — xhr.upload.onprogress for direct uploads, or the tick emitted by a progress-tracking TransformStream if you are streaming the request body.
  3. The total size from file.size, not from ProgressEvent.total. The latter is 0 whenever lengthComputable is false and includes multipart boundaries when it is not.
  4. A monotonic clock. Every method takes now as an argument so the whole thing is testable without fake timers; in production you pass performance.now().

Why the naive estimate lies

The naive estimate — remaining bytes divided by the mean speed since the upload began — has one virtue and two fatal flaws. The virtue is that it is smooth: a cumulative mean over a growing sample can only change slowly, so the number never flickers.

The first flaw is that smoothness is indistinguishable from correctness. When the link dies completely, the cumulative mean keeps reporting the speed of the past twenty seconds, so the ETA drifts upward by about one second per second and the user watches a confident “36 seconds remaining” while nothing at all is moving. The second flaw is the mirror image: after a bandwidth collapse from 5 MB/s to 1.2 MB/s, the mean is still dominated by the fast part of the run, and it takes minutes to admit the truth. Eight seconds after the link comes back at the lower speed, the naive figure below reads 38 seconds when the real answer is 81.

Naive versus smoothed time remaining across a stall A line chart of a 200 MB upload. The naive estimate keeps reporting around 35 seconds through a twelve-second stall and stays roughly half the true value after the link returns at a lower speed, while the smoothed estimator stops reporting during the stall and rejoins the true remaining time once the link is stable again. Displayed time remaining: naive versus smoothed 200 MB upload at 5 MB/s, dead link from 20 s to 32 s, returns at 1.2 MB/s link stalls returns at 1.2 MB/s naive: 38 s left truth: 81 s left 0 25 50 75 100 0 10 20 30 40 50 60 70 seconds since the upload started naive: bytes ÷ average speed smoothed estimator true remaining
The naive line is smooth and wrong: it never notices the stall and stays about half the true value for the rest of the run.

Chasing the instantaneous rate instead is no better. The dashed line above is the true remaining time, and it swings by roughly 30% second to second because real throughput does. A UI that tracked it faithfully would repaint a different number every frame. The fix is to smooth the rate — a single scalar with well-understood statistics — and derive the ETA from it, rather than smoothing the ETA, which is a non-linear function of the rate and over-weights the slowest samples.

Implementation

One class, no dependencies. It takes cumulative bytes and a timestamp, and returns a state plus a label.

export type EtaState = "warming" | "estimating" | "stalled" | "eta";

export interface EtaResult {
  state: EtaState;
  bytesPerSecond: number;
  /** Present only when state === "eta". */
  seconds?: number;
  label: string;
}

export interface EtaOptions {
  halfLifeMs?: number;   // smoothing: how fast old rate samples lose their vote
  windowMs?: number;     // sliding window used for the variance test
  rampUpMs?: number;     // slow-start period thrown away entirely
  stallMs?: number;      // silence after which we say "stalled"
  cvLimit?: number;      // coefficient of variation above which we refuse to guess
  minBins?: number;      // full one-second bins required before any number is shown
  gapMs?: number;        // a jump this big means the clock, not the network, moved
}

interface Sample { t: number; bytes: number }

export class EtaEstimator {
  #total: number;
  #halfLifeMs: number; #windowMs: number; #rampUpMs: number;
  #stallMs: number; #cvLimit: number; #minBins: number; #gapMs: number;

  #samples: Sample[] = [];
  #rate = 0;              // bytes per millisecond
  #primed = false;
  #startedAt = -1;
  #shown = -1;            // last value put in front of the user, seconds
  #shownAt = 0;
  #last: EtaResult = { state: "warming", bytesPerSecond: 0, label: "Starting…" };

  constructor(totalBytes: number, opts: EtaOptions = {}) {
    this.#total = totalBytes;
    this.#halfLifeMs = opts.halfLifeMs ?? 5_000;
    this.#windowMs = opts.windowMs ?? 10_000;
    this.#rampUpMs = opts.rampUpMs ?? 1_000;
    this.#stallMs = opts.stallMs ?? 3_000;
    this.#cvLimit = opts.cvLimit ?? 0.5;
    this.#minBins = opts.minBins ?? 4;
    this.#gapMs = opts.gapMs ?? 30_000;
  }

  update(loaded: number, now: number = performance.now()): EtaResult {
    if (this.#startedAt < 0) { this.#restart(now, loaded); return this.#last; }

    const prev = this.#samples[this.#samples.length - 1];
    const dt = now - prev.t;
    if (dt > this.#gapMs) { this.#restart(now, loaded); return this.#last; }
    if (dt <= 0) return this.#last;                    // duplicate or reordered timestamp

    const delta = Math.max(0, loaded - prev.bytes);    // a retry must never rewind the counter
    this.#samples.push({ t: now, bytes: loaded });

    // Slow start is not representative of the link. Throw it away rather than average it in.
    if (now - this.#startedAt < this.#rampUpMs) return this.#last;

    if (!this.#primed) {
      this.#samples = [prev, { t: now, bytes: loaded }];
      this.#primed = true;
      this.#rate = delta / dt;                         // seed from a measurement, not from 0
    } else {
      const alpha = 1 - 2 ** (-dt / this.#halfLifeMs); // time-aware: a long gap weighs more
      this.#rate += alpha * (delta / dt - this.#rate);
    }

    const cutoff = now - this.#windowMs;
    while (this.#samples.length > 2 && this.#samples[0].t < cutoff) this.#samples.shift();

    const bytesPerSecond = this.#rate * 1000;
    if (this.#isStalled(now)) {
      return (this.#last = { state: "stalled", bytesPerSecond,
        label: "Stalled — waiting for the network" });
    }

    const cv = this.#variation(now);
    if (this.#rate <= 0 || cv === null || cv > this.#cvLimit) {
      return (this.#last = { state: "estimating", bytesPerSecond, label: "Estimating…" });
    }

    const raw = (this.#total - loaded) / this.#rate / 1000;
    const seconds = this.#settle(raw, now);
    return (this.#last = { state: "eta", bytesPerSecond, seconds, label: formatEta(seconds) });
  }

  #restart(now: number, loaded: number): void {
    this.#samples = [{ t: now, bytes: loaded }];
    this.#rate = 0; this.#primed = false; this.#startedAt = now; this.#shown = -1;
    this.#last = { state: "warming", bytesPerSecond: 0, label: "Starting…" };
  }

  /** Silent for stallMs, measured from the last sample that actually carried bytes. */
  #isStalled(now: number): boolean {
    const s = this.#samples;
    const latest = s[s.length - 1].bytes;
    for (let i = s.length - 1; i >= 0; i--) {
      if (s[i].bytes < latest) return now - s[i].t > this.#stallMs;
    }
    return now - s[0].t > this.#stallMs;
  }

  /** Bin the window into whole seconds, then take stddev/mean over the completed bins. */
  #variation(now: number): number | null {
    const bins = new Map<number, number>();
    for (let i = 1; i < this.#samples.length; i++) {
      const age = Math.ceil((now - this.#samples[i].t) / 1000);   // age 0 = partial bin
      bins.set(age, (bins.get(age) ?? 0) + (this.#samples[i].bytes - this.#samples[i - 1].bytes));
    }
    const full = [...bins].filter(([age]) => age >= 1).map(([, bytes]) => bytes);
    if (full.length < this.#minBins) return null;
    const mean = full.reduce((a, b) => a + b, 0) / full.length;
    if (mean <= 0) return Infinity;
    const variance = full.reduce((a, b) => a + (b - mean) ** 2, 0) / full.length;
    return Math.sqrt(variance) / mean;
  }

  /** The displayed value counts down in real time and only jumps up for real regressions. */
  #settle(raw: number, now: number): number {
    if (this.#shown < 0) { this.#shown = raw; this.#shownAt = now; return raw; }
    const decayed = Math.max(0, this.#shown - (now - this.#shownAt) / 1000);
    const next =
      raw <= decayed ? raw
      : raw - decayed > Math.max(15, decayed * 0.5) ? raw
      : decayed;
    this.#shown = next; this.#shownAt = now;
    return next;
  }
}

export function formatEta(seconds: number): string {
  if (seconds < 10) return "A few seconds left";
  if (seconds < 60) return "Less than a minute left";
  if (seconds < 90) return "About a minute left";
  if (seconds < 3570) return `About ${Math.round(seconds / 60)} minutes left`;
  const totalMinutes = Math.round(seconds / 60);
  const hours = Math.floor(totalMinutes / 60);
  const minutes = totalMinutes % 60;
  return minutes === 0 ? `About ${hours} h left` : `About ${hours} h ${minutes} min left`;
}

Line-by-line on the parts that matter

  • update() takes now as a parameter with a default. Production calls estimator.update(bytes); tests call estimator.update(bytes, 4_250). Every branch in this class is a function of wall-clock deltas, and injecting the clock is the difference between a deterministic test suite and one that fails on a loaded CI runner.
  • Math.max(0, loaded - prev.bytes) turns a backwards jump into a zero-byte sample instead of a negative rate. Parallel part uploads produce backwards jumps routinely: one part fails, restarts, and its loaded drops to zero while the others keep climbing.
  • if (now - this.#startedAt < this.#rampUpMs) return this.#last; discards slow start. TCP congestion window growth plus TLS setup means the first second measures the handshake, not the link. Leave it in and the seeded average is roughly half the true rate; the first ETA the user sees is then about twice too long, and it shrinks for the next ten seconds, which looks exactly like a broken estimator even though it is converging correctly.
  • this.#rate = delta / dt on the first post-ramp sample. An EWMA initialised to zero spends several half-lives climbing out of the hole, which biases every early estimate high. Seeding directly from the first real measurement removes that transient entirely — in the reference run it is the difference between a first displayed value of 54 s and one of 78 s against a true 51 s.
  • alpha = 1 - 2 ** (-dt / halfLifeMs) is the time-aware form of the smoothing constant. Progress events do not arrive on a fixed cadence — xhr.upload.onprogress fires roughly every 50 ms on a fast link and once every few seconds on a slow one — so a fixed alpha silently changes meaning with the sample rate. Deriving alpha from the actual elapsed time makes the half-life a property of the clock, not of the event frequency.
  • #variation() bins into whole seconds before measuring spread. Per-event deltas are far too noisy to compare: a single 50 ms event may carry 0 bytes purely because of how the socket flushed. One-second bins are large enough that their spread reflects the link rather than the event loop.
  • cv === null is treated the same as “too noisy”. Fewer than four completed bins means less than four seconds of usable history, and the honest answer at that point is that you do not know yet.
  • #settle() decays the previously displayed value by real elapsed time before comparing. That is what makes a held estimate keep counting down instead of freezing — the user sees a steady countdown, and the underlying raw value quietly catches up.
Estimator states and transitions Four states: warming during the first second, estimating while variance is high, showing an ETA once variance settles, and stalled after three seconds without bytes. Bytes moving again returns the estimator to the estimating state. Estimator states and the transitions between them bytes move again warming first 1 000 ms discarded shows: Starting… estimating < 4 bins or CV > 0.5 shows: Estimating… showing an ETA raw = remaining ÷ rate clamped, then quantised stalled no bytes for 3 s shows: Stalled 1 000 ms elapsed CV ≤ 0.5 CV > 0.5 3 s with no bytes A gap over 30 s — a sleeping laptop — throws the window away and returns to warming.
Only one of the four states produces a number; the other three are the estimator admitting what it does not know.

Choosing the half-life

halfLifeMs is the only tuning knob that changes the character of the output, so pick it deliberately. It is the time after which a rate sample has half the influence it had when it arrived. With progress events every 250 ms, the derived alpha and the resulting behaviour are:

Half-life Alpha at 250 ms ticks Residual noise vs one sample Time to reach 90% of a new rate
1 s 0.159 29% 3.3 s
2 s 0.083 21% 6.6 s
5 s 0.034 13% 16.6 s
10 s 0.017 9% 33 s
20 s 0.009 7% 66 s

Five seconds is the knee. Dropping to 1 s more than doubles the residual noise, and on the reference run the smoothed rate then swings between 4.14 and 5.86 MB/s across a steady stretch — enough for the label to churn. Going the other way costs far more than it buys: measured against the same run, a 10-second half-life took 24 seconds to catch the drop from 5 MB/s to 1.2 MB/s and a 20-second half-life took 66 seconds, during which the UI is confidently wrong. The variance gate already covers the transition window, so there is no reason to buy extra stability with lag.

Two related knobs follow from it. windowMs should be about two half-lives — long enough for four one-second bins, short enough that the window has flushed within a few seconds of a regime change. stallMs at 3 s is deliberately shorter than the 20-second idle abort: the label should tell the truth long before the transport gives up.

Clamping the displayed value

A monotonic clamp is the single change users notice most. The rule in #settle() has three branches: if the raw estimate improved, follow it down immediately; if it worsened by more than max(15 s, 50%), accept the jump because something real happened; otherwise keep showing the previous value minus the seconds that have actually elapsed.

Raw estimate versus the clamped value shown to the user Between 13 and 22 seconds the raw estimate falls, plateaus near 25 seconds and then climbs to 30 seconds as the link fades, while the clamped displayed value continues counting down to 20 seconds, ending 10 seconds below the raw figure. The clamp: raw estimate versus what the user sees raw plateaus near 25 s, then climbs to 30 s the displayed value only ever counts down 10 s apart 18 s 22 s 26 s 30 s 13 15 17 19 21 seconds since the upload started raw = remaining ÷ smoothed rate displayed after the clamp
The two lines are identical while the estimate improves; the clamp only does work once the raw value turns around.

At 21.75 s in the reference run the raw estimate has climbed to 29.9 s while the display reads 19.7 s — a 10-second lie, deliberately told, because the alternative is a number that grows while the user watches. The clamp is not a fudge for long: 250 ms later the variance gate opens and the label switches to Estimating…, then to Stalled. The clamp buys the two seconds it takes the other detectors to become certain, and it makes sure the last thing the user saw before the state change was a countdown rather than a climb.

Quantising the label

Never render raw seconds. A number with second-level resolution invites the user to time it, and it changes 240 times a minute at 250 ms ticks. formatEta() collapses the whole range into five buckets: under 10 s is “A few seconds left”, under a minute is “Less than a minute left”, under 90 s is “About a minute left”, then whole minutes, then hours and minutes. The boundaries are chosen so a label changes at most once a minute in the minutes band and at most three times in the last minute, and the “About a minute” bucket exists so that 61 s does not read as “About 1 minutes left”.

Quantisation also does hysteresis work for free. Because the clamp guarantees the underlying value only descends, and each bucket is entered from above, a label can never bounce between two adjacent buckets — the classic “2 minutes, 1 minute, 2 minutes” flicker is structurally impossible.

Wiring it to real progress events

The estimator wants one cumulative byte count across every in-flight request. With parallel parts, keep a per-part high-water mark and sum it, then coalesce UI writes into an animation frame — the same discipline the parent topic applies to the bar itself.

const estimator = new EtaEstimator(file.size);
const loadedPerPart = new Map<number, number>();
let pending: EtaResult | null = null;
let frame = 0;

export function onPartProgress(part: number, loaded: number): void {
  // High-water mark: a part that retries restarts at 0 and must not rewind the total.
  loadedPerPart.set(part, Math.max(loadedPerPart.get(part) ?? 0, loaded));
  let total = 0;
  for (const bytes of loadedPerPart.values()) total += bytes;
  pending = estimator.update(total);
  if (frame === 0) frame = requestAnimationFrame(flush);
}

function flush(): void {
  frame = 0;
  if (!pending) return;
  document.querySelector<HTMLElement>("#eta")!.textContent = pending.label;
  document.querySelector<HTMLElement>("#speed")!.textContent =
    `${(pending.bytesPerSecond / 1_000_000).toFixed(1)} MB/s`;
}

Raw versus smoothed on a stalling connection

The run below is fully deterministic: a 200 MB file, a two-second ramp, 5 MB/s with a ±30% oscillation until 20 s, twelve seconds of complete silence, then a return at 1.2 MB/s. “Truth” is remaining bytes over the link’s actual instantaneous rate.

t Uploaded Truth Naive ETA Estimator Label shown
1 s 1.6 MB 79 s 127 s Estimating…
5 s 19.6 MB 51 s 46 s 54 s Less than a minute left
12 s 53.8 MB 35 s 33 s 33 s Less than a minute left
16 s 76.5 MB 27 s 26 s 25 s Less than a minute left
20 s 93.1 MB unbounded 23 s 21 s Less than a minute left
22 s 93.1 MB unbounded 25 s Estimating…
24 s 93.1 MB unbounded 28 s Stalled — waiting for the network
31 s 93.1 MB unbounded 36 s Stalled — waiting for the network
33 s 94.6 MB 88 s 37 s Estimating…
40 s 103.0 MB 81 s 38 s 88 s About a minute left
45 s 109.0 MB 76 s 38 s 79 s About a minute left
70 s 139.0 MB 51 s 31 s 51 s Less than a minute left

Through the steady phase the two agree, and that is the point: the estimator costs nothing when conditions are good. Everything after 20 s is the payoff. The naive figure never leaves the 23–38 second band across a dead link and a 4× bandwidth collapse, while the estimator stops guessing at 22 s, names the stall at 24 s, and re-converges to within 4% of truth by 45 s. If your recovery path also restarts parts — see resuming uploads after network loss — the stalled state is exactly the moment to surface a Retry affordance.

Configuration gotchas

Feeding ProgressEvent.total into the constructor gives Infinity and then a thrown formatter. When lengthComputable is false, e.total is 0; remaining / rate is then Infinity, and the first thing that touches it throws — RangeError: Value need to be finite number for Intl.RelativeTimeFormat.prototype.format() from V8, or RangeError: Invalid time value if you routed it through new Date(...). Construct with file.size and add if (!Number.isFinite(this.#total)) return { state: "estimating", … } as a belt-and-braces guard.

performance.now() keeps advancing while the machine sleeps. Close a laptop lid mid-upload and the first event after it reopens carries a dt measured in minutes with a handful of bytes attached. Without the dt > gapMs branch, the EWMA collapses and the label reads something like About 5 h 12 min left. Do not “fix” this by switching to Date.now() — an NTP correction can then step the clock backwards, dt goes negative, the ETA goes negative, and formatEta(-482) cheerfully returns “A few seconds left” forever. The dt <= 0 guard and the monotonic clock are both load-bearing.

Only calling update() once per completed part leaves the label stuck on “Estimating…” for the whole upload. With 8 MB parts on a 2 MB/s link you get one sample every four seconds, the 10-second window never accumulates four full one-second bins, #variation() returns null, and there is no error to tell you why. Either feed it byte-level ticks from xhr.upload.onprogress, or set { windowMs: 60_000, minBins: 3 } and accept a much slower response to change. This is the main reason to prefer many small parts over few large ones when sizing a 500 MB upload.

Passing a bound method around detaches the private fields. const { update } = estimator; onProgress(update) fails at the first call with TypeError: Cannot read properties of undefined (reading '#startedAt'), because #private access is keyed to the receiver. Pass (n: number) => estimator.update(n) instead, or bind in the constructor.

The bytes clock is not the whole job. loaded === total means the last byte reached the kernel, not that storage acknowledged it or that transcoding finished. Show a separate indeterminate “Finishing…” state between the last progress event and the response, and drive anything after that from server-sent upload progress rather than from this estimator.

Verification

Replay the profile with an injected clock. No timers, no network, and identical output on every machine:

import assert from "node:assert/strict";
import { EtaEstimator } from "./eta-estimator.js";

const MB = 1_000_000;
const TOTAL = 200 * MB;
// 2 s ramp, 5 MB/s ±30%, dead from 20 s to 32 s, back at 1.2 MB/s.
const rateAt = (t: number) =>
  t < 2 ? 2.5 * t : t < 20 ? 5 + 1.5 * Math.sin(t) : t < 32 ? 0 : 1.2;

const est = new EtaEstimator(TOTAL);
let loaded = 0;
const seen = new Map<number, ReturnType<EtaEstimator["update"]>>();
const shown: number[] = [];

for (let i = 0; i <= 280; i++) {
  const t = i * 0.25;                                   // one progress event every 250 ms
  if (i > 0) loaded = Math.min(TOTAL, loaded + rateAt(t) * MB * 0.25);
  const r = est.update(loaded, t * 1000);
  if (t >= 13 && t <= 21.75 && r.state === "eta") shown.push(r.seconds!);
  if (Number.isInteger(t)) seen.set(t, r);
}

assert.equal(seen.get(1)!.state, "estimating");          // refuses to guess during slow start
assert.equal(seen.get(16)!.state, "eta");
assert.ok(Math.abs(seen.get(16)!.seconds! - 25) < 1);    // truth 27 s, naive 26 s
assert.equal(seen.get(24)!.state, "stalled");            // 4 s after the bytes stopped
assert.equal(seen.get(31)!.state, "stalled");
assert.equal(seen.get(36)!.state, "estimating");         // resumed, variance still too high
assert.ok(Math.abs(seen.get(45)!.seconds! - 79) < 1);    // truth 76 s; naive still says 38 s
assert.ok(shown.every((v, i) => i === 0 || v <= shown[i - 1]), "display must never creep up");
console.log("eta estimator: all assertions passed");
// eta estimator: all assertions passed

Against a real endpoint, open DevTools, start a large upload, and switch the throttling profile from “No throttling” to a custom 50 kbit/s profile mid-transfer. The label should hold its countdown for a beat, flip to Estimating… within about two seconds, and settle on a new, much larger figure roughly ten seconds later.

Frequently Asked Questions

Why smooth the throughput rather than the ETA itself?

Because ETA is a reciprocal of rate, so averaging ETAs is not the same as averaging speeds. A sample at half speed produces double the ETA, and the mean of those two ETAs comes out 12.5% above the ETA implied by the mean of the two speeds. Smoothing the rate keeps the arithmetic linear and keeps a single momentary slowdown from dominating the display.

Should the displayed time ever be allowed to increase?

Yes — refusing outright is how you get a countdown that hits “a few seconds” and sits there for two minutes. The rule is that an increase must be large enough to be information: max(15 s, 50%) in this implementation. Anything smaller is measurement noise, and the clamp absorbs it by continuing to count down while the raw value catches up.

What should the UI show while the state is “estimating”?

Keep the determinate percentage bar, which is still exactly correct, and replace only the time label — with “Estimating…” or with nothing at all. Do not swap to an indeterminate spinner, because that discards the byte progress you do know, and a bar that changes shape mid-upload reads as an error.

Can this estimate server-side processing time as well?

Only if the server emits a monotonic counter you can substitute for bytes — frames encoded, pages rasterised, megabytes scanned. Feed that counter into a second EtaEstimator with its own total and add the two labels, or keep the phases separate in the UI, as persisting upload state in IndexedDB does with its per-phase records. A percentage-only feed cannot drive this because percentages hide the unit of work.

Why one second of ramp-up rather than two or five?

One second is long enough to cover TLS setup and the first few congestion-window doublings, and short enough that the estimator has something to say before a user gives up on it. Longer values mostly help on high-latency satellite links; if you serve those, raise rampUpMs to 3000 rather than raising the half-life, because slow start is a transient to discard, not noise to average.