Web Workers: How to Stop Heavy JavaScript From Freezing Your UI
3 min read
The bug report says the app "freezes" when users upload a big file. Nothing is broken — the parsing code is correct, it finishes, the data is fine. But for four seconds the page is a photograph: no clicks, no scrolling, no spinner animation, and the browser offers to kill your tab. I hit exactly this building a data tool that parses large spreadsheets in the browser, and the fix — Web Workers — deserves to be far more routine than it is.
Why your UI freezes during heavy computation
JavaScript's main thread does everything: your code, layout, painting, and responding to input, one task at a time (the event loop in action). While a long computation runs, the loop can't advance — clicks queue up unanswered, animations stop mid-frame, even the spinner you showed before starting freezes. That last one is the cruel irony: the loading indicator you added for the slow task is itself starved by the slow task.
The threshold is lower than people think. Frames render every ~16ms; anything over ~50ms is a perceptible hitch, and a few hundred milliseconds feels broken. Parsing a 50MB CSV takes seconds.
What a Web Worker actually does
A worker runs your script on a separate thread with its own event loop. It shares nothing with the page — no DOM access, no shared variables — and communicates only by message passing. That isolation is the deal: you give up direct access, and in exchange your heavy work runs truly in parallel while the main thread keeps painting frames and answering clicks.
A basic worker, wired up
The worker file — it computes; that's all it does:
// worker.js
self.onmessage = (event) => {
const { rows } = event.data;
const summary = rows.reduce(
(acc, row) => {
acc.total += row.value;
acc.count += 1;
return acc;
},
{ total: 0, count: 0 }
);
self.postMessage({ summary });
};The main thread — it delegates and stays responsive:
// main.js
const worker = new Worker('worker.js');
worker.postMessage({ rows: hugeArray }); // hand off the work
worker.onmessage = (event) => {
render(event.data.summary); // main thread was free this whole time
};
worker.onerror = (err) => showError(err.message);postMessage in, onmessage out — that's the entire API surface you need to start.
The UI thread does nothing but delegate and receive, so the spinner spins, scroll works,
and the browser never threatens your tab.
The real use case: processing an uploaded file
In my spreadsheet tool, the pipeline looks like this: the user drops a file; the main
thread immediately shows progress UI and hands the raw file to a worker; the worker
parses, type-detects, and summarizes, posting progress updates as it goes
(postMessage({ progress: 0.4 })); the main thread renders each update; the final
message carries the parsed result. A file that once froze the page for four seconds now
parses behind a live progress bar — same work, same duration, entirely different
experience. Two practical notes from that build:
FileandBlobobjects can be posted to workers directly — parse in the worker rather than reading the file on the main thread first.- Chunk your progress messages (every N rows, not every row) — messaging has overhead, and thousands of tiny updates recreate the jank you left.
Limitations worth knowing upfront
- No DOM access. Workers can't touch elements,
document, orwindow. Compute in the worker, render on the main thread — the separation is absolute. - Message payloads are copied, not shared (structured clone). Posting a giant object
costs serialization time on both ends. For large binary data, use transferable
objects —
postMessage(buffer, [buffer])moves an ArrayBuffer in O(1) instead of copying it. - Workers aren't free — each has startup cost and memory. Reuse one long-lived worker (or a small pool) rather than spawning per task.
- Debugging is slightly removed — workers appear as separate threads in DevTools; fine once you know to look.
The decision rule I use: if a computation can exceed ~50ms on a mid-range phone — parsing, compression, image processing, big transforms, crypto — it belongs in a worker. Your code was never the problem; the address it ran at was. Move it, and the freeze reports stop.