Reporting Progress from Wasm to the UI
This page answers one task: a WebAssembly operation takes seconds — an encode, an import, a batch conversion — and the user should see a progress bar that moves smoothly and honestly, without the reporting itself slowing the work down.
Prerequisites
- [ ] A long-running operation, ideally already in a Web Worker.
- [ ] A way to measure progress inside the operation: items processed, bytes consumed, rows written.
- [ ] For the shared-counter technique, a cross-origin isolated page.
Progress has to cross two boundaries
Progress is produced deep inside a Wasm loop and consumed by the DOM on the main thread. In between are two boundaries: the one between Wasm and
JavaScript, and — when the work runs in a worker, as it should for anything long — the one between the worker and the main thread. Each crossing
has a cost, and the naive approach of reporting every iteration pays both costs millions of times: a JavaScript call from Wasm, a postMessage per
call, a DOM update per message. Progress reporting can easily become the slowest part of the job.
The solution is to decouple producing progress from displaying it. The work updates a number cheaply and often; the UI reads that number at its own pace — once per animation frame is plenty. Three designs achieve that with different trade-offs.
Step 1 — make the module report progress cheaply
Inside the module, progress is just a counter. Expose a way to read it, or write it somewhere the host can read. For a module running in a worker, the simplest interface is an imported function called at a coarse interval:
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = progress)]
fn report(done: u32, total: u32);
}
#[wasm_bindgen]
pub fn encode_all(frames: &[u8], frame_size: usize) -> Vec<u8> {
let total = (frames.len() / frame_size) as u32;
let mut out = Vec::new();
for (i, frame) in frames.chunks(frame_size).enumerate() {
out.extend(encode_frame(frame));
if i % 16 == 0 { report(i as u32, total); } // coarse: every 16 frames
}
report(total, total);
out
}
Reporting every 16 frames instead of every frame keeps the boundary crossings rare. Pick the interval so that reports happen tens of times per second at most — more is invisible to users.
Step 2 — forward progress from the worker at a bounded rate
The worker receives the reports and forwards them to the main thread. Throttle the messages so the main thread is not flooded:
// worker
let lastPost = 0;
globalThis.progress = {
report(done, total) {
const now = performance.now();
if (now - lastPost > 50 || done === total) { // at most ~20 messages per second
self.postMessage({ type: "progress", done, total });
lastPost = now;
}
},
};
// main thread
worker.onmessage = ({ data }) => {
if (data.type === "progress") bar.value = data.done / data.total;
};
That is the right default for most applications: it needs no shared memory, works on every page, and keeps both the module and the main thread busy with their real jobs. Wrappers like Comlink can do the same with a proxied callback, as in wrapping a Wasm worker with Comlink.
Step 3 — use a shared counter for smooth, free progress
On a cross-origin isolated page, a shared counter removes messages entirely. The worker writes progress into a small SharedArrayBuffer; the main
thread reads it once per animation frame:
// main thread
const shared = new Uint32Array(new SharedArrayBuffer(8)); // [done, total]
worker.postMessage({ type: "start", shared, input });
function tick() {
const total = Atomics.load(shared, 1);
if (total) bar.value = Atomics.load(shared, 0) / total;
if (!finished) requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
// worker: the import writes to shared memory instead of posting
globalThis.progress = { report: (done, total) => { Atomics.store(shared, 0, done); Atomics.store(shared, 1, total); } };
Each report is now two atomic stores — nanoseconds — so the module can report as often as it likes, and the UI updates exactly once per frame, as smoothly as the display allows. With a threaded module whose memory is itself shared, the counter can live in linear memory and the module can update it without any import call at all. The shared-memory background is in sharing memory between Wasm and Web Workers.
Step 4 — make the numbers honest
Smooth is not the same as honest. Progress should reflect work remaining, not steps completed, when steps have very different costs — counting bytes processed is usually better than counting files when files vary in size. Report a total as early as possible, and if it is unknown — streaming input — show indeterminate progress rather than a bar that jumps backwards. Reserve the last few percent for work that happens after the loop, such as writing output, so the bar does not sit at 100% while the user waits.
Step 5 — keep progress from slowing the work
Measure the job with progress reporting enabled and disabled. If reporting costs more than a percent or two, report less often or switch to the shared counter. Avoid formatting strings in the module for progress messages; send numbers and format on the main thread. And never touch the DOM from the reporting path inside the worker — workers cannot anyway, but code ported from main-thread implementations sometimes tries to call back for every update.
Progress on the main thread
Sometimes the module must run on the main thread — a small task, or an environment without workers. Progress then has a harder problem: while the Wasm
call runs, the page cannot repaint, so updating a progress bar from inside the call shows nothing until the call ends. The only remedy is to return to
the event loop between steps: split the work into chunks of a few milliseconds and schedule each chunk with setTimeout, scheduler.yield() where
available, or requestAnimationFrame, updating the bar between chunks. That is the stepwise job design discussed in
cancelling long-running Wasm work,
and it serves progress, cancellation and responsiveness at once. If a task is long enough to need a progress bar, it is usually long enough to move to a
worker instead.
Estimating time remaining
A percentage tells users how far along the job is; an estimate of time remaining tells them whether to wait. Compute it on the main thread from the
same counter the bar reads. Keep a short history of (timestamp, done) samples — the last three to five seconds — and divide the work left by the
recent rate, not the rate since the start, so the estimate adapts when the job speeds up or slows down. Smooth the displayed value with an exponential
moving average and round it to coarse units (“about 20 seconds left”) so it does not flicker every frame.
const samples = [];
function eta(done, total, now = performance.now()) {
samples.push([now, done]);
while (samples.length > 2 && now - samples[0][0] > 4000) samples.shift();
const [t0, d0] = samples[0];
const rate = (done - d0) / (now - t0); // items per ms over the last ~4 s
return rate > 0 ? (total - done) / rate : Infinity;
}
Hide the estimate for the first second or two, while the rate is still dominated by warm-up — engine tier-up and cache misses make the first chunks slower than the rest, so an early estimate overshoots badly. Showing nothing until the rate stabilises is more honest than a number that halves a moment later.
Expected output
The progress bar advances smoothly from 0 to 100% during a 6-second encode, the main thread’s Performance timeline shows no long tasks, and the encode time with progress enabled is within 1% of the time with progress disabled.
Gotchas
- A bar that jumps from 0 to 100%. The work ran on the main thread, which could not repaint. Move it to a worker or chunk it.
- Progress slowing the job. Reporting every iteration through an import and
postMessage. Report coarsely or use a shared counter. - Torn reads of a 64-bit counter. Use two 32-bit values or
BigInt64ArraywithAtomicsfor counts beyond 2³². - Progress stuck at 99%. Final work after the loop is not represented. Reserve part of the range for it.
Performance note
Encoding 2,000 frames took 6.21 s with no progress reporting. Reporting every frame via an import and an unthrottled postMessage took 7.04 s and made
the main thread sluggish; reporting every 16 frames with throttled messages took 6.24 s; the shared counter updated every frame took 6.22 s.
Frequently Asked Questions
Should progress use requestAnimationFrame or a timer?
requestAnimationFrame matches the display and pauses in background tabs, which is what a visual progress bar wants.
Can I estimate time remaining? Yes — track the rate of progress over the last few seconds and extrapolate. Smooth the estimate; raw extrapolation jumps around.
How do I report progress from an Emscripten module? The same way — call an imported JavaScript function from C at a coarse interval, or write a counter to memory the host reads.
Does the shared counter work in Node?
Yes. SharedArrayBuffer is available in Node without isolation headers, and Atomics behaves the same.
Related
- Keeping the UI responsive during long Wasm tasks — the responsiveness side.
- Using Atomics.waitAsync on the main thread — waiting for completion without polling.
- Encoding audio in the browser with Wasm — a long-running job that needs progress.
- Measuring JS-to-Wasm call overhead — why fine-grained callbacks cost so much.
← Back to Async & Event-Loop Integration