HTML Web Workers
html web workers web workers keep main-thread inp healthy by isolating parse, search, and crypto web workers run javascript
Introduction
Web Workers run JavaScript on background threads — no DOM access, message-passing only via postMessage. Staff engineers offload CSV parsing, search indexing, and crypto to workers like Google Docs and Figma — keeping main thread INP under budget. Know transferables, worker termination on SPA navigation, and SharedWorker/Service Worker distinct roles.
Business problem
Business pressure: Main-thread jank during heavy compute loses users — spreadsheet recalc, PDF parse, image resize on upload. Slack search built inverted index in worker; skipping worker crashed tab on 10MB paste. Mobile CPUs punish sync parse on main thread with ANR-like freezes.
- Conversion: Checkout freeze during tax calc — worker + skeleton UI preserves completion rate.
- Compliance: Crypto operations in worker don't reduce PCI scope if card data still hits main — but keeps UI responsive during tokenization.
- SEO: Workers irrelevant to crawl — SSR must output indexable HTML without waiting for worker results for critical content.
Why this feature exists
Platform motivation: JavaScript single-threaded model blocked UI during long tasks. Workers (2009) added parallel compute without shared-memory races (until SharedArrayBuffer returned with COOP/COEP). Alternative to "just use WASM on main thread."
- History: Dedicated Worker most common; SharedWorker rare (Safari gaps); Service Worker for network intercept offline.
- Alternative rejected: setTimeout chunking on main — still contends with input; inferior to true parallel.
- Modern role: Module workers (type: module), OffscreenCanvas in worker for image decode — Chrome-first patterns spreading.
Browser internals
Inside the engine: new Worker(url) spawns parallel V8 isolate — separate event loop, no document/window. postMessage structured-clones data (expensive for large objects) or transfers ArrayBuffer ownership zero-copy. Worker script subject to same-origin and CSP worker-src. terminate() kills immediately — leak if forgotten on SPA route change.
- Parser: Worker script loaded async — not parser-blocking unlike sync script in HTML.
- DOM: No access — UI updates must postMessage back to main.
- Lifecycle: Garbage collected when terminated + no references — browsers cap worker count per origin (~20).
// main.jsconst worker = new Worker('/parse-worker.js', { type: 'module' });worker.postMessage({ csv: hugeString });worker.onmessage = e => renderTable(e.data.rows);worker.onerror = e => console.error(e.message);// parse-worker.jsself.onmessage = e => {const rows = parseCsv(e.data.csv);self.postMessage({ rows });};
Rendering workflow
Rendering path: Workers don't paint — main thread receives results and updates DOM (often triggering layout). Batch DOM updates from worker results in DocumentFragment or virtual DOM commit — Google Sheets batches cell updates per animation frame.
- Critical path: Don't await worker before first paint — show skeleton, stream partial results.
- Layout: Injecting 10k table rows from worker — use virtualization on main.
- Paint: OffscreenCanvas in worker — transfer bitmap to main via transferToImageBitmap.
Feature deep dive
Worker types: Dedicated (one page), Shared (multiple tabs — rare), Service (network proxy — PWA offline). Module workers support import/export. Inline workers via Blob URL — CSP may block. Comlink library simplifies RPC-style API.
- When to use: CPU >50ms tasks — parsing, compression, hashing, sort 100k items.
- When not: Tiny tasks — postMessage overhead exceeds savings.
- Transferables: ArrayBuffer, ImageBitmap, OffscreenCanvas — zero-copy handoff.
<!-- HTML loads main script only; worker spawned from module --><script type="module">import { initSearch } from '/search-main.js';initSearch(); // creates worker internally</script>
Accessibility analysis
A11y architecture: Workers compute — main thread must expose progress to AT via aria-busy and aria-live. Long task without feedback feels broken to screen reader users — announce "Processing 50%." Don't block keyboard while worker runs — main thread still handles input if not saturated.
- Screen readers: aria-live polite on status region during worker job.
- Keyboard: Cancel button must terminate worker — AbortSignal pattern via message.
- WCAG: 2.2.6 Timeouts — long worker task needs progress + extend option if session-bound.
SEO impact
SEO architecture: Crawlers don't run your worker pipeline for ranking-critical content. SSR/SSG must emit full HTML for product lists — client worker enhances search filter only. Dynamic rendering fallback if worker required for content (anti-pattern).
- Crawl: Initial HTML complete without worker — progressive filter OK.
- Rich results: Schema in SSR HTML — worker-filtered view is UX layer.
- CWV: Worker improves INP — positive ranking signal via field data.
Security considerations
Security boundary: Worker script must same-origin or CORS — malicious worker URL is XSS equivalent. postMessage accepts any structured clone — validate message shape before processing on main (prototype pollution). No DOM doesn't mean no fetch — worker can exfiltrate if CSP connect-src loose.
- CSP: worker-src 'self' — blocks injected Blob worker from XSS string.
- Data: Don't postMessage secrets to worker if untrusted code can register listener in extension context — edge case but audit extensions.
- SAB: SharedArrayBuffer requires cross-origin isolation headers — Spectre mitigation.
Performance impact
Performance: Long Task API flags main >50ms — workers eliminate many. postMessage clone 10MB JSON costs ms — use transferables or split chunks. Pool workers for parallel map-reduce — don't spawn 100 workers on mobile.
- INP: Primary win — keep input handlers on main responsive during background parse.
- Memory: Duplicate data in worker + main until transfer — plan lifecycle.
- Startup: Worker cold start ~ms — reuse pool for repeated tasks (Figma pattern).
Real production example
Google Docs spell-check pattern: Typing on main; dictionary lookup and suggestion compute in worker; results posted back debounced 100ms; worker terminated on doc close. Figma: geometry tessellation in worker, render commands to canvas on main.
- Pool: navigator.hardwareConcurrency - 1 workers max on desktop.
- Fallback: Main thread parse with requestIdleCallback on unsupported browsers — rare.
- Monitoring: Sentry breadcrumbs for worker errors — separate from main stack.
class WorkerPool {constructor(url, size = 2) {this.workers = Array.from({ length: size }, () => new Worker(url));this.queue = [];}run(data) {return new Promise((resolve, reject) => {const w = this.workers.find(w => !w.busy) ?? this.workers[0];w.busy = true;w.onmessage = e => { w.busy = false; resolve(e.data); };w.onerror = reject;w.postMessage(data);});}}
Enterprise usage
Enterprise: CSP worker-src whitelists bundled worker chunks in webpack/vite — no dynamic user worker URLs. Air-gapped builds inline workers as base64 — increases bundle but satisfies policy. Excel Online workers for formula recalc — fallback message if workers disabled.
- Build: new URL('./worker.js', import.meta.url) — Vite worker pattern.
- Testing: Jest worker mock or run integration in real browser only.
- MDM: Some corporate policies disable workers — detect and degrade.
Common production failures
What breaks in prod: Worker leak on SPA navigation (10 workers idle), postMessage megabyte clone freeze, Safari module worker edge cases, forgot terminate on unmount — tab memory GB.
- Incident: CSV import worker duplicated 500MB array main+worker — mobile OOM crash.
- Perf: Spawning new worker per keystroke — pool fixed 200ms lag.
- CSP: worker-src missing — workers silently fail prod only.
Architecture review questions
- Is worker terminated on component unmount / route change?
- Are large payloads transferred not cloned?
- Is there main-thread fallback if Worker undefined?
- Does UI show progress via aria-busy/live during compute?
- Is worker-src in CSP configured?
- Is worker pool sized to hardwareConcurrency?
Hands-on project
Project: CSV upload: main UI + worker parse + progress aria-live + transferable buffers + worker pool + terminate on route leave.
- Deliverable: Module worker via Vite; INP stays <200ms during 1MB parse.
- Verify: DevTools memory stable after 10 navigations; axe busy states.
- Stretch: OffscreenCanvas thumbnail in worker.
Interview questions
Worker vs Service Worker vs SharedWorker?(Advanced)
Dedicated: page background compute. Service Worker: network intercept, push, offline cache — persists beyond page. SharedWorker: shared between tabs — rare, poor Safari support. Different problems.
Follow-up: Can Service Worker access DOM?
postMessage performance with large data?(Advanced)
Structured clone copies — expensive. Use transferable ArrayBuffer to move ownership. Chunk stream processing. Comlink abstracts but same underlying cost.
Follow-up: SharedArrayBuffer requirements?
How would you refactor a 500ms main-thread sort?(Advanced)
Move sort to worker, postMessage indices or transferable TypedArray, main applies reorder to virtualized list, show skeleton during compute, terminate worker on cancel.
Follow-up: When is setTimeout chunking enough?
Try it yourself
Edit the HTML, CSS, or JS panels — the preview updates as you type.
Try it yourself
Summary
Web Workers keep main-thread INP healthy by isolating parse, search, and crypto workloads — with transferable buffers, worker pools, lifecycle termination, CSP worker-src, and accessible progress feedback on the main UI thread.