
Offloading CPU Work to Web Workers Before Your UI Freezes
There's a specific kind of bug that's hard to notice in development but obvious in production: the UI freezing for 200ms when you do something. You click a button, nothing happens, then suddenly everything updates at once. The loading spinner stops spinning. Animations stutter.
The cause is always the same: you did synchronous work on the main thread that took long enough for the browser to drop frames.
Web Workers are the solution. They're not complicated, but they require a different mental model, and there are a few sharp edges worth knowing about.
Why the Main Thread Is Special (and Fragile)
The browser runs JavaScript, layout, paint, and user event handling in the same thread. If your JavaScript takes 50ms to run, that's 50ms where the browser can't respond to clicks, can't animate, can't paint. At 60fps, you have 16ms per frame. A single 50ms JavaScript task eats three frames.
For most things this doesn't matter. A network request is async. A database query is async. The main thread is free while they run.
It matters when you have CPU-bound work: parsing large files, running complex algorithms, processing image data, executing regular expressions against long strings, or even just sorting large arrays.
Web Workers run JavaScript in a separate thread. They can't access the DOM (which is main-thread-only), but they can run arbitrary JavaScript and communicate back via messages.
The Basic Pattern
javascript// worker.js self.addEventListener('message', (event) => { const { type, payload } = event.data switch (type) { case 'PROCESS_DATA': { const result = heavyComputation(payload) self.postMessage({ type: 'RESULT', payload: result }) break } } }) function heavyComputation(data) { // CPU-heavy work here — doesn't block the main thread return data.map(item => expensiveTransform(item)) }
javascript// main.js const worker = new Worker('/worker.js') worker.addEventListener('message', (event) => { const { type, payload } = event.data if (type === 'RESULT') { updateUI(payload) } }) worker.postMessage({ type: 'PROCESS_DATA', payload: largeDataset })
The problem with this raw API: callbacks everywhere, no TypeScript, no error handling, message types are untyped. You end up writing a lot of boilerplate or getting confused about which messages go where.
A Typed Worker Wrapper
Here's what I actually use. The trick is treating the worker as an async function:
typescript// lib/worker-client.ts interface WorkerMessage<T = unknown> { id: string type: string payload: T } interface WorkerResponse<T = unknown> { id: string success: boolean payload?: T error?: string } export function createWorkerClient< TRequests extends Record<string, unknown>, TResponses extends Record<keyof TRequests, unknown> >(workerUrl: string) { const worker = new Worker(workerUrl, { type: 'module' }) const pending = new Map<string, { resolve: Function; reject: Function }>() worker.addEventListener('message', (event: MessageEvent<WorkerResponse>) => { const { id, success, payload, error } = event.data const promise = pending.get(id) if (!promise) return pending.delete(id) if (success) { promise.resolve(payload) } else { promise.reject(new Error(error ?? 'Worker error')) } }) return { call<K extends keyof TRequests & string>( type: K, payload: TRequests[K] ): Promise<TResponses[K]> { return new Promise((resolve, reject) => { const id = crypto.randomUUID() pending.set(id, { resolve, reject }) worker.postMessage({ id, type, payload } satisfies WorkerMessage) }) }, terminate() { worker.terminate() pending.forEach(({ reject }) => reject(new Error('Worker terminated'))) pending.clear() }, } }
Define the types in one place:
typescript// workers/csv-worker.types.ts export interface CsvWorkerRequests { PARSE_CSV: { content: string; delimiter?: string } EXPORT_CSV: { rows: Record<string, unknown>[]; headers: string[] } } export interface CsvWorkerResponses { PARSE_CSV: { headers: string[]; rows: Record<string, unknown>[]; rowCount: number } EXPORT_CSV: { content: string } }
The worker itself:
typescript// workers/csv.worker.ts import type { WorkerMessage, WorkerResponse } from '../lib/worker-client' import type { CsvWorkerRequests, CsvWorkerResponses } from './csv-worker.types' import Papa from 'papaparse' self.addEventListener('message', (event: MessageEvent<WorkerMessage>) => { const { id, type, payload } = event.data try { let result: unknown switch (type as keyof CsvWorkerRequests) { case 'PARSE_CSV': { const { content, delimiter = ',' } = payload as CsvWorkerRequests['PARSE_CSV'] const parsed = Papa.parse(content, { header: true, delimiter, skipEmptyLines: true, }) result = { headers: parsed.meta.fields ?? [], rows: parsed.data, rowCount: parsed.data.length, } break } case 'EXPORT_CSV': { const { rows, headers } = payload as CsvWorkerRequests['EXPORT_CSV'] result = { content: Papa.unparse(rows, { columns: headers }) } break } default: throw new Error(`Unknown message type: ${type}`) } self.postMessage({ id, success: true, payload: result } satisfies WorkerResponse) } catch (err) { self.postMessage({ id, success: false, error: err instanceof Error ? err.message : 'Unknown error', } satisfies WorkerResponse) } })
Using it from React:
typescript// hooks/useCsvWorker.ts import { useMemo } from 'react' import { createWorkerClient } from '../lib/worker-client' import type { CsvWorkerRequests, CsvWorkerResponses } from '../workers/csv-worker.types' export function useCsvWorker() { const client = useMemo( () => createWorkerClient<CsvWorkerRequests, CsvWorkerResponses>( new URL('../workers/csv.worker.ts', import.meta.url) ), [] ) return client } // In a component function CsvImport() { const worker = useCsvWorker() const [parsing, setParsing] = useState(false) const handleFile = async (file: File) => { setParsing(true) const content = await file.text() try { // This runs in a worker — main thread stays responsive const result = await worker.call('PARSE_CSV', { content }) console.log(`Parsed ${result.rowCount} rows`) } finally { setParsing(false) } } }
Transferable Objects: Avoiding Copies
The default message passing copies data. A 10MB ArrayBuffer sent via postMessage is serialized, transferred, and deserialized — slow.
Transferable objects move ownership rather than copying:
typescript// Without transfer copies the buffer worker.postMessage({ imageData: buffer }) // With transfer moves ownership (buffer is now empty in main thread) worker.postMessage({ imageData: buffer }, [buffer])
This matters for image processing, audio, and any large binary data:
typescript// Image processing in a worker async function processImage(imageData: ImageData): Promise<ImageData> { const buffer = imageData.data.buffer // Transfer the buffer — no copy worker.postMessage( { type: 'PROCESS_IMAGE', buffer, width: imageData.width, height: imageData.height }, [buffer] // Transferable list ) // Wait for result return new Promise((resolve) => { worker.onmessage = (e) => { const { buffer: resultBuffer, width, height } = e.data resolve(new ImageData(new Uint8ClampedArray(resultBuffer), width, height)) } }) }
When Not to Use Workers
For async work. If your heavy operation is already a network request or filesystem read, it's already non-blocking. Workers don't help.
For very small tasks. Worker startup cost is roughly 10–40ms. If your computation takes 5ms, a worker makes it slower. Benchmark first.
In Node.js for I/O. Node.js's event loop handles I/O non-blocking natively. Workers are for CPU-heavy work in Node too (worker_threads), but the same rule applies only for genuinely CPU-bound operations like cryptography, data processing, or video transcoding.
The practical threshold I use: if a task takes more than 50ms on typical hardware, it belongs in a worker. Less than that, it's usually fine on the main thread.
React Integration with Comlink
If you don't want to manage message passing yourself, Comlink (by the Chrome team) wraps workers in a Proxy that makes them feel like regular async functions:
typescript// worker.ts import { expose } from 'comlink' const api = { async parseCsv(content: string) { // ... return { headers, rows } }, async sortData(data: Row[], column: string) { return data.sort((a, b) => a[column] > b[column] ? 1 : -1) }, } expose(api) export type WorkerApi = typeof api // main.ts import { wrap } from 'comlink' import type { WorkerApi } from './worker' const worker = new Worker(new URL('./worker.ts', import.meta.url)) const api = wrap<WorkerApi>(worker) // Feels like a regular async function const result = await api.parseCsv(fileContent)
Comlink handles all the message plumbing. The tradeoff is you add a dependency and lose some control over transfer optimization. For most use cases, it's worth it.