Published on
10 min read

Web Workers: keep the UI responsive during heavy computation

Authors
  • Learna
    Name
    Learna

An input stops responding while your page calculates something. Wrapping the calculation in an async function changes its return value, but the arithmetic still runs on the same JavaScript thread. A Promise.then() callback also runs JavaScript there; it does not allocate a CPU thread. See the Promise execution model.

A dedicated Web Worker gives that calculation its own execution context, with a separate event loop. The page sends data, the worker computes, and the page renders the reply. Workers cannot directly edit the page's DOM. This separation is useful for image processing, parsing large inputs, numerical simulations and other CPU work that interrupts interaction. MDN explains the worker model and available APIs.

Moving work is not a promise of faster completion. Worker startup, messaging, data copying and competition for the same hardware all cost time. Your UI can become more responsive even when the answer takes longer. First establish that CPU work is the problem; a worker will not fix a slow server or an unnecessarily expensive algorithm.

Run these examples locally

These are standalone browser demos, not code to paste into this blog's application. Create a disposable directory with primes/ and mix/ subdirectories. Save each fence under its stated filename. Nothing runs until you open your local page and click a button.

From the parent directory, serve the files with an existing Python 3 installation:

python3 -m http.server 8000 --bind 127.0.0.1

Open http://127.0.0.1:8000/primes/ or http://127.0.0.1:8000/mix/ in a current browser. Use another unused port if necessary. Stop the server with Ctrl+C. No package installation, remote binary, framework or build step is involved. Use HTTP rather than double-clicking an HTML file: module loading and worker origin rules apply. new URL('./worker.js', import.meta.url) resolves relative to the calling module; { type: 'module' } enables module syntax. These are the standard module-worker loading rules.

Example 1: count primes without blocking typing

We deliberately use trial division: enough real CPU work to observe, with a strict upper bound. An efficient sieve would be a better algorithm for many production prime-counting workloads. The input includes the upper limit; 10 has four primes and 100 has twenty-five.

<!-- primes/index.html -->
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Prime worker</title>
<label>Upper limit <input id="limit" type="number" min="2" max="2000000" value="500000"></label>
<button id="run">Count / replace job</button>
<button id="cancel">Cancel</button>
<p><label>Type while it works <input></label></p>
<p id="output" role="status">Ready</p>
<script type="module" src="./main.js"></script>
</html>
// primes/main.js
const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
const output = document.querySelector('#output');
const run = document.querySelector('#run');
let latest = 0;

run.onclick = () => {
  const id = ++latest;
  output.textContent = 'Counting…';
  worker.postMessage({ type: 'count', id, limit: Number(document.querySelector('#limit').value) });
};
document.querySelector('#cancel').onclick = () => {
  worker.postMessage({ type: 'cancel', id: latest });
  latest++; // Also invalidate a result already queued for the page.
  output.textContent = 'Cancelled';
};
worker.onmessage = ({ data }) => {
  if (data.id !== latest) return;
  if (data.type === 'result') output.textContent = `${data.count} primes`;
  if (data.type === 'error') output.textContent = data.message;
};
worker.onerror = () => {
  latest++;
  output.textContent = 'Worker failed. Reload to retry.';
  run.disabled = true;
};
addEventListener('pagehide', () => worker.terminate());
addEventListener('pageshow', (event) => {
  if (event.persisted) location.reload(); // Recreate after a back/forward cache restore.
});
// primes/worker.js
let generation = 0;
let activeId = null;

self.onmessage = ({ data }) => {
  if (data.type === 'cancel') {
    if (data.id === activeId) {
      generation++;
      activeId = null;
    }
    return;
  }
  if (data.type !== 'count') return;
  const { id, limit } = data;
  if (!Number.isSafeInteger(id) || !Number.isInteger(limit) || limit < 2 || limit > 2_000_000) {
    postMessage({ type: 'error', id, message: 'Choose an integer from 2 to 2000000.' });
    return;
  }
  const token = ++generation;
  activeId = id;
  let candidate = 2;
  let count = 0;

  function chunk() {
    if (token !== generation) return;
    const end = Math.min(candidate + 2_000, limit + 1);
    for (; candidate < end; candidate++) {
      let prime = true;
      for (let divisor = 2; divisor * divisor <= candidate; divisor++) {
        if (candidate % divisor === 0) { prime = false; break; }
      }
      if (prime) count++;
    }
    if (candidate <= limit) setTimeout(chunk, 0);
    else {
      activeId = null;
      postMessage({ type: 'result', id, count });
    }
  }
  chunk();
};

Try 100, then the default 500000: expect 25 primes and 41538 primes. Type in the other input while the longer job runs. Click Count again to replace it, or Cancel to abandon it. No duration is guaranteed; use browser CPU throttling if your machine finishes too quickly to observe.

The request ID answers which calculation may update the page? The worker's generation answers which calculation should keep consuming CPU? They solve different problems. Discarding stale results protects the UI, but does not by itself cancel work. Cancelling work also cannot recall a result already queued for delivery, hence the page's ID check.

The worker processes one chunk synchronously, then yields with a timer so it can receive messages. A cancel message cannot interrupt an uninterrupted loop. Repeated await Promise.resolve() would only yield to microtasks, which is insufficient for processing the next message task. Chunk size bounds work between opportunities to cancel, not wall-clock latency: timers, scheduling and device speed still matter.

worker.terminate() stops a worker immediately, without giving it a cleanup opportunity. It is useful when disposing a worker or abandoning a non-cooperative calculation; you must create a replacement before starting another job. Our Cancel button keeps the worker reusable by cancelling cooperatively. The page uses termination on exit. If the browser restores this demo from its back/forward cache, the pageshow handler reloads it to recreate the terminated worker; any previous demo state is discarded. The termination API documents this distinction.

Messages: copying data versus moving ownership

Ordinary messages use the structured clone algorithm. They do not share the same mutable objects between the two contexts. Functions and DOM nodes are not ordinary message payloads. Pass small explicit records such as { type, id, limit }, not an application service or component instance. Structured clone defines the supported data types.

For a large binary payload, transferring its ArrayBuffer avoids copying that buffer. Include it both in the message and the transfer list. Afterwards the sender's buffer is detached: its byteLength is zero and its old views cannot continue using the storage. The receiver owns it until transferring it back. Typed arrays are views; transfer their underlying buffer. This is ownership transfer, not shared memory. MDN describes transfer lists and detachment.

Example 2: the same bounded kernel in JavaScript and Wasm

A worker decides where work runs. WebAssembly decides how a kernel is represented and executed. Wasm on the main thread can still block interaction. Here both versions run inside a worker, processing identical inputs with identical unsigned 32-bit overflow semantics.

Our workload repeatedly applies x = x * 1664525 + 1013904223, modulo 2³², to each seed. This is a deterministic arithmetic exercise, not cryptography or a recommended random-number generator. The default performs 4096 × 8192 = 33,554,432 iterations. We keep the kernel small so every instruction is inspectable; real application speed depends on its actual algorithm and data.

The following is the complete textual module, mix/kernel.wat, for audit. Browsers do not execute WAT directly. The equivalent 75 bytes in kernel.js make this demo runnable without a compiler download. There are no imports, memory or external binaries. The WAT guide explains the notation; the Wasm specification defines instruction encodings.

;; mix/kernel.wat (audit source; not loaded by the browser)
(module
  (func (export "mix") (param $seed i32) (param $rounds i32) (result i32)
    block $done
      loop $again
        local.get $rounds
        i32.eqz
        br_if $done
        local.get $seed
        i32.const 1664525
        i32.mul
        i32.const 1013904223
        i32.add
        local.set $seed
        local.get $rounds
        i32.const 1
        i32.sub
        local.set $rounds
        br $again
      end
    end
    local.get $seed))
// mix/kernel.js
export function mixJS(seed, rounds) {
  let value = seed >>> 0;
  for (let round = 0; round < rounds; round++) {
    value = (Math.imul(value, 1_664_525) + 1_013_904_223) >>> 0;
  }
  return value;
}

export const wasmBytes = new Uint8Array([
  0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x07, 0x01, 0x60,
  0x02, 0x7f, 0x7f, 0x01, 0x7f, 0x03, 0x02, 0x01, 0x00, 0x07, 0x07, 0x01,
  0x03, 0x6d, 0x69, 0x78, 0x00, 0x00, 0x0a, 0x2b, 0x01, 0x29, 0x00, 0x02,
  0x40, 0x03, 0x40, 0x20, 0x01, 0x45, 0x0d, 0x01, 0x20, 0x00, 0x41, 0x8d,
  0xcc, 0xe5, 0x00, 0x6c, 0x41, 0xdf, 0xe6, 0xbb, 0xe3, 0x03, 0x6a, 0x21,
  0x00, 0x20, 0x01, 0x41, 0x01, 0x6b, 0x21, 0x01, 0x0c, 0x00, 0x0b, 0x0b,
  0x20, 0x00, 0x0b,
]);
<!-- mix/index.html -->
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Worker kernel comparison</title>
<button id="js">Run JavaScript</button>
<button id="wasm">Run Wasm</button>
<p><label>Type while it works <input></label></p>
<p id="output" role="status">Ready</p>
<script type="module" src="./main.js"></script>
</html>
// mix/main.js
const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
const output = document.querySelector('#output');
const js = document.querySelector('#js');
const wasm = document.querySelector('#wasm');
let latest = 0;
let started = 0;

function start(engine) {
  const id = ++latest;
  js.disabled = wasm.disabled = true; // One job at a time.
  started = performance.now();
  const values = Uint32Array.from({ length: 4_096 }, (_, index) => index);
  const buffer = values.buffer;
  worker.postMessage({ type: 'mix', id, engine, rounds: 8_192, buffer }, [buffer]);
  output.textContent = `Working; sender buffer now has ${buffer.byteLength} bytes`;
}
js.onclick = () => start('js');
wasm.onclick = () => start('wasm');
worker.onmessage = ({ data }) => {
  if (data.id !== latest) return;
  const elapsed = performance.now() - started;
  js.disabled = wasm.disabled = false;
  if (data.type === 'error') { output.textContent = data.message; return; }
  const values = new Uint32Array(data.buffer);
  output.textContent = `First: ${values[0]}; kernel: ${data.kernelMs.toFixed(1)} ms; elapsed: ${elapsed.toFixed(1)} ms`;
};
worker.onerror = () => {
  latest++;
  output.textContent = 'Worker failed. Reload to retry.';
  js.disabled = wasm.disabled = true;
};
addEventListener('pagehide', () => worker.terminate());
addEventListener('pageshow', (event) => {
  if (event.persisted) location.reload(); // Recreate after a back/forward cache restore.
});
// mix/worker.js
import { mixJS, wasmBytes } from './kernel.js';
let compiled;

self.onmessage = async ({ data }) => {
  const { id, engine, rounds, buffer } = data;
  try {
    if (data.type !== 'mix' || !['js', 'wasm'].includes(engine) ||
        !Number.isInteger(rounds) || rounds < 0 || rounds > 8_192 ||
        !(buffer instanceof ArrayBuffer) || buffer.byteLength % 4 !== 0 ||
        buffer.byteLength > 4_096 * 4) {
      throw new Error('Invalid or excessive work');
    }
    let kernel = mixJS;
    if (engine === 'wasm') {
      compiled ??= WebAssembly.instantiate(wasmBytes);
      kernel = (await compiled).instance.exports.mix;
    }
    const values = new Uint32Array(buffer);
    const started = performance.now();
    for (let index = 0; index < values.length; index++) {
      values[index] = kernel(values[index], rounds) >>> 0;
    }
    const kernelMs = performance.now() - started;
    postMessage({ type: 'result', id, buffer, kernelMs }, [buffer]);
  } catch {
    postMessage({ type: 'error', id, message: 'Kernel failed; reload or try another engine.' });
  }
};

Both buttons must produce First: 1695850496; mixJS(0, 1) is 1013904223, and zero rounds preserve the seed. Wasm returns an i32; >>> 0 interprets the result as unsigned, matching the typed array and JavaScript kernel. Math.imul supplies the low 32 bits of multiplication without relying on imprecise large floating-point products.

The worker instantiates the bytes once on the first Wasm request. The reported kernel time excludes that setup; page elapsed time includes input creation, queueing, any first-use setup and delivery. Worker construction starts before the button click, so elapsed time is not a complete startup benchmark. Each button creates fresh seeds because the previous input buffer was transferred away. On an error after transfer, those seeds are intentionally abandoned; a new click recreates them.

This second calculation is bounded but deliberately synchronous inside the worker. It has no cooperative Cancel button. A cancel message added without chunking would wait until computation finished; termination could stop it, at the cost of destroying the worker and its in-flight buffer.

Measure the experience and the calculation separately

Use a browser performance recording to inspect main-thread long tasks and interaction while the worker runs. Compare correctness first, then measure representative data on the devices you support. Keep the input, iteration count and output handling equal. Separate cold compilation/startup from repeated warm runs, alternate engine order, and record distributions rather than choosing the best run. Our Wasm boundary is crossed once per seed, which itself costs time; this tiny kernel cannot predict whether your image codec or parser benefits from Wasm.

The article's automated checks execute the actual JavaScript fences, validate the embedded Wasm module, and compare both kernels against independent integer arithmetic. A deterministic message harness checks cancellation and stale results; Node's structured clone checks buffer detachment. Those checks do not establish browser scheduling, actual Worker transfer, MIME/origin behavior or smooth rendering. Verify those in the local browser demo. A slow CPU, busy tab or thermal limits can change both responsiveness and completion time.

Choose a worker when independent CPU work interferes with interaction and the messaging cost is justified. Choose Wasm when an appropriate implementation and measurements justify that additional representation. They can work together, but they answer different engineering questions.