Web Workers — computation that doesn’t freeze the UI
The same heavy task: on the main thread it freezes the page, in a Web Worker it runs in the background — the spinner and FPS never skip. Feel the difference live.
JS in the browser is single-threaded: while a heavy loop runs, the page can’t paint or react to clicks. I wanted to show that with your hands — make the freeze something you can feel — and right next to it, watch a worker remove it.
A worker is a real second OS thread, not just “async”. It genuinely computes in parallel, but has no DOM access and talks only via postMessage (structured clone copies the data). For big arrays there are transferable buffers — handed over without a copy, in nanoseconds.
The first version built the worker from a Blob URL — fewer files. But the site’s strict CSP (default-src self, no blob:) killed it. I served the worker as a static file /workers/heavy-task.js and added worker-src self to the CSP — cleaner anyway: same-origin instead of inline code.
Why the UI freezes at all
JavaScript in the browser runs on one thread. The same thread runs your code, lays out the page, paints a frame and handles clicks. While it’s stuck in a synchronous loop for a couple of seconds, the page is dead: animations stall, buttons don’t click, the caret in a text field stops blinking.
Below is a liveness indicator driven by requestAnimationFrame (spinner, FPS and a pulse). Hit “On the main thread” — that same rAF loop lives on the busy thread, so everything freezes for the duration. Then hit “In a Web Worker” and feel the difference.
How it works
new Worker('/workers/heavy-task.js') spins up a separate thread. Communication is messages only:
// main thread
worker.postMessage(40_000_000);
worker.onmessage = (e) => console.log(e.data.ms);
// worker.js — its own global scope, no window, no DOM
self.onmessage = (e) => {
let acc = 0;
for (let i = 0; i < e.data; i++) acc += Math.sqrt(i) * Math.sin(i);
self.postMessage({ acc });
};The heavy computation in the demo (√·sin over 40M iterations) is identical in both cases — only where it runs changes. On the main thread the UI stall ≈ the task time; in the worker it stays a steady ~16 ms per frame.
What else workers do
This is the most visceral case, but not the only one:
- Background fetch & parsing — pull and parse a large JSON without hanging the render.
- SharedWorker — one thread across several tabs: a shared socket, state synced between windows of the same site.
- OffscreenCanvas + transferables — filter/decode images inside the worker, returning pixels as a zero-copy buffer.
Each deserves its own note; here the point was the root idea: don’t compute where you paint.