Implementing Web Workers for Background Computation on a Website
JavaScript is single-threaded. A heavy operation in the main thread freezes the UI: scrolling stutters, animations break, buttons don't respond. Web Workers solve this by moving computations to a separate OS thread.
Worker has no access to DOM, window, document. Communication with main thread — only through messages (postMessage/onmessage). This limitation — simultaneously a protection against race conditions.
Basic Structure
Two files: main thread and Worker:
// worker.ts
self.onmessage = (event: MessageEvent) => {
const { type, payload } = event.data
switch (type) {
case 'PROCESS': {
const result = heavyComputation(payload)
self.postMessage({ type: 'RESULT', payload: result })
break
}
}
}
function heavyComputation(data: number[]): number {
// Computation that would take 500ms in main thread
return data.reduce((sum, n) => sum + Math.sqrt(n), 0)
}
// main.ts
const worker = new Worker(new URL('./worker.ts', import.meta.url), {
type: 'module',
})
worker.postMessage({ type: 'PROCESS', payload: largeArray })
worker.onmessage = (event: MessageEvent) => {
const { type, payload } = event.data
if (type === 'RESULT') {
console.log('Result:', payload)
}
}
worker.onerror = (error) => {
console.error('Worker error:', error.message)
}
// Terminate Worker
worker.terminate()
Typed Wrapper
Working with raw postMessage is inconvenient. Typed wrapper solves this:
// worker-bridge.ts
type WorkerMessage<T extends Record<string, unknown>> = {
[K in keyof T]: { type: K; payload: T[K] }
}[keyof T]
interface WorkerRequest {
SORT: { array: number[]; direction: 'asc' | 'desc' }
FILTER: { data: Record<string, unknown>[]; query: string }
PARSE_CSV: { content: string }
}
interface WorkerResponse {
SORT_DONE: number[]
FILTER_DONE: Record<string, unknown>[]
PARSE_CSV_DONE: Record<string, string>[]
ERROR: { message: string }
}
class TypedWorker {
private worker: Worker
private pending = new Map<string, { resolve: Function; reject: Function }>()
private seq = 0
constructor(workerUrl: URL) {
this.worker = new Worker(workerUrl, { type: 'module' })
this.worker.onmessage = ({ data }) => {
const { id, type, payload } = data
const handler = this.pending.get(id)
if (!handler) return
this.pending.delete(id)
if (type === 'ERROR') {
handler.reject(new Error(payload.message))
} else {
handler.resolve(payload)
}
}
}
send<K extends keyof WorkerRequest>(
type: K,
payload: WorkerRequest[K]
): Promise<WorkerResponse[`${K}_DONE` & keyof WorkerResponse]> {
return new Promise((resolve, reject) => {
const id = String(++this.seq)
this.pending.set(id, { resolve, reject })
this.worker.postMessage({ id, type, payload })
})
}
terminate(): void {
this.worker.terminate()
}
}
Transferring Large Data — Transferable Objects
postMessage copies data by default. For large ArrayBuffer this is expensive. Transferable Objects are passed by reference (owner transfer), without copying:
// Create buffer
const buffer = new ArrayBuffer(1024 * 1024 * 10) // 10 MB
const view = new Float32Array(buffer)
// ... fill with data
// Transfer without copying — after this buffer in main thread is unavailable
worker.postMessage({ type: 'PROCESS', payload: buffer }, [buffer])
// In Worker
self.onmessage = (event: MessageEvent) => {
const buffer = event.data.payload as ArrayBuffer
const view = new Float32Array(buffer)
// process...
// Return back
self.postMessage({ type: 'DONE', payload: buffer }, [buffer])
}
Transferable: ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas, ReadableStream, WritableStream.
OffscreenCanvas — Rendering in Worker
// main.ts
const canvas = document.getElementById('chart') as HTMLCanvasElement
const offscreen = canvas.transferControlToOffscreen()
worker.postMessage({ type: 'INIT_CANVAS', canvas: offscreen }, [offscreen])
worker.postMessage({ type: 'RENDER', data: chartData })
// chart-worker.ts
let ctx: OffscreenCanvasRenderingContext2D
self.onmessage = (event: MessageEvent) => {
const { type, canvas, data } = event.data
if (type === 'INIT_CANVAS') {
ctx = canvas.getContext('2d')!
return
}
if (type === 'RENDER') {
renderChart(ctx, data)
}
}
Worker Pool
For parallel processing of multiple tasks:
class WorkerPool {
private workers: Worker[] = []
private queue: Array<{ resolve: Function; reject: Function; message: unknown }> = []
private idle: Worker[] = []
constructor(workerUrl: URL, poolSize = navigator.hardwareConcurrency || 4) {
for (let i = 0; i < poolSize; i++) {
const worker = new Worker(workerUrl, { type: 'module' })
worker.onmessage = (event) => this.onWorkerMessage(worker, event)
worker.onerror = (error) => this.onWorkerError(worker, error)
this.workers.push(worker)
this.idle.push(worker)
}
}
execute(message: unknown): Promise<unknown> {
return new Promise((resolve, reject) => {
const task = { resolve, reject, message }
const worker = this.idle.pop()
if (worker) {
this.dispatch(worker, task)
} else {
this.queue.push(task)
}
})
}
private dispatch(worker: Worker, task: { resolve: Function; reject: Function; message: unknown }): void {
(worker as any).__resolve = task.resolve;
(worker as any).__reject = task.reject;
worker.postMessage(task.message)
}
private onWorkerMessage(worker: Worker, event: MessageEvent): void {
(worker as any).__resolve?.(event.data)
this.scheduleNext(worker)
}
private onWorkerError(worker: Worker, error: ErrorEvent): void {
(worker as any).__reject?.(new Error(error.message))
this.scheduleNext(worker)
}
private scheduleNext(worker: Worker): void {
const next = this.queue.shift()
if (next) {
this.dispatch(worker, next)
} else {
this.idle.push(worker)
}
}
terminate(): void {
this.workers.forEach((w) => w.terminate())
}
}
React Hook
function useWorker<TInput, TOutput>(workerUrl: URL) {
const workerRef = useRef<Worker>()
const [result, setResult] = useState<TOutput | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
useEffect(() => {
workerRef.current = new Worker(workerUrl, { type: 'module' })
workerRef.current.onmessage = ({ data }) => {
setResult(data)
setLoading(false)
}
workerRef.current.onerror = (e) => {
setError(e.message)
setLoading(false)
}
return () => workerRef.current?.terminate()
}, [workerUrl.href])
const run = useCallback((payload: TInput) => {
setLoading(true)
setError(null)
workerRef.current?.postMessage(payload)
}, [])
return { run, result, error, loading }
}
Typical Tasks for Workers
- Parsing and transforming large CSV/JSON (>1MB)
- Encryption/decryption
- Canvas graphics rendering and charts
- Image processing (resize, filters, conversion)
- Search and sort algorithms on large arrays
- Data compression (pako, zlib)
- Hash computation (SHA-256, MD5)
- Raytracing, physics simulations
Timeline: 1–2 days depending on task complexity and need for worker pool.







