Skip to content

Progress Tracking

Track progress across multiple async operations using an EventEmitter-based promise wrapper. Emits progress events on each settlement and provides summary statistics.

When to Use

  • File uploads, batch processing, or parallel API calls where users need progress feedback
  • Showing a progress bar for deterministic work (known total) or a spinner for indeterminate work
  • Waiting for all promises to settle before deciding success/failure (no early rejection)

The Pattern

File uploads with progress bar

javascript
const uploads = files.map(f => uploadFile(f));
const progress = new ProgressPromise(uploads);

progress.on("progress", ({ progress: p }) => {
  updateProgressBar(p.proportion);
  updateLabel(
    `${p.fulfilled} of ${p.total} complete`
  );
});

try {
  const result = await progress;
  showSuccess("All uploads complete");
} catch (error) {
  showWarning(
    `${error.progress.rejected} uploads failed`
  );
}

Deterministic vs indeterminate UI

javascript
progress.on("progress", ({ progress: p }) => {
  if (p.proportion === 0) {
    showSpinner();
  } else {
    showProgressBar(p.proportion);
    updateLabel(`${p.percentage} complete`);
  }
});

Summary statistics

javascript
const promises = [
  Promise.resolve("a"),
  Promise.reject("b"),
  Promise.resolve("c"),
  Promise.resolve("d"),
  Promise.reject("e"),
];

const progress = new ProgressPromise(promises);
await progress.catch(() => {});

// progress.fulfilled === 3
// progress.rejected === 2
// progress.total === 5
// progress.percentage === "100.0%"

Implementation

javascript
class ProgressPromise extends EventEmitter {
  #resolvers = Promise.withResolvers();
  
  constructor(promises) {
    super();
    promises = ensureArray(promises);
    this.#init(promises);
  }
  
  async #init(promises) {
    this.promises = promises.map(
      promise => new PromiseOutcome(promise)
    );
    
    for (const outcome of this.promises) {
      outcome.then(
        () => this.#emitProgress(),
        () => this.#emitProgress(),
      );
    }
    
    await Promise.allSettled(this.promises);
    const summary = this.summary();
    
    if (summary.rejected === 0) {
      this.#resolvers.resolve({
        outcomes: this.promises,
        progress: summary,
      });
    } else {
      this.#resolvers.reject({
        outcomes: this.promises,
        progress: summary,
      });
    }
  }
  
  #emitProgress() {
    this.emit("progress", {
      type: "progress",
      progress: this.summary(),
      outcomes: this.promises,
    });
  }
  
  summary() {
    return {
      total: this.total,
      pending: this.pending,
      fulfilled: this.fulfilled,
      rejected: this.rejected,
      settled: this.settled,
      proportion: this.proportion,
      percentage: this.percentage,
    };
  }
  
  get total() {
    return this.promises.length;
  }
  
  get pending() {
    return this.promises.filter(
      o => o.status === "pending"
    ).length;
  }
  
  get fulfilled() {
    return this.promises.filter(
      o => o.status === "fulfilled"
    ).length;
  }
  
  get rejected() {
    return this.promises.filter(
      o => o.status === "rejected"
    ).length;
  }
  
  get settled() {
    return this.fulfilled + this.rejected;
  }
  
  get proportion() {
    if (this.total === 0) return 1.0;
    return this.settled / this.total;
  }
  
  get percentage() {
    return `${(this.proportion * 100).toFixed(1)}%`;
  }
  
  get promise() {
    return this.#resolvers.promise;
  }
  
  then(onFulfilled, onRejected) {
    return this.#resolvers.promise.then(
      onFulfilled, onRejected,
    );
  }
  
  catch(onRejected) {
    return this.#resolvers.promise.then(
      undefined, onRejected,
    );
  }
  
  finally(onSettled) {
    return this.#resolvers.promise.finally(
      onSettled,
    );
  }
}

Key details

Universal version (Node.js and browsers)

Replace EventEmitter with EventTarget, which is built into every browser and Node.js 15+. No imports, no polyfills.

The only API difference is how you listen for progress:

javascript
// EventEmitter (Node.js)
progress.on("progress", ({ progress: p }) => {
  updateProgressBar(p.proportion);
});

// EventTarget (browser / universal)
progress.addEventListener("progress", (e) => {
  updateProgressBar(e.detail.progress.proportion);
});

Everything else — await, try/catch, summary statistics — works identically.

Implementing ProgressPromise with EventTarget

Dispatches CustomEvent instances instead of EventEmitter events, making the class portable to any runtime:

javascript
class ProgressPromise extends EventTarget {
  #resolvers = Promise.withResolvers();
  
  constructor(promises) {
    super();
    promises = ensureArray(promises);
    this.#init(promises);
  }
  
  async #init(promises) {
    this.promises = promises.map(
      promise => new PromiseOutcome(promise)
    );
    
    for (const outcome of this.promises) {
      outcome.then(
        () => this.#emitProgress(),
        () => this.#emitProgress(),
      );
    }
    
    await Promise.allSettled(this.promises);
    const summary = this.summary();
    
    if (summary.rejected === 0) {
      this.#resolvers.resolve({
        outcomes: this.promises,
        progress: summary,
      });
    } else {
      this.#resolvers.reject({
        outcomes: this.promises,
        progress: summary,
      });
    }
  }
  
  #emitProgress() {
    this.dispatchEvent(
      new CustomEvent("progress", {
        detail: {
          progress: this.summary(),
          outcomes: this.promises,
        },
      })
    );
  }
  
  summary() {
    return {
      total: this.total,
      pending: this.pending,
      fulfilled: this.fulfilled,
      rejected: this.rejected,
      settled: this.settled,
      proportion: this.proportion,
      percentage: this.percentage,
    };
  }
  
  get total() {
    return this.promises.length;
  }
  
  get pending() {
    return this.promises.filter(
      o => o.status === "pending"
    ).length;
  }
  
  get fulfilled() {
    return this.promises.filter(
      o => o.status === "fulfilled"
    ).length;
  }
  
  get rejected() {
    return this.promises.filter(
      o => o.status === "rejected"
    ).length;
  }
  
  get settled() {
    return this.fulfilled + this.rejected;
  }
  
  get proportion() {
    if (this.total === 0) return 1.0;
    return this.settled / this.total;
  }
  
  get percentage() {
    return (
      `${(this.proportion * 100).toFixed(1)}%`
    );
  }
  
  get promise() {
    return this.#resolvers.promise;
  }
  
  then(onFulfilled, onRejected) {
    return this.#resolvers.promise.then(
      onFulfilled, onRejected,
    );
  }
  
  catch(onRejected) {
    return this.#resolvers.promise.then(
      undefined, onRejected,
    );
  }
  
  finally(onSettled) {
    return this.#resolvers.promise.finally(
      onSettled,
    );
  }
}

Trade-offs

ApproachProsCons
ProgressPromiseRich metrics, eventsNeeds class
Promise.all + counterSimpleFails on first reject
allSettled + manualNo class neededWire up events yourself
Async iterationBackpressureDifferent model