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.

Three ways to get progress from Wasm to the screen A shared counter in memory is the cheapest per update and is read by the UI once per frame. Messages at chunk boundaries need no shared memory but update less smoothly. A callback per update is simplest and too expensive for fine-grained progress. shared counter Wasm writes a number to shared memory UI reads it each animation frame near-zero cost per update smooth, cheap, needs isolation messages per chunk worker posts progress every N items UI updates on message no shared memory needed good default callback per update Wasm calls JS for every item JS posts or updates DOM each time dominates run time if frequent only for coarse updates

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.

Progress through a shared counter The Wasm loop updates a counter with atomic stores as it works. The main thread's animation-frame callback reads the counter once per frame and updates the progress bar, so producer and consumer run at their own rates with no messages in between. Wasm loop work on item i Atomics. store(done) nanoseconds shared memory [done, total] frame callback read once per frame progress bar smooth, 60 updates/s max

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 BigInt64Array with Atomics for 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.

Cost of progress reporting on a 2,000-frame encode Total encode time in a worker with no progress, with a callback and message for every frame, with throttled messages every 16 frames, and with a shared counter updated every frame. seconds to encode 2,000 frames no progress 6.2 s message every frame 7.0 s throttled messages 6.2 s shared counter every frame 6.2 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.

← Back to Async & Event-Loop Integration