JavaScript has a single main thread. A synchronous CSV export that filters and serializes 50,000 rows, or a bcrypt hash in a form submit handler, blocks the main thread for 200–400ms (ISO-25010 time-behaviour). During that time the browser cannot respond to any user input, animate CSS transitions, or process other JavaScript. The UI appears frozen. Web Workers solve this by running the computation on a separate thread, leaving the main thread free to handle interactions — but only if the developer moves the work there deliberately.
Low because CPU-heavy main-thread operations freeze the UI only during the computation, not continuously — but the freeze is user-visible and reproducible on every trigger.
Move CPU-heavy operations into a Web Worker. Create a dedicated worker file and communicate via postMessage:
// src/workers/export.worker.ts
self.addEventListener('message', (e: MessageEvent) => {
const { rows } = e.data
const csv = rows.map((r: Record<string, unknown>) => Object.values(r).join(',')).join('\n')
self.postMessage({ csv })
})
// src/components/ExportButton.tsx
const worker = new Worker(new URL('../workers/export.worker.ts', import.meta.url))
worker.postMessage({ rows: largeDataset })
worker.onmessage = (e) => downloadFile(e.data.csv)
Always include a main-thread fallback for environments where Worker is unavailable:
const processData = typeof Worker !== 'undefined'
? () => useWorker(data)
: () => processOnMainThread(data) // SSR or worker-restricted environments
For simpler APIs, use Comlink to expose worker functions as async callables without manual message passing.
ID: performance-core.script-style-efficiency.web-worker
Severity: low
What to look for: Count all relevant instances and enumerate each. Identify CPU-heavy operations in the codebase: JSON parsing of large payloads, cryptographic operations (hashing, encryption), image processing, data sorting or filtering over thousands of records, and complex mathematical computations. Check whether any of these operations run on the main thread (in component lifecycle methods, event handlers, or module-level code). Search for new Worker(...), worker_threads, or libraries that abstract Web Workers (Comlink, workerize-loader, vite-plugin-worker). Verify fallback behavior when Web Workers are unavailable.
Pass criteria: Identified CPU-heavy operations (those taking over 50ms to complete) are offloaded to Web Workers or handled asynchronously via the Scheduler API. A graceful fallback is provided for environments where Web Workers are not available. Worker communication is structured cleanly with typed messages.
Fail criteria: Heavy computations run synchronously on the main thread during user interactions: large JSON parsing in fetch handlers, encryption in form submit handlers, or image processing that blocks the UI. No Web Worker usage detected despite the presence of computation-heavy code paths.
Skip (N/A) when: The project has no CPU-heavy operations — all data transformations are lightweight (under 10ms), computations are handled server-side, or the project is primarily UI-driven with no client-side data processing.
Detail on fail: Identify the blocking computation. Example: "CSV export function filters and serializes 50K rows synchronously in the main thread; blocks UI for ~400ms. No Web Worker or async chunking used" or "Password hashing with bcrypt runs on main thread during registration; browser hangs for 200ms while hashing".
Remediation: Web Workers run JavaScript on a background thread, keeping the main thread free for UI updates. Move heavy computation into a worker:
// heavy-compute.worker.ts
self.addEventListener('message', (e: MessageEvent) => {
const { data, operation } = e.data
let result
if (operation === 'sort') {
result = data.sort((a, b) => a.value - b.value)
} else if (operation === 'parse') {
result = JSON.parse(data)
}
self.postMessage(result)
})
// main thread
const worker = new Worker(new URL('./heavy-compute.worker.ts', import.meta.url))
worker.postMessage({ data: largeArray, operation: 'sort' })
worker.onmessage = (e) => setResults(e.data)
With Comlink (cleaner API):
// worker.ts
import * as Comlink from 'comlink'
const api = {
processData: (input: number[]) => input.sort((a, b) => a - b)
}
Comlink.expose(api)
// main.ts
import * as Comlink from 'comlink'
const worker = new Worker(new URL('./worker.ts', import.meta.url))
const api = Comlink.wrap<typeof api>(worker)
const result = await api.processData(largeArray) // runs off main thread
Always include a graceful fallback:
const processData = typeof Worker !== 'undefined'
? () => useWorker(data)
: () => processOnMainThread(data) // fallback for environments without Worker support