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 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. 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. 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

Main thread không chỉ là “JavaScript thread”. Đó 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. Khi nó bận, mọi thứ xếp hàng sau work đó phải chờ.

Event loop: tasks vs microtasks

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

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ề. Network song song; logic aggregate của bạn thì không.

Long tasks and input delay

Chrome DevTools và Long Tasks API đánh dấu work dài hơn 50 ms là long task. 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 });

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 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

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. 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 tập con API: fetch, IndexedDB, WebCrypto, WebSocket, Cache (một số context), importScripts, và ngày càng đầy đủ WASM. 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 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

Mỗi postMessage vượt qua isolation boundary. 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

  • Duyệt object graph đệ quy
  • Copy các type được hỗ trợ: plain object, array, Date, Map, Set, ArrayBuffer, typed array, ImageBitmap, File, và thêm theo HTML spec
  • Từ chối function, DOM node, symbol (làm key), và object có prototype chain mà algorithm không traverse an toàn

Chi phí là O(kích thước graph) về thời gian và memory — clone ArrayBuffer 50 MB thì duplicate 50 MB. 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

Truyền transfer list làm đối số thứ hai cho postMessage. 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

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 cũng nghĩa là bạn không gửi class instance sống có method rồi mong behavior bên kia. Thiết kế message chỉ data (DTO) và reconstruct domain object trong worker.


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) });
  }
};

Đây là RPC layer tối thiểu mà mọi codebase tự reinvent.

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:

  • Ưu: Ergonomics, hỗ trợ callback và transfer với Comlink.transfer()
  • Nhược: Chi phí serialization ẩn, khó audit hot path, proxy indirection trong profiler

Dùng Comlink cho call tần suất vừa; dùng message thuần cho data plane firehose.

Worker pools

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);
  }
}

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 với payload thật, không phải microbenchmark.


5. Module workers and bundler integration

Classic worker load một script URL; dependency qua importScripts (sync, block worker startup). 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+, và esbuild tôn trọng new URL(..., import.meta.url) cho worker entry và emit chunk riêng. Tránh bug thứ tự importScripts thủ công.

Caveats:

  • Dependency dùng chung có thể duplicate vào worker bundle — theo dõi tổng download size
  • Top-level await trong module worker được hỗ trợ trên browser hiện đại — hữu ích cho WASM init
  • Dynamic import() trong worker cho phép code-split codec nặng

6. Shared Workers and Service Workers — different roles

Tên nghe giống nhau; thực tế không.

Shared Worker

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 case: leader election cross-tab, WebSocket fan-in dùng chung, lớp sync collaborative editing. 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

Service Workernetwork proxy và background script có lifecycle, không phải compute farm đa năng. 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

Đừng offload parse CSV 300 ms sang Service Worker — dùng Dedicated Worker. Dùng Service Worker cho intercept requestlifecycle asset, không phải thread pool.


7. SharedArrayBuffer, Atomics, and cross-origin isolation

SharedArrayBuffer (SAB) expose shared memory giữa thread với không copy khi truy cập — khác mọi postMessage. Main thread và worker có thể đọc ghi cùng byte đồng thời. Sức mạnh đó mang lại data race mà JavaScript thường tránh.

Spectre and cross-origin isolation

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

Hoặc biến thể COEP credentialless mới khi phù hợp. 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:

  • Script third-party, iframe cross-origin không CORP/COEP, và một số tag ad/analytics phá isolation
  • 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 trên mọi asset embed

Atomics — coordinating without copying

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 block worker thread, không block main thread — vẫn hữu ích cho producer/consumer ring không busy-wait. Trên main thread, Atomics.wait throw — thiết kế cho phù hợp.

Typical SAB architectures:

  • Ring buffer cho audio/video frame giữa decoder worker và playback
  • WASM memory dùng chung với mutex Atomics (pattern Rust wasm-bindgen)
  • Sort/map song song trên typed array với ranh giới chunk cẩn thận

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 và WebGL trước đây gắn rendering với main thread vì canvas element nằm trong DOM. 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);
};

Worker điều khiển rAF trong worker (nơi hỗ trợ) hoặc vòng lặp tự schedule cho WebGL. 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:

  • Render map tile, engine chart với hàng nghìn draw call
  • Filter ảnh trên pipeline ImageBitmap
  • Game loop mà simulation + render đều off main

Limits:

  • 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
  • Wiring event DOM (hover, focus) vẫn trên main
  • Transfer một chiều — lên kế hoạch ownership sớm

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) 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. 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)

Engine JavaScript xuất sắc với app logic đa hình, nhiều allocation. WASM thắng khi hot path số học, chặt, portable thư viện.

JS ↔ WASM boundary cost

Mỗi lần gọi function import/export có thể trap, convert type, và sync memory view. 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: module WASM dùng WebAssembly.Memory — import shared memory (với SAB + isolation) hoặc copied view. Grow memory tường minh; GC không compact WASM heap.

Running WASM inside a worker

Đ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

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

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);
}

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ế. 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 trên main trướcscheduler.yield(), requestIdleCallback, vòng lặp time-sliced trước khi thêm worker
  2. Transfer megabyte, clone kilobyte — luôn đo structured clone trong Performance panel
  3. Worker sở hữu WASM + OffscreenCanvas — main chỉ orchestrate UX state
  4. Protocol tường minh — message type có version, correlation ID, propagate fatal error
  5. 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

  • Work nhạy latency dưới ~5–10 ms mà spawn + postMessage vượt compute
  • Coupling chặt với đo DOM (read layout, write layout) — worker không thấy layout
  • Trang admin traffic thấp mà complexity tax không bao giờ hoàn vốn
  • Team không có kỷ luật profiling — worker nhân debugging surface

Closing mental model

Browser concurrency là menu cơ chế isolation, không phải một cái búa. 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. 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