Web Workers
Web Workers
How to run heavy code in parallel without freezing the UI
#JavaScript #WebWorkers #WebPerformance #WebAPIs
The Frozen UI
Imagine this scenario: Your dashboard loads a CSV. The user clicks "Analyze." You run a complex aggregation across 2 million rows. The browser stops. The cursor spins. The user taps their fingers. Then they close the tab.
The calculation finished in 400ms. But the UI was frozen for those entire 400ms. That's the problem Web Workers solve — not slowness, but blocking.
Every dashboard eventually hits the same wall: you run a heavy calculation — parsing, sorting, aggregating, compressing, encoding — and the browser stutters. The UI lags. Buttons stop responding. The tab feels "heavy."
This isn't because JavaScript is slow. It's because the main thread is busy.
Web Workers and Background Threads
Web Workers solve this problem by giving your application background threads — real parallel execution — so heavy work happens off the main thread while the UI stays smooth.
If File System Access gives your dashboard files, IndexedDB gives it state, and Streams give it throughput, then Web Workers give it parallelism. This is the fourth pillar of a local-first architecture.
A Separate JavaScript Execution Environment
A Web Worker is a separate JavaScript execution environment that runs in parallel with the main thread. It has its own event loop, its own memory space, and no access to the DOM. You communicate with it by sending messages — structured data that the browser transfers efficiently between threads.
The mental model is simple: the main thread handles UI, and the worker handles heavy computation.
One important caveat: Workers have startup cost — typically 10-50ms to spin up. For tiny operations, a worker can actually be slower than running on the main thread. Workers shine for sustained or substantial work, not for one-off micro-optimizations.
Your UI Stays Responsive
Let me give you a concrete example.
Imagine a dashboard analyzing 10 years of sales data — 5 million rows. Without workers, filtering and aggregating that data freezes the UI for 8 seconds. The user can't scroll, can't click, can't cancel. They just wait.
With workers, the UI stays responsive. And here's where it gets interesting: you can spawn four parallel workers, each processing a different year range. The same 8-second operation finishes in 2 seconds — and the UI never blocked once.
To see this horizontal scaling in action:
Chunk 1 (2020-2021) → Worker A ─┐
Chunk 2 (2022-2023) → Worker B ─┼─→ Results combined → UI
Chunk 3 (2024-2025) → Worker C ─┘
Chunk 4 (2026) → Worker D
That's not just "workers exist." That's vertical and horizontal parallelism for free.
Creating Your First Web Worker
A worker is just a separate JavaScript file:
// worker.js
self.onmessage = event => {
const data = event.data;
const result = heavyComputation(data);
self.postMessage(result);
};
And you use it from the main thread like this:
const worker = new Worker("worker.js");
worker.onmessage = event => {
console.log("Result:", event.data);
};
worker.postMessage({ numbers: [1, 2, 3] });
The main thread stays responsive while the worker crunches numbers in the background.
Web Workers Together With Streams
This is where things get genuinely powerful. Streams handle memory efficiency — processing data in chunks without loading entire files. Workers handle parallelism — processing those chunks without blocking the UI.
Together, they form a pipeline that combines the best of both worlds:
ReadableStream → Chunks → Worker → Processed Chunks → UI Update
Here's a complete, working example that reads a massive CSV file from disk, streams it chunk by chunk into a worker for heavy processing, and updates the UI progressively — never blocking, never loading the whole file:
// main.js — the main thread
const file = await fileHandle.getFile();
const stream = file.stream();
// Create a worker for heavy processing
const worker = new Worker('data-processor.js');
// Set up UI progress
const progressDiv = document.getElementById('progress');
let chunksProcessed = 0;
// Stream chunks directly to the worker
const reader = stream.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
// Send each chunk to the worker
worker.postMessage(value);
chunksProcessed++;
progressDiv.textContent = `Sent ${chunksProcessed} chunks to worker...`;
}
// Listen for results from the worker
worker.onmessage = event => {
const { type, data } = event.data;
if (type === 'progress') {
progressDiv.textContent = `Worker processed ${data.rows} rows...`;
} else if (type === 'result') {
console.log('Final result:', data);
renderDashboard(data);
}
};
worker.onerror = error => {
console.error('Worker error:', error);
};
And the worker itself:
// data-processor.js — the worker thread
let totalRows = 0;
let aggregateData = {};
self.onmessage = event => {
const chunk = event.data;
// Decode and parse the chunk
const textChunk = new TextDecoder().decode(chunk);
const lines = textChunk.split('\n');
// Heavy computation — this runs in the background, UI stays smooth
for (const line of lines) {
if (!line.trim()) continue;
const parsed = parseCSVLine(line); // imaginary parsing function
totalRows++;
// Heavy aggregation — maybe sum, average, complex transforms
aggregateData = updateAggregation(aggregateData, parsed);
// Send progress updates back to main thread every 1000 rows
if (totalRows % 1000 === 0) {
self.postMessage({
type: 'progress',
data: { rows: totalRows }
});
}
}
// Send final result back to main thread
self.postMessage({
type: 'result',
data: { totalRows, aggregateData }
});
};
Heavy Computations in the Background
The file was streamed in chunks — memory efficient. Each chunk was sent to a worker — UI never blocked. The worker processed heavy computations in the background — parallelism. Progress updates flowed back to the UI — user stayed informed. And at no point did the entire 5 million rows live in memory at once.
This is how you build high‑performance local dashboards. Streams for memory. Workers for parallelism. Together, they're unstoppable.
Move Binary Buffers Between Threads
Workers support transferable objects, which let you move large binary buffers between threads without copying them. This is essential for performance.
worker.postMessage(buffer, [buffer]);
After this call, the main thread no longer owns the buffer — the worker does. Zero copying. Zero overhead. Maximum throughput. This is how you move megabyte‑ or even gigabyte‑sized data between threads without slowing down.
A Note on SharedArrayBuffer
For even more performance, workers can share memory using SharedArrayBuffer. This allows true parallelism with coordinated access using Atomics. This is how you build real‑time dashboards, concurrent parsers, multi‑threaded data pipelines, and local AI inference loops. It's the closest the browser gets to low‑level parallel programming — but start with basic workers first. The transferable object pattern covers 95% of use cases.
Web Workers are the Fourth Pillar
At this point in the series, your dashboard can:
- read real files (File System API)
- store real state (IndexedDB)
- stream real data (Streams API)
- process real workloads in parallel (Web Workers)
This is no longer a "web app." This is a local application that happens to run in a browser tab — with performance characteristics that rival native desktop software.
Web Workers complete the foundation. They give your dashboard the ability to stay smooth and responsive even under heavy load. And when combined with Streams, they unlock a level of local data processing that most developers don't even know is possible in a browser.
Next Up: Broadcast Channel
Because once you have workers running in parallel and your UI staying smooth, the next challenge is keeping multiple dashboard tabs in sync — without a server, without a database, without a network round-trip. That's where Broadcast Channel enters the picture.
Aaron Rose is a software engineer and technology writer at tech-reader.blog.
Catch up on the latest explainer videos, podcasts, and industry discussions below.
.jpeg)
