jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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

  1. Why the main thread is precious
  2. Dedicated Web Workers — spawning and messaging
  3. Structured clone, transferables, and the real cost of postMessage
  4. Communication patterns — RPC, Comlink, and worker pools
  5. Module workers and bundler integration
  6. Shared Workers and Service Workers — different roles
  7. SharedArrayBuffer, Atomics, and cross-origin isolation
  8. OffscreenCanvas — graphics off the main thread
  9. WebAssembly — when it wins and where the boundary hurts
  10. Worklets — Paint, Audio, and Animation
  11. 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 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}.

QueueExamplesRuns when
Task (macrotask)setTimeout, setInterval, I/O, postMessage, user eventsOne per loop turn
MicrotaskPromise.then, queueMicrotask, MutationObserverAfter current task, before next task
Render stepStyle, 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}.

APIMain threadDedicated Worker
DOM / CSSOMYesNo
localStorageYesNo
fetch / WebSocketYesYes
WebCryptoYesYes
OffscreenCanvasYes (via transfer)Yes
SharedArrayBufferYes (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:

TypeTypical use
ArrayBufferRaw binary, WASM memory views, decoded frames
MessagePortConnect workers, build channels
ImageBitmapDecoded pixels without DOM
OffscreenCanvasRendering surface
ReadableStream / WritableStreamStreaming pipelines (where supported)
VideoFrameWebCodecs output
AudioDataWebCodecs 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}.


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 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ới Comlink.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"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 await in module workers is supported in modern browsers — useful for WASM init {Top-level await trong 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 {Dynamic import() 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 Workernetwork 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 typeLifetimeShared between tabs?Primary purpose
DedicatedWhile owner document/worker ref existsNoCPU + isolated tasks
SharedWhile any connected port existsYes (same origin)Cross-tab coordination
ServiceBrowser-managed, event-drivenYes (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 requestlifecycle 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, crossOriginIsolatedfalse 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-Policy on all embedded assets {Audit Cross-Origin-Resource-Policy trê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-bindgen patterns) {WASM memory dùng chung với mutex Atomics (pattern Rust wasm-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 ImageBitmap pipelines {Filter ảnh trên pipeline ImageBitmap}
  • 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

DomainWhy WASM
Codecs / compressionTight loops, SIMD, existing C/Rust libraries
CryptographyConstant-time primitives, native libs
Parsing / serializationBinary protocols (protobuf, flatbuffers) at line rate
Image/video processingPixel kernels, convolution
Games / physicsFixed timestep simulation
Scientific / ML inferenceONNX 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)

ToolchainInputTypical outputNotes
Rust + wasm-packRustES module + .wasmStrong ergonomics, wasm-bindgen
EmscriptenC/C++JS glue + .wasmLegacy native ports, SDL, ffmpeg wasm
AssemblyScriptTypeScript subset.wasmGood for numeric kernels, not full TS
Component Model (evolving)Multiple languagesInteroperable componentsWatch 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}.

WorkletAPIRuns onPurpose
PaintWorkletCSS paint()Paint worklet threadProgrammatic backgrounds, low-level paint
AnimationWorkletWeb Animations animate()Animator worklet threadScroll-linked effects off main
AudioWorkletWeb AudioReal-time audio threadDSP 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

  1. Chunk + yield on main firstscheduler.yield(), requestIdleCallback, time-sliced loops before adding workers {Chunk + yield trên main trướcscheduler.yield(), requestIdleCallback, vòng lặp time-sliced trước khi thêm worker}
  2. Transfer megabytes, clone kilobytes — always measure structured clone in Performance panel {Transfer megabyte, clone kilobyte — luôn đo structured clone trong Performance panel}
  3. Worker owns WASM + OffscreenCanvas — main orchestrates UX state only {Worker sở hữu WASM + OffscreenCanvas — main chỉ orchestrate UX state}
  4. Explicit protocol — versioned message types, correlation IDs, fatal error propagation {Protocol tường minh — message type có version, correlation ID, propagate fatal error}
  5. Pool sized to hardware — usually navigator.hardwareConcurrency - 1 to leave headroom for main + compositor {Pool size theo hardware — thường navigator.hardwareConcurrency - 1 để chừa headroom cho main + compositor}

Anti-patterns

Anti-patternWhy it fails
Worker for every JSON.parseStartup + clone cost exceeds parse time on small payloads
Sending React component treesFunctions and DOM refs don’t clone; architectural mismatch
Shared mutable state via repeated postMessageRace bugs without SAB; use immutable snapshots or SAB+Atomics
Ignoring crossOriginIsolatedSAB silently missing; prod-only failures
Blocking worker with sync XHR/importScriptsFreezes 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}.


Further reading