Browser Concurrency — Web Workers, Shared Memory, OffscreenCanvas, and WebAssembly
Why the main thread breaks INP, how Web Workers and shared memory actually work, and when WASM beats JS — a principal engineer guide to browser multithreading.
JavaScript runs on a single main thread per browsing context, yet modern apps routinely need CPU-heavy work — image decoding, cryptography, physics, parsing megabyte JSON, video codecs {JavaScript chạy trên một main thread duy nhất mỗi browsing context, nhưng app hiện đại thường xuyên cần work nặng CPU — decode ảnh, cryptography, physics, parse JSON megabyte, video codec}.
The browser gives you parallelism without shared mutable JS state via Web Workers, true shared memory via SharedArrayBuffer, off-main-thread rendering via OffscreenCanvas, and near-native compute via WebAssembly {Trình duyệt cho bạn parallelism không có shared mutable JS state qua Web Workers, shared memory thật qua SharedArrayBuffer, render off main thread qua OffscreenCanvas, và compute gần native qua WebAssembly}.
This post is the decision guide senior engineers wish they had before spawning their tenth worker that made things slower {Bài viết này là decision guide mà senior engineer ước mình có trước khi spawn worker thứ mười khiến mọi thứ chậm hơn}.
Table of contents
- Why the main thread is precious
- Dedicated Web Workers — spawning and messaging
- Structured clone, transferables, and the real cost of
postMessage - Communication patterns — RPC, Comlink, and worker pools
- Module workers and bundler integration
- Shared Workers and Service Workers — different roles
- SharedArrayBuffer, Atomics, and cross-origin isolation
- OffscreenCanvas — graphics off the main thread
- WebAssembly — when it wins and where the boundary hurts
- Worklets — Paint, Audio, and Animation
- Patterns, anti-patterns, and a principal engineer decision guide
1. Why the main thread is precious
The main thread is not “the JavaScript thread” alone {Main thread không chỉ là “JavaScript thread”}. It is the thread that runs your JS and services input, timers, fetch callbacks, layout, paint (unless offloaded), and compositor-adjacent work coordination {Đó là thread chạy JS và xử lý input, timer, fetch callback, layout, paint (trừ khi offload), và điều phối work liên quan compositor}. When it is busy, everything queued behind that work waits {Khi nó bận, mọi thứ xếp hàng sau work đó phải chờ}.
Event loop: tasks vs microtasks
The event loop processes one macrotask (task), then drains all microtasks, then may render {Event loop xử lý một macrotask (task), rồi drain hết microtasks, sau đó có thể render}.
| Queue | Examples | Runs when |
|---|---|---|
| Task (macrotask) | setTimeout, setInterval, I/O, postMessage, user events | One per loop turn |
| Microtask | Promise.then, queueMicrotask, MutationObserver | After current task, before next task |
| Render step | Style, layout, paint (if needed) | Browser decides; often ~60 Hz cap |
A common mistake: awaiting fifty fetch responses in a tight loop still schedules microtasks on the main thread when results arrive {Lỗi phổ biến: await fifty fetch response trong vòng lặp chặt vẫn schedule microtasks trên main thread khi kết quả về}.
The network was parallel; your aggregation logic was not {Network song song; logic aggregate của bạn thì không}.
Long tasks and input delay
Chrome DevTools and the Long Tasks API mark work longer than 50 ms as a long task {Chrome DevTools và Long Tasks API đánh dấu work dài hơn 50 ms là long task}. That threshold is not arbitrary — it sits near human perception of jank and directly feeds Interaction to Next Paint (INP) {Ngưỡng đó không tùy tiện — nó gần ngưỡng cảm nhận giật của con người và feed trực tiếp Interaction to Next Paint (INP)}.
// Detect long tasks in production (requires PerformanceObserver support)
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.warn(`Long task: ${entry.duration.toFixed(1)}ms`, entry);
}
});
observer.observe({ type: "longtask", buffered: true });
Principal insight: Moving work off the main thread is not about “using all CPU cores.” It is about bounding main-thread occupancy so input and rendering get scheduled within your INP budget {Insight principal: Đưa work ra khỏi main thread không phải “dùng hết CPU core.” Mà là giới hạn thời gian chiếm main thread để input và rendering được schedule trong INP budget}.
User click
│
▼
[ Main thread busy: 200ms JSON parse ]
│
▼ (input queued, no handler runs)
[ Finally: event handler + rAF + paint ]
│
▼
INP spike — user feels lag
Workers help when the critical path includes CPU-bound steps that cannot be chunked with requestIdleCallback or scheduler.yield() alone {Workers giúp khi critical path có bước CPU-bound không thể chunk chỉ bằng requestIdleCallback hoặc scheduler.yield()}.
2. Dedicated Web Workers — spawning and messaging
A Dedicated Worker is an isolated global: separate event loop, separate heap, no DOM, no window, no direct access to parent variables {Dedicated Worker là global cô lập: event loop riêng, heap riêng, không DOM, không window, không truy cập trực tiếp biến parent}.
Communication is async message passing only {Giao tiếp chỉ qua async message passing}.
Classic worker script
// main.js
const worker = new Worker("/workers/parser.js");
worker.onmessage = (event) => {
console.log("Parsed:", event.data);
};
worker.onerror = (event) => {
// ErrorEvent — message, filename, lineno
console.error(event.message);
};
worker.postMessage({ type: "parse", payload: largeText });
// workers/parser.js
self.onmessage = (event) => {
const { type, payload } = event.data;
if (type === "parse") {
const result = heavyParse(payload); // CPU work here
self.postMessage({ type: "result", result });
}
};
Worker global surface
Workers expose a subset of APIs: fetch, IndexedDB, WebCrypto, WebSocket, Cache (in some contexts), importScripts, and increasingly full WASM {Workers expose tập con API: fetch, IndexedDB, WebCrypto, WebSocket, Cache (một số context), importScripts, và ngày càng đầy đủ WASM}.
They do not expose DOM, document, localStorage (use IndexedDB instead), or synchronous XHR {Chúng không expose DOM, document, localStorage (dùng IndexedDB thay thế), hay synchronous XHR}.
| API | Main thread | Dedicated Worker |
|---|---|---|
| DOM / CSSOM | Yes | No |
localStorage | Yes | No |
fetch / WebSocket | Yes | Yes |
WebCrypto | Yes | Yes |
OffscreenCanvas | Yes (via transfer) | Yes |
SharedArrayBuffer | Yes (with isolation) | Yes (with isolation) |
Terminate aggressively when idle to reclaim memory — workers are not free processes, but separate V8 isolates with baseline overhead {Terminate mạnh khi idle để reclaim memory — worker không phải process miễn phí, mà là V8 isolate riêng có baseline overhead}.
worker.terminate(); // abrupt — no graceful shutdown unless you protocol one
3. Structured clone, transferables, and the real cost of postMessage
Every postMessage crosses an isolation boundary {Mỗi postMessage vượt qua isolation boundary}.
The browser serializes the message with the structured clone algorithm (same family as structuredClone()) unless you transfer ownership of supported objects {Trình duyệt serialize message bằng structured clone algorithm (cùng họ với structuredClone()) trừ khi bạn transfer quyền sở hữu object được hỗ trợ}.
What structured clone does
- Walks the object graph recursively {Duyệt object graph đệ quy}
- Copies supported types: plain objects, arrays,
Date,Map,Set,ArrayBuffer, typed arrays,ImageBitmap,File, and more per HTML spec {Copy các type được hỗ trợ: plain object, array,Date,Map,Set,ArrayBuffer, typed array,ImageBitmap,File, và thêm theo HTML spec} - Rejects functions, DOM nodes, symbols (as keys), and objects with prototype chains the algorithm cannot traverse safely {Từ chối function, DOM node, symbol (làm key), và object có prototype chain mà algorithm không traverse an toàn}
The cost is O(size of graph) in time and memory — cloning a 50 MB ArrayBuffer duplicates 50 MB {Chi phí là O(kích thước graph) về thời gian và memory — clone ArrayBuffer 50 MB thì duplicate 50 MB}.
Principal teams profile this; junior teams wonder why the worker “didn’t help” {Team principal profile cái này; team junior thắc mắc sao worker “không giúp gì”}.
Transferable objects — zero-copy handoff
Pass a transfer list as the second argument to postMessage {Truyền transfer list làm đối số thứ hai cho postMessage}.
Ownership moves; the sender’s reference becomes detached (neutered) {Quyền sở hữu chuyển đi; reference phía gửi trở thành detached (neutered)}.
const buffer = new ArrayBuffer(16 * 1024 * 1024); // 16 MB
const view = new Uint8Array(buffer);
view[0] = 42;
worker.postMessage({ view }, [buffer]);
// buffer.byteLength === 0 on main thread now
// view.buffer is detached — do not read view[0] here
Common transferables:
| Type | Typical use |
|---|---|
ArrayBuffer | Raw binary, WASM memory views, decoded frames |
MessagePort | Connect workers, build channels |
ImageBitmap | Decoded pixels without DOM |
OffscreenCanvas | Rendering surface |
ReadableStream / WritableStream | Streaming pipelines (where supported) |
VideoFrame | WebCodecs output |
AudioData | WebCodecs audio frames |
// Transfer ImageBitmap after decode — avoid copying pixel memory
const bitmap = await createImageBitmap(blob);
worker.postMessage({ bitmap }, [bitmap]);
// bitmap.width throws or is unusable on sender side
Rule of thumb: If payload is megabytes, transfer. If payload is a small config object, clone is fine {Quy tắc ngón tay cái: Payload megabyte thì transfer. Payload config nhỏ thì clone ổn}.
Message size and design pressure
Structured clone also means you cannot send a live class instance with methods and expect behavior on the other side {Structured clone cũng nghĩa là bạn không gửi class instance sống có method rồi mong behavior bên kia}. Design data-only messages (DTOs) and reconstruct domain objects inside the worker {Thiết kế message chỉ data (DTO) và reconstruct domain object trong worker}.
4. Communication patterns — RPC, Comlink, and worker pools
Raw postMessage devolves into spaghetti without structure {postMessage thuần dễ thành spaghetti nếu không có cấu trúc}.
Request/response with correlation IDs
// main.js
let nextId = 0;
const pending = new Map();
function callWorker(worker, method, args) {
const id = ++nextId;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
worker.postMessage({ id, method, args });
});
}
worker.onmessage = (event) => {
const { id, result, error } = event.data;
const handlers = pending.get(id);
if (!handlers) return;
pending.delete(id);
if (error) handlers.reject(new Error(error));
else handlers.resolve(result);
};
// worker.js
const methods = {
parse(json) {
return JSON.parse(json);
},
hash(data) {
/* crypto work */
},
};
self.onmessage = async (event) => {
const { id, method, args } = event.data;
try {
const fn = methods[method];
if (!fn) throw new Error(`Unknown method: ${method}`);
const result = await fn(...args);
self.postMessage({ id, result });
} catch (err) {
self.postMessage({ id, error: String(err.message) });
}
};
This is the minimal RPC layer every codebase reinvents {Đây là RPC layer tối thiểu mà mọi codebase tự reinvent}.
Comlink — ergonomic RPC over postMessage
Comlink wraps workers with Proxy-based RPC: expose an object on one side, call methods from the other {Comlink bọc worker bằng RPC kiểu Proxy: expose object một phía, gọi method từ phía kia}.
import * as Comlink from "comlink";
const worker = new Worker(new URL("./worker.js", import.meta.url));
const api = Comlink.wrap(worker);
const result = await api.heavyCompute(42);
// Under the hood: postMessage + structured clone per call
Trade-offs:
- Pros: Developer ergonomics, supports callbacks and transfers with
Comlink.transfer(){Ưu: Ergonomics, hỗ trợ callback và transfer vớiComlink.transfer()} - Cons: Hidden serialization cost, harder to audit hot paths, proxy indirection in profilers {Nhược: Chi phí serialization ẩn, khó audit hot path, proxy indirection trong profiler}
Use Comlink for moderate-frequency calls; use raw messages for firehose data planes {Dùng Comlink cho call tần suất vừa; dùng message thuần cho data plane firehose}.
Worker pools
One worker serializes CPU tasks; a pool maps work to min(hardwareConcurrency, taskParallelism) workers {Một worker serialize CPU task; pool map work tới min(hardwareConcurrency, taskParallelism) worker}.
class WorkerPool {
constructor(workerUrl, size = navigator.hardwareConcurrency || 4) {
this.workers = Array.from({ length: size }, () => new Worker(workerUrl));
this.queue = [];
this.idle = [...this.workers];
}
run(task) {
return new Promise((resolve, reject) => {
this.queue.push({ task, resolve, reject });
this.dispatch();
});
}
dispatch() {
if (!this.queue.length || !this.idle.length) return;
const worker = this.idle.pop();
const { task, resolve, reject } = this.queue.shift();
const onMessage = (event) => {
worker.removeEventListener("message", onMessage);
worker.removeEventListener("error", onError);
this.idle.push(worker);
resolve(event.data);
this.dispatch();
};
const onError = (event) => {
worker.removeEventListener("message", onMessage);
worker.removeEventListener("error", onError);
this.idle.push(worker);
reject(event.error ?? new Error(event.message));
this.dispatch();
};
worker.addEventListener("message", onMessage);
worker.addEventListener("error", onError);
worker.postMessage(task);
}
}
Pool sizing: more workers than cores contend without gain when work is CPU-saturated; fewer workers underutilize hardware on embarrassingly parallel batches {Size pool: nhiều worker hơn core contend không lợi khi work CPU-saturated; ít worker underutilize hardware với batch embarrassingly parallel}. Profile with real payloads, not microbenchmarks {Profile với payload thật, không phải microbenchmark}.
5. Module workers and bundler integration
Classic workers load a single script URL; dependencies come via importScripts (sync, blocks worker startup) {Classic worker load một script URL; dependency qua importScripts (sync, block worker startup)}.
Module workers use type: "module" and native import/export {Module worker dùng type: "module" và import/export native}.
const worker = new Worker(
new URL("./compute.worker.ts", import.meta.url),
{ type: "module" }
);
// compute.worker.ts
import { parseAST } from "./parser";
self.onmessage = (event) => {
self.postMessage(parseAST(event.data));
};
Vite, Webpack 5+, and esbuild respect new URL(..., import.meta.url) for worker entry points and emit separate chunks {Vite, Webpack 5+, và esbuild tôn trọng new URL(..., import.meta.url) cho worker entry và emit chunk riêng}.
This avoids manual importScripts ordering bugs {Tránh bug thứ tự importScripts thủ công}.
Caveats:
- Shared dependencies may duplicate into worker bundles — watch total download size {Dependency dùng chung có thể duplicate vào worker bundle — theo dõi tổng download size}
- Top-level
awaitin module workers is supported in modern browsers — useful for WASM init {Top-levelawaittrong module worker được hỗ trợ trên browser hiện đại — hữu ích cho WASM init} - Dynamic
import()inside workers enables code-split heavy codecs {Dynamicimport()trong worker cho phép code-split codec nặng}
6. Shared Workers and Service Workers — different roles
These names sound interchangeable; they are not {Tên nghe giống nhau; thực tế không}.
Shared Worker
A SharedWorker is shared across same-origin documents (tabs, iframes) via one worker instance and MessagePort connections {SharedWorker được share giữa document cùng origin (tab, iframe) qua một worker instance và kết nối MessagePort}.
const worker = new SharedWorker("/shared/sync.worker.js");
worker.port.start();
worker.port.postMessage({ type: "subscribe" });
worker.port.onmessage = (e) => console.log(e.data);
Use cases: cross-tab leader election, shared WebSocket fan-in, collaborative editing sync layer {Use case: leader election cross-tab, WebSocket fan-in dùng chung, lớp sync collaborative editing}. Support is weaker than Dedicated Workers (no SharedWorker on Safari iOS historically — verify current compat tables before betting the product) {Support yếu hơn Dedicated Worker (Safari iOS từng không có SharedWorker — verify bảng compat hiện tại trước khi bet product)}.
Service Worker
A Service Worker is a network proxy and lifecycle-managed background script, not a general compute farm {Service Worker là network proxy và background script có lifecycle, không phải compute farm đa năng}. It intercepts fetch, enables offline caching, push notifications, and background sync — orthogonal to CPU parallelism {Nó intercept fetch, bật offline cache, push notification, background sync — trục khác với CPU parallelism}.
| Worker type | Lifetime | Shared between tabs? | Primary purpose |
|---|---|---|---|
| Dedicated | While owner document/worker ref exists | No | CPU + isolated tasks |
| Shared | While any connected port exists | Yes (same origin) | Cross-tab coordination |
| Service | Browser-managed, event-driven | Yes (scope) | Network, cache, push |
Do not offload a 300 ms CSV parse to a Service Worker — use a Dedicated Worker {Đừng offload parse CSV 300 ms sang Service Worker — dùng Dedicated Worker}. Do use Service Workers for request interception and asset lifecycle, not as a thread pool {Dùng Service Worker cho intercept request và lifecycle asset, không phải thread pool}.
7. SharedArrayBuffer, Atomics, and cross-origin isolation
SharedArrayBuffer (SAB) exposes shared memory between threads with no copy on access — unlike every postMessage {SharedArrayBuffer (SAB) expose shared memory giữa thread với không copy khi truy cập — khác mọi postMessage}.
Both the main thread and workers can read and write the same bytes concurrently {Main thread và worker có thể đọc ghi cùng byte đồng thời}.
That power reintroduces data races JavaScript normally avoids {Sức mạnh đó mang lại data race mà JavaScript thường tránh}.
Spectre and cross-origin isolation
After Spectre mitigations, SAB and high-resolution timers require a cross-origin isolated context {Sau mitigations Spectre, SAB và high-resolution timer cần context cross-origin isolated}. Your document must send:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Or the newer credentialless COEP variant where appropriate {Hoặc biến thể COEP credentialless mới khi phù hợp}.
Without this, crossOriginIsolated is false and SAB is unavailable in many browsers {Không có điều này, crossOriginIsolated là false và SAB không dùng được trên nhiều browser}.
if (!crossOriginIsolated) {
console.warn("SharedArrayBuffer unavailable — fall back to postMessage");
}
Implications for principal engineers:
- Third-party scripts, cross-origin iframes without CORP/COEP, and some ad/analytics tags break isolation {Script third-party, iframe cross-origin không CORP/COEP, và một số tag ad/analytics phá isolation}
- You may isolate only a heavy sub-origin (e.g.
app.example.com) while marketing stays un-isolated {Bạn có thể isolate chỉ sub-origin nặng (vd.app.example.com) trong khi marketing không isolate} - Audit
Cross-Origin-Resource-Policyon all embedded assets {AuditCross-Origin-Resource-Policytrên mọi asset embed}
Atomics — coordinating without copying
Use Atomics for locks, counters, and wait/notify when threads must synchronize {Dùng Atomics cho lock, counter, wait/notify khi thread cần đồng bộ}.
const sab = new SharedArrayBuffer(1024);
const int32 = new Int32Array(sab);
// Worker or main: increment safely
Atomics.add(int32, 0, 1);
// Worker waits until index 0 becomes non-zero (main notifies)
Atomics.wait(int32, 0, 0); // blocks worker until notify
// Main signals worker
Atomics.store(int32, 0, 1);
Atomics.notify(int32, 0, 1);
Atomics.wait blocks the worker thread, not the main thread — still useful for producer/consumer rings without busy-wait {Atomics.wait block worker thread, không block main thread — vẫn hữu ích cho producer/consumer ring không busy-wait}.
On the main thread, Atomics.wait throws — design accordingly {Trên main thread, Atomics.wait throw — thiết kế cho phù hợp}.
Typical SAB architectures:
- Ring buffer for audio/video frames between decoder worker and playback {Ring buffer cho audio/video frame giữa decoder worker và playback}
- Shared WASM memory with Atomics mutexes (Rust
wasm-bindgenpatterns) {WASM memory dùng chung với mutex Atomics (pattern Rustwasm-bindgen)} - Parallel sort/map on typed arrays with careful chunk boundaries {Sort/map song song trên typed array với ranh giới chunk cẩn thận}
When SAB is worth the COOP/COEP tax: High-frequency, small updates (games, audio DSP, live collaboration cursors) where postMessage overhead dominates {Khi SAB đáng COOP/COEP tax: Cập nhật nhỏ tần suất cao (game, audio DSP, cursor collaboration live) mà overhead postMessage chiếm ưu thế}.
8. OffscreenCanvas — graphics off the main thread
Canvas 2D and WebGL historically tied rendering to the main thread because the canvas element lived in the DOM {Canvas 2D và WebGL trước đây gắn rendering với main thread vì canvas element nằm trong DOM}. OffscreenCanvas decouples the rendering surface from the DOM tree {OffscreenCanvas tách rendering surface khỏi DOM tree}.
Transfer control from a visible canvas
const canvas = document.querySelector("#gl");
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ canvas: offscreen }, [offscreen]);
// canvas on main thread is now opaque — do not call getContext on it
// worker.js
let gl;
self.onmessage = (event) => {
const { canvas } = event.data;
gl = canvas.getContext("webgl2");
requestAnimationFrameLoop(render);
};
The worker drives rAF in worker (where supported) or self-scheduled loops for WebGL {Worker điều khiển rAF trong worker (nơi hỗ trợ) hoặc vòng lặp tự schedule cho WebGL}. Main thread composites the result as a layer — input hit-testing still happens on main unless you architect otherwise {Main thread composite kết quả như layer — hit-testing input vẫn trên main trừ khi bạn kiến trúc khác}.
Use cases:
- Map tile rendering, chart engines with thousands of draw calls {Render map tile, engine chart với hàng nghìn draw call}
- Image filters on
ImageBitmappipelines {Filter ảnh trên pipelineImageBitmap} - Game loops where simulation + render both off main {Game loop mà simulation + render đều off main}
Limits:
- Not every canvas feature is identical offscreen across browsers — test 2D text metrics and filter stacks {Không phải mọi tính năng canvas giống hệt offscreen trên mọi browser — test 2D text metrics và filter stack}
- DOM event wiring (hover, focus) remains on main {Wiring event DOM (hover, focus) vẫn trên main}
- Transfer is one-way — plan ownership early {Transfer một chiều — lên kế hoạch ownership sớm}
Pair OffscreenCanvas + Worker + WASM for physics or pathfinding while GPU draws instances {Kết hợp OffscreenCanvas + Worker + WASM cho physics hoặc pathfinding trong khi GPU vẽ instance}.
9. WebAssembly — when it wins and where the boundary hurts
WebAssembly (WASM) is a portable bytecode format with a linear memory model and predictable performance for numeric loops {WebAssembly (WASM) là định dạng bytecode portable với memory model linear và performance dự đoán được cho vòng lặp số học}. It is not magic — it is a compute island you still integrate via JS or workers {Không phải phép màu — là đảo compute bạn vẫn tích hợp qua JS hoặc worker}.
When WASM helps
| Domain | Why WASM |
|---|---|
| Codecs / compression | Tight loops, SIMD, existing C/Rust libraries |
| Cryptography | Constant-time primitives, native libs |
| Parsing / serialization | Binary protocols (protobuf, flatbuffers) at line rate |
| Image/video processing | Pixel kernels, convolution |
| Games / physics | Fixed timestep simulation |
| Scientific / ML inference | ONNX runtime, custom ops (often with SIMD) |
JavaScript engines are excellent at polymorphic, allocation-heavy app logic {Engine JavaScript xuất sắc với app logic đa hình, nhiều allocation}. WASM wins when hot paths are numeric, tight, and library-portable {WASM thắng khi hot path số học, chặt, portable thư viện}.
JS ↔ WASM boundary cost
Every imported/exported function call may trap, convert types, and sync memory views {Mỗi lần gọi function import/export có thể trap, convert type, và sync memory view}. Batch work inside WASM; avoid calling WASM per pixel or per JSON token {Batch work bên trong WASM; tránh gọi WASM mỗi pixel hoặc mỗi JSON token}.
// ❌ Anti-pattern: thousands of tiny WASM calls from JS
for (const item of items) {
wasm.processOne(item); // boundary overhead dominates
}
// ✅ Process a slab in one call
wasm.processBatch(itemsPtr, items.length);
Memory: WASM modules use WebAssembly.Memory — either imported shared memory (with SAB + isolation) or copied views {Memory: module WASM dùng WebAssembly.Memory — import shared memory (với SAB + isolation) hoặc copied view}.
Grow memory explicitly; GC does not compact WASM heaps {Grow memory tường minh; GC không compact WASM heap}.
Running WASM inside a worker
The sweet spot: Worker hosts WASM, main thread stays responsive, large buffers transfer in {Điểm ngọt: Worker host WASM, main thread responsive, buffer lớn transfer vào}.
// main.js
const worker = new Worker(new URL("./wasm.worker.js", import.meta.url), {
type: "module",
});
const input = new Uint8Array(await file.arrayBuffer());
worker.postMessage({ input }, [input.buffer]);
worker.onmessage = (e) => {
const { output } = e.data;
renderResult(output);
};
// wasm.worker.js
import init, { decode } from "./pkg/codec.js"; // wasm-pack output
await init();
self.onmessage = (event) => {
const { input } = event.data;
const output = decode(input);
self.postMessage({ output }, [output.buffer]);
};
Toolchains (brief)
| Toolchain | Input | Typical output | Notes |
|---|---|---|---|
| Rust + wasm-pack | Rust | ES module + .wasm | Strong ergonomics, wasm-bindgen |
| Emscripten | C/C++ | JS glue + .wasm | Legacy native ports, SDL, ffmpeg wasm |
| AssemblyScript | TypeScript subset | .wasm | Good for numeric kernels, not full TS |
| Component Model (evolving) | Multiple languages | Interoperable components | Watch for plugin architectures |
Choose Rust/wasm-pack for new native-heavy modules; Emscripten when porting existing C++ with minimal rewrite {Chọn Rust/wasm-pack cho module native-heavy mới; Emscripten khi port C++ sẵn có ít rewrite}.
10. Worklets — Paint, Audio, and Animation
Worklets are lightweight, scope-specific scripts registered via CSS or Web Audio — not general threads {Worklet là script theo scope nhẹ, đăng ký qua CSS hoặc Web Audio — không phải thread đa năng}.
| Worklet | API | Runs on | Purpose |
|---|---|---|---|
| PaintWorklet | CSS paint() | Paint worklet thread | Programmatic backgrounds, low-level paint |
| AnimationWorklet | Web Animations animate() | Animator worklet thread | Scroll-linked effects off main |
| AudioWorklet | Web Audio | Real-time audio thread | DSP with hard latency bounds |
// Register paint worklet
CSS.paintWorklet.addModule("/worklets/checkerboard.js");
.card {
background-image: paint(checkerboard);
}
Worklets are not a replacement for Web Workers — they hook into specific rendering or audio pipelines with strict APIs and limited globals {Worklet không thay Web Worker — chúng hook pipeline render hoặc audio cụ thể với API chặt và global hạn chế}. Use them when the browser exposes the hook you need (custom paint, scroll animation, audio node) {Dùng khi browser expose hook bạn cần (custom paint, scroll animation, audio node)}.
11. Patterns, anti-patterns, and a principal engineer decision guide
Patterns that hold up
- Chunk + yield on main first —
scheduler.yield(),requestIdleCallback, time-sliced loops before adding workers {Chunk + yield trên main trước —scheduler.yield(),requestIdleCallback, vòng lặp time-sliced trước khi thêm worker} - Transfer megabytes, clone kilobytes — always measure structured clone in Performance panel {Transfer megabyte, clone kilobyte — luôn đo structured clone trong Performance panel}
- Worker owns WASM + OffscreenCanvas — main orchestrates UX state only {Worker sở hữu WASM + OffscreenCanvas — main chỉ orchestrate UX state}
- Explicit protocol — versioned message types, correlation IDs, fatal error propagation {Protocol tường minh — message type có version, correlation ID, propagate fatal error}
- Pool sized to hardware — usually
navigator.hardwareConcurrency - 1to leave headroom for main + compositor {Pool size theo hardware — thườngnavigator.hardwareConcurrency - 1để chừa headroom cho main + compositor}
Anti-patterns
| Anti-pattern | Why it fails |
|---|---|
Worker for every JSON.parse | Startup + clone cost exceeds parse time on small payloads |
| Sending React component trees | Functions and DOM refs don’t clone; architectural mismatch |
| Shared mutable state via repeated postMessage | Race bugs without SAB; use immutable snapshots or SAB+Atomics |
Ignoring crossOriginIsolated | SAB silently missing; prod-only failures |
Blocking worker with sync XHR/importScripts | Freezes that worker’s queue; use async module workers |
| Comlink on hot loops (60+ Hz) | Proxy + clone per frame destroys budgets |
Decision guide
Is the task CPU-bound AND > 50ms on representative hardware?
├── No → stay on main; chunk/yield/async
└── Yes
├── Needs DOM/layout/paint?
│ ├── Yes → main thread or compositor-friendly changes only
│ └── No → candidate for worker
├── Payload size per message?
│ ├── Large binary → transfer ArrayBuffer / ImageBitmap / VideoFrame
│ └── Small/frequent → consider SharedArrayBuffer + isolation
├── Needs existing C/Rust lib?
│ └── WASM in worker
├── Needs custom rendering loop?
│ └── OffscreenCanvas (+ WASM optional)
└── Cross-tab network dedup only?
└── Service Worker (not compute)
When NOT to use a worker
- Latency-sensitive work under ~5–10 ms where spawn + postMessage exceeds compute {Work nhạy latency dưới ~5–10 ms mà spawn + postMessage vượt compute}
- Tight coupling with DOM measurements (read layout, write layout) — workers cannot see layout {Coupling chặt với đo DOM (read layout, write layout) — worker không thấy layout}
- Low-traffic admin pages where complexity tax never pays back {Trang admin traffic thấp mà complexity tax không bao giờ hoàn vốn}
- Teams without profiling discipline — workers multiply debugging surface {Team không có kỷ luật profiling — worker nhân debugging surface}
Closing mental model
Browser concurrency is a menu of isolation mechanisms, not one hammer {Browser concurrency là menu cơ chế isolation, không phải một cái búa}. Dedicated Workers move CPU; transferables move bytes without copy; SharedArrayBuffer + Atomics move coordination cost when you pay the isolation tax; OffscreenCanvas moves draw calls; WASM moves numeric kernels; Worklets hook specialized pipelines {Dedicated Worker dời CPU; transferable dời byte không copy; SharedArrayBuffer + Atomics dời chi phí coordination khi bạn trả isolation tax; OffscreenCanvas dời draw call; WASM dời numeric kernel; Worklet hook pipeline chuyên biệt}. The principal engineer’s job is to measure main-thread occupancy, pick the cheapest mechanism that preserves UX, and document the protocol before the second engineer copies the pattern wrong {Việc của principal engineer là đo main-thread occupancy, chọn mechanism rẻ nhất giữ UX, và document protocol trước khi engineer thứ hai copy pattern sai}.