Appearance
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
| Approach | Pros | Cons |
|---|---|---|
ProgressPromise | Rich metrics, events | Needs class |
Promise.all + counter | Simple | Fails on first reject |
allSettled + manual | No class needed | Wire up events yourself |
| Async iteration | Backpressure | Different model |