Your CSV upload locks the page for four seconds. Your image filter freezes inputs the moment a user drags the slider. Your JSON parse spikes INP to 800ms and your Core Web Vitals dashboard lights up red.
You go looking for a javascript web workers tutorial. You find prime number demos. Hello-world. The Fibonacci sequence, again, somehow.
None of them help when your real app is the thing that’s frozen.
This is three production patterns — JSON parsing, CSV streaming, image filtering — with the postMessage gotchas and the Transferable objects trap that quietly makes your “optimization” slower than the code it replaced.
Why You’re Reading This (And Why INP Made It Urgent)
Workers aren’t new. They’ve had 97%+ browser support since before TypeScript was cool. What changed is the cost of ignoring them.
JavaScript runs on one main thread. Heavy work — parsing, sorting, pixel math — blocks rendering and blocks user input. If you’re not sure whether that’s why your website is slow, it probably is. You knew that already. What you might not have noticed: in March 2024, INP replaced FID as a Core Web Vital. INP measures your worst interaction latency, not your median. That means a single 400ms handler on a single button click is now an SEO problem, not just a UX one. The web performance checklist covers where workers fit alongside every other fix.
Workers run in a background thread. They can’t touch the DOM. They can do everything else — the math, the parsing, the pixel work that was killing your INP. ES module workers ship in every major browser. Vite and esbuild bundle them natively in 2026 — no webpack plugins, no worker-loader configuration archaeology.
So workers are mandatory now. But every tutorial still makes them look awkward. There’s a reason for that, and it’s worth covering before any of the patterns make sense.
The postMessage Cost Nobody Mentions
postMessage is not free.
Every payload between the main thread and a worker is structured-cloned by default. The browser walks the object graph, serializes it, deserializes it on the other side, and hands you a copy. For small messages — a config object, a progress percentage — the cost is invisible. For large ones, it isn’t. A 500MB payload takes roughly 149ms to clone in Chrome and 380ms in Firefox. Half a second of “background work” that’s actually blocking your main thread, twice.
Transferable Objects fix the big-buffer case. Instead of copying, ownership moves: the source side loses access, the destination side picks it up at zero cost. That same 500MB drops from 149ms to about 1ms.
Sounds like the answer. It is — until you reach for it in the wrong shape.
Here’s the trap: many small Transferable transfers are exponentially worse than cloning. In one benchmarked case, transferring 200,000 small chunks took 7.6 seconds versus 170ms with structured clone. Each transfer has fixed overhead. Batch them and the math destroys you.
The rule that actually works in production:
- Transferable Objects for a few large buffers —
ArrayBuffer,ImageData,Uint8Arraybacked by megabytes. - Structured clone for many small messages — progress updates, parsed records, config.
- Batch always — never
postMessageper item in a loop.
That single rule explains why each of the three patterns below looks the way it does.
Pattern 1: Parse Large JSON Without Freezing the UI
The scenario: your analytics dashboard fetches an 8MB JSON response. JSON.parse on the main thread is a 600ms blocking task. Your INP is destroyed every time the user clicks “Refresh.”
Move the parse into a module worker. Vite and esbuild handle this with no config:
// parser.worker.js
self.onmessage = (e) => {
const data = JSON.parse(e.data);
self.postMessage(data);
};
// main.js
const worker = new Worker(
new URL('./parser.worker.js', import.meta.url),
{ type: 'module' }
);
worker.onmessage = (e) => render(e.data);
const res = await fetch('/api/analytics');
const text = await res.text(); // text clones cheaply
worker.postMessage(text); // one message in
// one message out
The raw text travels in cheaply — strings clone fast. The parsed result travels back as one batched message, not one message per record. That’s the “batch always” rule from the last section, applied.
If worker.onmessage and postMessage(text) spaghetti is what makes workers feel awkward to you, Comlink is the cleanup:
// parser.worker.js
import { expose } from 'comlink';
expose({ parse: (text) => JSON.parse(text) });
// main.js
import { wrap } from 'comlink';
const parser = wrap(new Worker(/* … */, { type: 'module' }));
const data = await parser.parse(text); // just an async function
The parse still takes 600ms. It doesn’t get faster. But the main thread is free the entire time, and INP drops from ~620ms to ~80ms. Your dashboard “feels instant” the same way it would if the parse were faster. For the user — and for Google — those are the same thing.
That works when you have one big result. What about a 100K-row CSV where the user expects a progress bar?
Pattern 2: Stream a 100K-Row CSV With Progress
A user uploads a 100,000-row CSV. Parsing it on the main thread freezes the tab for four seconds. Even moved to a worker, you don’t want it to be a black box — users need progress, not a spinner of faith.
Stream the file inside the worker. Send progress as small messages. Send the final dataset as one batched message at the end.
// csv.worker.js
self.onmessage = async ({ data: file }) => {
const reader = file.stream()
.pipeThrough(new TextDecoderStream())
.getReader();
const rows = [];
let processed = 0;
let lastReport = 0;
const totalBytes = file.size;
while (true) {
const { value, done } = await reader.read();
if (done) break;
rows.push(...parseChunk(value));
processed += value.length;
const pct = Math.floor((processed / totalBytes) * 100);
if (pct - lastReport >= 5) { // ~20 progress messages total
self.postMessage({ type: 'progress', pct });
lastReport = pct;
}
}
self.postMessage({ type: 'done', rows }); // one batched payload
};
Two things matter here. First, progress updates are small structured-clone messages — at twenty of them per parse, clone cost is irrelevant. Second, the rows ship as one final message, not 100,000 individual ones. If you postMessage per row, the messaging overhead will cost more than the parse itself. That’s the Transferable trap from earlier, applied to clones.
The main thread does almost nothing: it listens for progress and updates a progress bar, then listens for done and renders the table. The UI stays responsive through all four seconds of parsing because none of those four seconds happen on the main thread.
Text payloads are one case. Binary payloads — image pixels, audio buffers — are where Transferable Objects finally earn their keep.
Pattern 3: Apply Image Filters Off the Main Thread
A 12-megapixel image. A grayscale filter, or a blur, or a channel mix. Pixel-by-pixel math on the main thread takes roughly 400ms per pass. The user moves a slider; the slider freezes. Your in-browser photo editor feels like a 2009 Java applet.
ImageData from canvas is exactly the shape Transferable Objects were designed for: one large ArrayBuffer-backed object, millions of bytes, transferred once.
// filter.worker.js
self.onmessage = ({ data: { imageData } }) => {
const px = imageData.data;
for (let i = 0; i < px.length; i += 4) {
const gray = px[i] * 0.299 + px[i+1] * 0.587 + px[i+2] * 0.114;
px[i] = px[i+1] = px[i+2] = gray;
}
self.postMessage({ imageData }, [imageData.data.buffer]);
};
// main.js
const imageData = ctx.getImageData(0, 0, w, h);
worker.postMessage({ imageData }, [imageData.data.buffer]);
worker.onmessage = ({ data }) => ctx.putImageData(data.imageData, 0, 0);
The second argument to postMessage — [imageData.data.buffer] — is the transfer list. Ownership moves both ways. The main thread loses access to the buffer while the worker has it, and gets it back when the worker is done. Total transfer cost: roughly 1ms each way, regardless of image size.
If you don’t need the pixels back on the main thread at all, OffscreenCanvas is the next step up. Transfer the canvas itself into the worker once with canvas.transferControlToOffscreen(), render directly from there, and skip the round-trip entirely.
INP on the filter button drops from ~420ms to ~50ms. The 400ms of pixel work still happens — it just happens somewhere your user can’t feel it.
The patterns work. But the moment your user clicks “next page” in your SPA, you have a different problem.
The Cleanup Nobody Writes About: Workers in SPAs
SPA route changes don’t terminate workers.
If your CSV uploader unmounts mid-parse, the worker keeps the file in memory until the tab closes. Hit “back” three times and you’re holding three CSVs and three workers. None of the tutorials mention this because none of the tutorials are running inside a real SPA.
Call worker.terminate() in your cleanup path. Every framework has one: useEffect return value in React, onUnmounted in Vue, ngOnDestroy in Angular, the disconnected callback in a Web Component. For a more general-purpose cleanup pattern, the JavaScript using keyword handles automatic disposal — useful when workers share a scope with file handles or database connections. If you’ve shipped vanilla JavaScript instead of a framework, it’s your route handler.
For workers that get reused across the app — one image filter worker driving every editor screen — pool them. Create once, reuse, terminate only when the user actually leaves.
Errors deserve their own line. worker.onerror catches thrown errors inside the worker. worker.onmessageerror catches structured-clone failures — the message that contained a function reference, or a DOM node, or anything else that can’t be cloned. They are different events. If you only wire one, you’ll spend an afternoon debugging silence.
Comlink folds both into normal Promise rejection — try/catch works the way you’d expect. That’s a second reason to reach for it the moment your app has more than one worker.
The Bottom Line
The frozen UI from the opening — the JSON parse, the CSV upload, the image filter — has a pattern for each one in this article. The pattern is the easy part. The rules behind the patterns are what matter once you’re shipping more than a demo:
postMessageisn’t free. Batch your results.- Transferable Objects are for a few large buffers, not many small ones.
- Always terminate workers on unmount.
- Reach for Comlink the moment you have more than one worker.
Web workers won’t make your code faster. They’ll make it feel faster — which, for INP and for your users, is the same thing.
If you ship one pattern this week, ship the JSON parse. Open the Performance panel in Chrome DevTools, record the offending interaction before and after, and watch the long task collapse. It’s the cheapest INP win in your codebase, and it’s almost certainly waiting on you. If you want the bigger picture, the INP guide covers what to measure next.