Heavy JSON parsing, image processing, sorting 50,000 rows, AI inference in the browser with ONNX — the complete guide to offloading CPU-intensive work to a background thread so your UI never stutters, with the Comlink wrapper that makes Web Workers actually pleasant to use.
JavaScript runs on a single thread. Everything your application does — parse JSON, sort a table, respond to a click, render a frame — competes for the same thread. When a heavy computation runs, the browser can’t do anything else. The UI freezes. Scroll doesn’t respond. Animations stutter. Click handlers queue up and fire all at once when the computation finishes. Users experience this as “the page hung” and they’re correct.
Web Workers give you a second thread. Real parallelism, not async. The computation runs in a separate OS thread that doesn’t share the event loop with your UI. Your main thread stays responsive. Animations play. Click handlers fire. The work happens in the background and sends a message when it’s done.
Web Workers have been in every major browser since 2012. They’re universally supported, require no build tool configuration in Vite or webpack, and solve one of the most concrete performance problems in frontend development. They’re also used by almost nobody — because the raw postMessage API is genuinely unpleasant to work with, and because the problems they solve don’t appear during development on a fast laptop. They appear in production, on real devices, for real users, and they’re reported as “the site feels slow.”
This post covers the raw API, the Comlink wrapper that makes Workers feel like async functions, the patterns for heavy data processing, and running ONNX models for AI inference entirely in the browser without a server round-trip.
What the Browser’s Single Thread Actually Means
The browser’s main thread is responsible for:
JavaScript execution → your app code
Style calculation → CSS cascade, specificity resolution
Layout → element position and size computation
Paint → rendering pixels to layers
Composite → combining layers and sending to GPU
Event handling → click, scroll, keydown, mousemove
Every one of these happens on the same thread, in sequence. A long JavaScript task blocks all of them. The browser’s target is 60 frames per second — 16.6ms per frame. Any JavaScript task that exceeds 16.6ms causes a dropped frame. Any task that exceeds 50ms is classified as a “Long Task” in the Performance API, detectable with PerformanceObserver.
// Detect long tasks in production
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.warn(`Long task detected: ${entry.duration.toFixed(1)}ms`, entry)
// Send to your monitoring service
}
})
observer.observe({ entryTypes: ['longtask'] })
What triggers Long Tasks in real applications:
JSON.parse() on a 5MB API response → 80–200ms depending on device
Array.sort() on 50,000 objects → 100–400ms
Canvas image processing (resize, filter) → 200ms–2s
CSV parsing with Papa Parse → 50–500ms
Markdown parsing of long documents → 20–100ms
ONNX model inference → 50ms–several seconds
These timings are on fast developer hardware. On a mid-range Android phone — the median device for most web applications — multiply by 3–6×.
The Raw Worker API — Why Nobody Uses It
A Web Worker is a JavaScript file that runs in a separate thread. The main thread and the worker communicate exclusively through postMessage and the message event. No shared memory, no shared state — just messages.
// worker.js — the worker file
self.onmessage = function(event) {
const { data } = event // whatever was postMessage'd
// Do expensive work
const result = heavyComputation(data)
// Send the result back
self.postMessage(result)
}
// main.js — the main thread
const worker = new Worker('./worker.js')
// Send data to the worker
worker.postMessage(inputData)
// Receive the result
worker.onmessage = function(event) {
const result = event.data
// Use the result
}
The friction is in the callback-based, event-driven communication. In any real use case, you have multiple operations with multiple responses. You need to correlate which response belongs to which request. You end up writing a message bus:
// The boilerplate every developer writes before giving up on Workers
const pendingRequests = new Map()
let requestId = 0
function callWorker(type, payload) {
return new Promise((resolve, reject) => {
const id = ++requestId
pendingRequests.set(id, { resolve, reject })
worker.postMessage({ id, type, payload })
})
}
worker.onmessage = function(event) {
const { id, result, error } = event.data
const pending = pendingRequests.get(id)
if (!pending) return
pendingRequests.delete(id)
if (error) {
pending.reject(new Error(error))
} else {
pending.resolve(result)
}
}
This is correct but tedious. It’s also what Comlink implements, properly, in about 3KB.
Comlink — Workers That Feel Like Functions
Comlink (from Google Chrome Labs) wraps the postMessage protocol in a Proxy. You expose an object from the worker; Comlink makes it callable from the main thread as if it were local, returning Promises.
npm install comlink
The worker with Comlink:
// workers/data-processor.worker.js
import * as Comlink from 'comlink'
const DataProcessor = {
// Each method is callable from the main thread
async sortRows(rows, column, direction) {
return rows.sort((a, b) => {
const aVal = a[column]
const bVal = b[column]
const cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0
return direction === 'asc' ? cmp : -cmp
})
},
async parseCSV(csvString) {
// Heavy parse — could be megabytes
return csvString
.trim()
.split('\n')
.map(line => line.split(',').map(cell => cell.trim()))
},
async parseJSON(jsonString) {
// JSON.parse blocks on large payloads — move it off the main thread
return JSON.parse(jsonString)
},
async filterRows(rows, predicates) {
return rows.filter(row =>
predicates.every(({ column, operator, value }) => {
const cell = row[column]
switch (operator) {
case 'eq': return cell === value
case 'contains': return String(cell).includes(value)
case 'gt': return Number(cell) > Number(value)
case 'lt': return Number(cell) < Number(value)
default: return true
}
})
)
},
}
// Expose the object to the main thread
Comlink.expose(DataProcessor)
The main thread:
// In a Vue composable, React hook, or vanilla JS module
import * as Comlink from 'comlink'
// Create the worker once — reuse it for all operations
const worker = new Worker(
new URL('./workers/data-processor.worker.js', import.meta.url),
{ type: 'module' },
)
const processor = Comlink.wrap(worker)
// Now call worker methods as if they're local async functions
async function handleSort(rows, column, direction) {
const sorted = await processor.sortRows(rows, column, direction)
// UI stays responsive during the sort — it's running in another thread
tableData.value = sorted
}
async function handleFileUpload(file) {
const text = await file.text()
const parsed = await processor.parseCSV(text)
tableData.value = parsed
}
processor.sortRows() looks like a local async function. Under the hood, Comlink serializes the arguments, posts a message to the worker, and resolves the Promise when the worker responds. The main thread is free during the entire sort operation.
Vue Composable That Owns a Worker
The right pattern in Vue is a composable that creates the worker once, exposes its operations as reactive async functions, and cleans up on unmount.
// composables/useDataProcessor.ts
import { onUnmounted, ref } from 'vue'
import * as Comlink from 'comlink'
type DataProcessor = {
sortRows(rows: Record<string, unknown>[], column: string, direction: 'asc' | 'desc'): Promise<Record<string, unknown>[]>
parseCSV(csv: string): Promise<string[][]>
parseJSON(json: string): Promise<unknown>
filterRows(rows: Record<string, unknown>[], predicates: FilterPredicate[]): Promise<Record<string, unknown>[]>
}
type FilterPredicate = {
column: string
operator: 'eq' | 'contains' | 'gt' | 'lt'
value: unknown
}
export function useDataProcessor() {
const isWorking = ref(false)
const error = ref<string | null>(null)
// Worker created once per composable instance
const worker = new Worker(
new URL('../workers/data-processor.worker.js', import.meta.url),
{ type: 'module' },
)
const processor = Comlink.wrap<DataProcessor>(worker)
async function run<T>(operation: () => Promise<T>): Promise<T | null> {
isWorking.value = true
error.value = null
try {
return await operation()
} catch (err) {
error.value = (err as Error).message
return null
} finally {
isWorking.value = false
}
}
const sortRows = (rows: Record<string, unknown>[], col: string, dir: 'asc' | 'desc') =>
run(() => processor.sortRows(rows, col, dir))
const parseCSV = (csv: string) =>
run(() => processor.parseCSV(csv))
const parseJSON = (json: string) =>
run(() => processor.parseJSON(json))
const filterRows = (rows: Record<string, unknown>[], predicates: FilterPredicate[]) =>
run(() => processor.filterRows(rows, predicates))
// Clean up the worker when the component unmounts
onUnmounted(() => {
processor[Comlink.releaseProxy]()
worker.terminate()
})
return {
isWorking,
error,
sortRows,
parseCSV,
parseJSON,
filterRows,
}
}
The component using this composable:
<script setup lang="ts">
import { ref } from 'vue'
import { useDataProcessor } from '@/composables/useDataProcessor'
const { sortRows, parseCSV, isWorking, error } = useDataProcessor()
const rows = ref<Record<string, unknown>[]>([])
const sortColumn = ref('name')
const sortDir = ref<'asc' | 'desc'>('asc')
async function handleSort(column: string): Promise<void> {
if (sortColumn.value === column) {
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
} else {
sortColumn.value = column
sortDir.value = 'asc'
}
const sorted = await sortRows(rows.value, column, sortDir.value)
if (sorted) rows.value = sorted
// The UI was completely responsive during this operation
// The user could scroll, click, resize — none of it was blocked
}
async function handleFile(event: Event): Promise<void> {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file) return
const text = await file.text()
const parsed = await parseCSV(text)
if (parsed) {
// parsed is string[][] — first row is headers
rows.value = parsed.slice(1).map(row =>
Object.fromEntries(parsed[0].map((header, i) => [header, row[i]]))
)
}
}
</script>
Transferable Objects — Avoiding the Copy Cost
By default, postMessage copies data using the structured clone algorithm. For large typed arrays (image pixel data, audio buffers, large Float32Arrays), copying is expensive — you’re allocating and memcpying megabytes between threads.
Transferable objects solve this: ownership of the buffer transfers from sender to receiver. No copy. The sender can no longer access the data after transfer.
// Without transfer — copies the entire ArrayBuffer
worker.postMessage({ pixels: imageData.data.buffer })
// With transfer — transfers ownership, zero copy
worker.postMessage(
{ pixels: imageData.data.buffer },
[imageData.data.buffer], // ← second argument: list of transferable objects
)
// imageData.data.buffer is now detached — don't access it
Comlink supports transfer with Comlink.transfer():
// Worker exposes an image processor
const ImageProcessor = {
async applyGrayscale(pixels, width, height) {
const data = new Uint8ClampedArray(pixels)
for (let i = 0; i < data.length; i += 4) {
const avg = (data[i] + data[i + 1] + data[i + 2]) / 3
data[i] = avg // R
data[i + 1] = avg // G
data[i + 2] = avg // B
// data[i + 3] = alpha — unchanged
}
// Transfer the result back — no copy
return Comlink.transfer(data.buffer, [data.buffer])
},
}
// Main thread — transfer the input, receive the output
async function processImage(canvas) {
const ctx = canvas.getContext('2d')
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
// Transfer the pixels to the worker — zero copy
const resultBuffer = await processor.applyGrayscale(
Comlink.transfer(imageData.data.buffer, [imageData.data.buffer]),
canvas.width,
canvas.height,
)
// Put the result back on the canvas
const result = new ImageData(
new Uint8ClampedArray(resultBuffer),
canvas.width,
canvas.height,
)
ctx.putImageData(result, 0, 0)
}
For a 4K image (3840×2160 × 4 bytes = 33MB), the difference between copying and transferring is the difference between a 200ms stutter and an instantaneous operation.
Progress From a Worker
Long operations should report progress. The worker can postMessage progress updates while the main thread listens alongside the Comlink wrapper.
// worker — in a worker that doesn't use Comlink (raw API)
self.onmessage = async function({ data: { rows, column } }) {
const total = rows.length
rows.sort((a, b) => {
// ... sort logic
})
// Process in chunks and report progress
const CHUNK = 5000
const result = []
for (let i = 0; i < total; i += CHUNK) {
const chunk = rows.slice(i, i + CHUNK)
// Process chunk...
result.push(...chunk)
self.postMessage({
type: 'progress',
progress: Math.round(((i + CHUNK) / total) * 100),
})
// Yield to allow progress message to be received
await new Promise(resolve => setTimeout(resolve, 0))
}
self.postMessage({ type: 'done', result })
}
For progress reporting with Comlink, use a callback proxy:
// worker with Comlink
import * as Comlink from 'comlink'
const Processor = {
async processLargeDataset(rows, onProgress) {
const CHUNK = 5000
const total = rows.length
const result = []
for (let i = 0; i < rows.length; i += CHUNK) {
const chunk = rows.slice(i, i + CHUNK)
result.push(...chunk)
// Call the main-thread callback — Comlink handles the proxy
await onProgress(Math.round(((i + CHUNK) / total) * 100))
}
return result
},
}
Comlink.expose(Processor)
// Main thread
const progress = ref(0)
await processor.processLargeDataset(
rows,
Comlink.proxy((pct) => { // ← Comlink.proxy wraps a callback for cross-thread calls
progress.value = pct
}),
)
Comlink.proxy() wraps a main-thread function so the worker can call it directly. The callback executes on the main thread, updating reactive state, without any manual postMessage wiring.
ONNX Runtime Web in a Worker
Running ML inference on the main thread is the worst thing you can do — model loading blocks the UI for seconds and inference blocks it for tens to hundreds of milliseconds per call. The ONNX docs are explicit on this: if inference takes a while, run it in a Web Worker.
npm install onnxruntime-web
The inference worker:
// workers/inference.worker.js
import * as Comlink from 'comlink'
import * as ort from 'onnxruntime-web'
// Configure WASM paths — required when using onnxruntime-web in a Worker
ort.env.wasm.wasmPaths = '/ort-wasm/' // serve the .wasm files from your public dir
let session = null
const InferenceEngine = {
async loadModel(modelPath) {
// Detect WebGPU — use it when available, fall back to WASM
const hasWebGPU = typeof navigator !== 'undefined' && !!navigator.gpu
session = await ort.InferenceSession.create(modelPath, {
executionProviders: hasWebGPU ? ['webgpu', 'wasm'] : ['wasm'],
graphOptimizationLevel: 'all',
})
// Warmup run — compiles WebGPU pipelines, primes WASM JIT
// Prevents the first real inference from being slow
if (session.inputNames.length > 0) {
try {
const dummyInputs = {}
for (const name of session.inputNames) {
dummyInputs[name] = new ort.Tensor('float32', new Float32Array(1), [1])
}
await session.run(dummyInputs)
} catch {
// Warmup may fail for models with strict input shapes — safe to ignore
}
}
return {
inputNames: session.inputNames,
outputNames: session.outputNames,
}
},
async runInference(inputs) {
if (!session) throw new Error('Model not loaded. Call loadModel() first.')
// Convert plain objects to ORT Tensors
const ortInputs = {}
for (const [name, { data, dims, type }] of Object.entries(inputs)) {
ortInputs[name] = new ort.Tensor(type ?? 'float32', data, dims)
}
const outputs = await session.run(ortInputs)
// Convert ORT Tensors back to plain objects for serialization
const result = {}
for (const [name, tensor] of Object.entries(outputs)) {
result[name] = {
data: Array.from(tensor.data),
dims: tensor.dims,
type: tensor.type,
}
}
return result
},
async dispose() {
if (session) {
await session.release()
session = null
}
},
}
Comlink.expose(InferenceEngine)
A Vue composable that wraps the inference worker:
// composables/useInference.ts
import { ref, onUnmounted } from 'vue'
import * as Comlink from 'comlink'
type TensorSpec = {
data: number[]
dims: number[]
type?: 'float32' | 'int64' | 'bool'
}
type InferenceResult = Record<string, TensorSpec>
export function useInference() {
const isLoading = ref(false)
const isRunning = ref(false)
const modelLoaded = ref(false)
const error = ref<string | null>(null)
const worker = new Worker(
new URL('../workers/inference.worker.js', import.meta.url),
{ type: 'module' },
)
const engine = Comlink.wrap(worker)
async function loadModel(modelPath: string): Promise<void> {
isLoading.value = true
error.value = null
try {
await engine.loadModel(modelPath)
modelLoaded.value = true
} catch (err) {
error.value = `Failed to load model: ${(err as Error).message}`
} finally {
isLoading.value = false
}
}
async function infer(inputs: Record<string, TensorSpec>): Promise<InferenceResult | null> {
if (!modelLoaded.value) {
error.value = 'Model not loaded.'
return null
}
isRunning.value = true
error.value = null
try {
return await engine.runInference(inputs)
} catch (err) {
error.value = (err as Error).message
return null
} finally {
isRunning.value = false
}
}
onUnmounted(async () => {
await engine.dispose()
engine[Comlink.releaseProxy]()
worker.terminate()
})
return {
loadModel,
infer,
isLoading,
isRunning,
modelLoaded,
error,
}
}
Using the composable for sentiment classification:
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useInference } from '@/composables/useInference'
const { loadModel, infer, isLoading, isRunning, modelLoaded } = useInference()
const inputText = ref('')
const result = ref<string | null>(null)
onMounted(async () => {
// Model loads in the worker — main thread stays responsive
await loadModel('/models/sentiment-classifier.onnx')
})
async function classify(): Promise<void> {
// Encode input text as token IDs
// (In a real app, use a tokenizer — this is simplified)
const tokenIds = encode(inputText.value)
const output = await infer({
input_ids: {
data: tokenIds,
dims: [1, tokenIds.length],
type: 'int64',
},
})
if (output) {
// Argmax of logits → class label
const logits = output.logits.data
const maxIndex = logits.indexOf(Math.max(...logits))
result.value = ['negative', 'neutral', 'positive'][maxIndex] ?? 'unknown'
}
}
</script>
<template>
<div>
<p v-if="isLoading">Loading model…</p>
<div v-else>
<textarea v-model="inputText" placeholder="Enter text to classify" />
<button :disabled="isRunning || !modelLoaded" @click="classify">
{{ isRunning ? 'Classifying…' : 'Classify' }}
</button>
<p v-if="result">Sentiment: {{ result }}</p>
</div>
</div>
</template>
The model loads once in the worker. Every inference call runs there too. The main thread — and therefore the UI — is never blocked.
The Tier Decision for AI Inference
Not every device can run ONNX with WebGPU. The ONNX Runtime docs describe a tiered approach: WebGPU for devices with a capable GPU, WASM with SIMD for CPU fallback, and server-side inference as the final fallback for devices that can’t run the model locally.
// workers/inference.worker.js — execution provider selection
function pickExecutionProvider() {
const hasGPU = typeof navigator !== 'undefined' && !!navigator.gpu
const hasMemory = typeof navigator !== 'undefined'
? (navigator.deviceMemory ?? 4) >= 4 // require at least 4GB RAM
: true
if (hasGPU && hasMemory) return ['webgpu', 'wasm'] // GPU with CPU fallback
return ['wasm'] // CPU only
}
session = await ort.InferenceSession.create(modelPath, {
executionProviders: pickExecutionProvider(),
graphOptimizationLevel: 'all',
})
The right model size policy: for production web applications, prefer “tiny” or “small” model variants — larger models impact user experience through longer load times and slower inference on less powerful hardware. A quantized 8-bit model that loads in 3 seconds and infers in 40ms is a better choice than a full-precision model that loads in 30 seconds and infers in 400ms, even if the accuracy is slightly lower.
What Terminates a Worker and What Doesn’t
Workers persist until explicitly terminated or the page is closed. They don’t garbage-collect when the wrapping composable unmounts — you must call worker.terminate() explicitly. The onUnmounted hook is the right place:
onUnmounted(() => {
engine[Comlink.releaseProxy]() // release Comlink's proxy
worker.terminate() // terminate the OS thread
})
A terminated worker cannot be reused. If you need a worker to persist beyond a single component’s lifecycle — for a shared data processor or a long-lived ONNX session — create it at the module level and share the Comlink-wrapped proxy:
// lib/sharedProcessor.ts — singleton, shared across all components
import * as Comlink from 'comlink'
const worker = new Worker(
new URL('../workers/data-processor.worker.js', import.meta.url),
{ type: 'module' },
)
// Export the wrapped proxy — all imports share the same worker instance
export const sharedProcessor = Comlink.wrap(worker)
The shared worker pattern is the right choice for a heavy ONNX model that takes seconds to load — you load it once at app startup and share the session across every component that needs it.
The Performance API Feedback Loop
After adding Workers to a performance-sensitive path, measure the before and after with the Performance API rather than guessing:
// Before — measure a task on the main thread
performance.mark('sort-start')
const sorted = rows.sort(compareFn)
performance.mark('sort-end')
performance.measure('main-thread-sort', 'sort-start', 'sort-end')
const [measure] = performance.getEntriesByName('main-thread-sort')
console.log(`Main thread sort: ${measure.duration.toFixed(1)}ms`)
// After — measure the same task via the Worker
performance.mark('worker-sort-start')
const sorted = await processor.sortRows(rows, column, direction)
performance.mark('worker-sort-end')
performance.measure('worker-sort', 'worker-sort-start', 'worker-sort-end')
const [workerMeasure] = performance.getEntriesByName('worker-sort')
console.log(`Worker sort (wall time): ${workerMeasure.duration.toFixed(1)}ms`)
The wall time for a worker operation includes serialization, message passing, computation, and deserialization. For small arrays (< 1,000 items), the overhead of message passing may exceed the cost of the computation — don’t move work to a Worker just because it’s possible. Move it because the main-thread version creates Long Tasks.
Where Workers Don’t Help
Workers run in a separate thread but they can’t access the DOM, window, or any browser API that requires the main thread. They also add serialization overhead for every message.
Works great in a Worker:
✅ JSON.parse / JSON.stringify for large payloads
✅ Sorting and filtering large arrays
✅ CSV / TSV / text parsing
✅ Image pixel processing (receive ImageData, return processed buffer)
✅ Cryptography (SubtleCrypto is available in Workers)
✅ ONNX inference, TensorFlow.js inference
✅ Markdown / syntax highlighting / text transformation
✅ Data compression / decompression
Does not work in a Worker (main thread only):
❌ DOM manipulation
❌ Canvas rendering (creating canvases, drawing to them)
❌ localStorage / sessionStorage
❌ alert(), confirm(), prompt()
❌ Synchronous XHR (async fetch works fine)
The test: can the operation be described purely in terms of input data → output data, with no DOM side effects? If yes, it belongs in a Worker.
The Moment It Clicks
Web Workers are one of those browser features where the impact is felt immediately and physically — you add the Worker, run the sort, and the page doesn’t freeze. The difference between a Long Task and a Worker-offloaded operation is a UI that stutters and one that doesn’t. That’s not a benchmark number. It’s something users notice on first contact.
The Comlink wrapper removes the last excuse. The raw postMessage API is too cumbersome for most use cases. With Comlink, calling a Worker method is indistinguishable from calling a local async function. The Worker is an implementation detail. The composable hides it. The component sorts 50,000 rows and renders AI inference results without knowing a thread boundary exists.
That’s the correct abstraction. Write it once and stop thinking about it.
