Lần đầu mình đưa một đoạn parse CSV vào Web Worker, mình đã rất tự tin: main thread hết bận, INP sẽ đẹp. Kết quả là trang vẫn khựng gần nửa giây — chỉ là giờ khựng ở chỗ khác, lúc worker gửi trả hai trăm nghìn object và main thread phải deserialize từng cái một. Worker không làm việc nặng biến mất; nó chuyển việc sang thread khác và thu phí ở hai đầu đường ống. Bài này là những gì mình rút ra sau vài lần làm đúng và nhiều lần làm sai: khi nào đáng dùng worker, structured clone tốn bao nhiêu, transferable và SharedArrayBuffer giúp gì, cross-origin isolation đòi hỏi gì, cách tự viết một lớp RPC kiểu Comlink, cấu hình bundler cho Next.js và Vite, worker pool, huỷ việc bằng AbortSignal, vài ví dụ thật từ CSV tới WASM — và khi nào scheduler.yield() là đủ.
Khi nào chuyển việc sang worker là đáng#
Main thread của trình duyệt làm gần như mọi thứ: chạy JavaScript, xử lý input, tính style, layout, paint. Một task chạy quá 50ms là long task — trong thời gian đó, cú bấm của người dùng phải xếp hàng, và đó chính là phần input delay của INP. Nếu bạn cần ôn lại mô hình task, microtask và frame, mình đã viết kỹ ở bài Event loop, microtask và rendering.
Có hai cách để main thread rảnh: chia nhỏ việc để trình duyệt chen input và render vào giữa, hoặc chuyển việc sang thread khác. Worker là cách thứ hai, và nó hợp nhất khi việc có những đặc điểm sau:
- Nặng về CPU, không cần DOM. Parse, nén, mã hoá, tìm kiếm, tính toán hình học, xử lý ảnh. Worker không truy cập được
documenthaywindow. - Đầu vào lớn, đầu ra nhỏ — hoặc dữ liệu nhị phân chuyển được bằng transferable. Gửi một
Filevào, nhận một bản tóm tắt ra. - Tốn hàng chục mili giây trở lên và lặp lại. Chi phí khởi động worker và truyền dữ liệu phải được bù bằng thời gian main thread được giải phóng.
- Có trạng thái sống lâu đáng giữ riêng. Một search index, một model WASM — nạp một lần, truy vấn nhiều lần.
Và worker không giúp khi:
- Phần chậm là render. React reconcile mười nghìn dòng, layout một DOM khổng lồ — worker không đụng được DOM. Ở đây cần virtualization,
content-visibility, giảm số node. - Chi phí truyền dữ liệu lấn át việc tính toán. Lọc một mảng 5.000 phần tử mất 3ms; clone nó qua worker và clone kết quả về có thể còn đắt hơn.
- Việc vốn đã nhỏ nhưng gọi dày đặc. Mỗi lượt
postMessagecó chi phí cố định; với việc vài trăm micro giây, chia nhỏ trên main thread rẻ hơn.
Đo trước khi chuyển. Tab Performance trong DevTools cho bạn thấy long task và ai gây ra nó; Long Animation Frames API cho bạn thấy điều đó trên máy người dùng thật. Mình chỉ đưa một đoạn code vào worker khi profile chỉ rõ nó là khúc chiếm main thread, và kết quả trả về đủ nhỏ.
| Tình huống | Cách mình chọn | Lý do |
|---|---|---|
| Parse file CSV/JSON vài chục MB | Worker, gửi File vào | Đầu vào lớn, đầu ra là bản tóm tắt nhỏ |
| Tìm kiếm full-text trên vài chục nghìn tài liệu | Worker sống lâu giữ index | Index nạp một lần, mỗi truy vấn chỉ gửi chuỗi ngắn |
| Nén, resize ảnh trước khi upload | Worker với OffscreenCanvas | Dữ liệu nhị phân, trả về Blob |
| Lọc, sắp xếp vài nghìn dòng trong bảng | Main thread, useDeferredValue hoặc chia nhỏ | Truyền dữ liệu đắt hơn tính toán |
| Render danh sách dài | Virtualization | Phần chậm là DOM, worker không chạm được |
| Khởi tạo nhiều widget lúc tải trang | scheduler.yield() giữa các bước | Việc cần DOM, chỉ cần nhường lượt |
Ba loại worker và khi nào dùng loại nào#
| Dedicated Worker | Shared Worker | Service Worker | |
|---|---|---|---|
| Sở hữu bởi | Một trang (một document) | Mọi tab cùng origin kết nối tới | Origin, theo scope |
| Vòng đời | Sống tới khi trang đóng hoặc terminate() | Sống khi còn ít nhất một tab kết nối | Trình duyệt bật khi có sự kiện, tắt khi rảnh |
| Giao tiếp | postMessage trực tiếp | Qua MessagePort (worker.port) | Sự kiện fetch, push, message |
| Dùng cho | Tính toán, parse, WASM | Chia sẻ một kết nối hoặc cache giữa các tab | Proxy mạng, offline, push notification |
| Cạm bẫy | Mỗi worker một heap riêng | Hỗ trợ trên mobile từng không đồng đều; debug khó hơn | Có thể bị tắt giữa chừng; không phải chỗ tính toán |
Trong thực tế, 90% trường hợp "đưa việc ra khỏi main thread" là Dedicated Worker. Mình dùng Shared Worker khi nhiều tab cùng cần một tài nguyên đắt — một kết nối WebSocket, một database SQLite WASM — và chấp nhận phải có đường fallback cho trình duyệt không hỗ trợ. Service Worker thì đừng dùng để tính toán: trình duyệt có thể dừng nó bất cứ lúc nào khi không có sự kiện, và mọi thứ nó làm nằm trên đường đi của request mạng.
Ngoài ra còn có worklet — AudioWorklet, PaintWorklet — là các môi trường rất hẹp cho đúng một pipeline. Nếu bạn xử lý âm thanh thời gian thực, đó mới là chỗ đúng, không phải Dedicated Worker.
Cái giá của postMessage: structured clone#
Mọi giá trị đi qua postMessage đều được structured clone: serialize ở thread gửi, deserialize ở thread nhận. Hai điều mà nhiều người không để ý:
- Serialize chạy đồng bộ trên thread gửi. Gọi
worker.postMessage(bigArray)từ main thread nghĩa là main thread phải tự duyệt và sao chép cả mảng trước khi lệnh đó trả về. Bạn vừa tạo một long task để... tránh long task. - Chi phí tỷ lệ với số lượng và độ phức tạp của object, không chỉ số byte. Hai trăm nghìn object nhỏ đắt hơn nhiều so với một
ArrayBuffercùng kích thước.
// Đo chi phí gửi ngay trên main thread
const rows = await loadRows(); // ví dụ 200.000 object { id, name, price, tags }
const t0 = performance.now();
worker.postMessage({ type: "index", rows });
console.log(`postMessage chặn main thread ${(performance.now() - t0).toFixed(1)}ms`);
Structured clone giữ được nhiều thứ hơn JSON: Map, Set, Date, RegExp, BigInt, typed array, Blob, File, và cả Error. Nhưng nó không clone được function, DOM node, Symbol, và class instance sẽ mất prototype — một Money gửi qua sẽ thành object thường, không còn method. Gửi thứ không clone được, postMessage ném DataCloneError ngay ở thread gửi.
Quy tắc mình rút ra:
- Gửi tham chiếu, không gửi nội dung.
FilevàBlobđược clone theo tham chiếu, gần như không tốn gì. GửiFilevào worker rồi đọc nó trong worker, thay vì đọc thành chuỗi trên main thread rồi gửi chuỗi. - Để worker tự fetch. Nếu dữ liệu đến từ mạng,
fetchvàJSON.parsengay trong worker — main thread không bao giờ phải chạm vào nó. - Giữ dữ liệu ở lại worker. Gửi dữ liệu một lần, sau đó chỉ gửi truy vấn nhỏ qua lại.
- Trả về bản tóm tắt. Trang hiển thị 50 dòng thì đừng trả về 200.000 dòng.
Transferable: chuyển quyền sở hữu thay vì sao chép#
Với dữ liệu nhị phân, có cách tốt hơn clone: transfer. Thay vì sao chép, trình duyệt chuyển quyền sở hữu bộ nhớ sang thread kia, gần như không tốn thời gian bất kể kích thước. Bên gửi mất quyền truy cập — buffer bị detached.
const samples = new Float32Array(8_000_000); // khoảng 32 MB
fillSamples(samples);
worker.postMessage({ type: "analyze", samples }, [samples.buffer]);
console.log(samples.byteLength); // 0 — buffer đã thuộc về worker
Những thứ transfer được hay dùng nhất: ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas, các stream (ReadableStream, WritableStream, TransformStream), VideoFrame và AudioData. Cũng có thể dùng structuredClone(value, { transfer }) để transfer trong cùng một thread.
Transfer buffer của một subarray là transfer cả buffer gốc
subarray() tạo một view mới trên cùng ArrayBuffer. Nếu bạn gửi chunk.buffer của một subarray, bạn transfer toàn bộ buffer gốc — mọi view khác trên main thread lập tức thành rỗng, và worker nhận về nhiều dữ liệu hơn bạn nghĩ. Khi chỉ muốn gửi một phần, dùng slice() (tạo bản sao riêng) rồi transfer bản sao đó, và gửi kèm byteOffset/length nếu worker cần biết vị trí.
OffscreenCanvas: vẽ trong worker#
Một transferable đặc biệt hữu ích là OffscreenCanvas. Bạn chuyển quyền điều khiển một <canvas> trên trang sang worker, và worker vẽ trực tiếp — main thread không tham gia gì vào việc vẽ. Mình dùng cách này cho một biểu đồ thời gian thực nhiều chục nghìn điểm: cuộn và zoom vẫn mượt vì tính toán và vẽ đều nằm ngoài main thread.
const canvas = document.querySelector<HTMLCanvasElement>("#chart")!;
const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker(new URL("./chart.worker.ts", import.meta.url), { type: "module" });
worker.postMessage({ type: "init", canvas: offscreen, dpr: devicePixelRatio }, [offscreen]);
// Sau đó chỉ gửi dữ liệu và sự kiện nhỏ
new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
worker.postMessage({ type: "resize", width, height });
}).observe(canvas);
let ctx: OffscreenCanvasRenderingContext2D | null = null;
let canvas: OffscreenCanvas | null = null;
let dpr = 1;
self.onmessage = (event: MessageEvent) => {
const msg = event.data;
if (msg.type === "init") {
canvas = msg.canvas as OffscreenCanvas;
dpr = msg.dpr;
ctx = canvas.getContext("2d");
} else if (msg.type === "resize" && canvas) {
canvas.width = Math.round(msg.width * dpr);
canvas.height = Math.round(msg.height * dpr);
draw();
}
};
function draw() {
if (!ctx || !canvas) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
// ... vẽ series
}
Sau khi gọi transferControlToOffscreen(), bạn không thể lấy context của canvas đó trên main thread nữa. Kích thước hiển thị vẫn do CSS ở main thread quyết định; worker chỉ điều khiển bộ đệm pixel.
SharedArrayBuffer, Atomics và cross-origin isolation#
Transfer là chuyển quyền, tại một thời điểm chỉ một thread giữ dữ liệu. SharedArrayBuffer thì khác: cùng một vùng nhớ được nhìn thấy đồng thời bởi nhiều thread. Mình dùng nó cho ba việc:
- WASM đa luồng — các toolchain biên dịch thread của C/C++/Rust sang Web Worker cùng chia sẻ một bộ nhớ.
- Cờ huỷ và tiến độ mà không cần đợi event loop của worker rảnh (chi tiết ở phần huỷ việc).
- Ring buffer cho luồng dữ liệu tần suất cao, ví dụ âm thanh giữa
AudioWorkletvà worker.
Điều kiện: cross-origin isolated#
Sau các lỗ hổng kiểu Spectre, trình duyệt chỉ cho dùng SharedArrayBuffer khi trang ở trạng thái cross-origin isolated — bạn kiểm tra bằng self.crossOriginIsolated. Để vào trạng thái đó, document phải được phục vụ với hai header:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
- COOP
same-origincắt liên kết giữa trang của bạn và các cửa sổ cross-origin mở nó hoặc được nó mở. - COEP
require-corpbuộc mọi tài nguyên cross-origin mà trang nạp phải chủ động cho phép, qua CORS hoặc headerCross-Origin-Resource-Policy. Giá trịcredentiallesslà lựa chọn mềm hơn: tài nguyên cross-origin không cần CORP, nhưng được tải không kèm cookie.
Cái giá khá thật:
| Thứ bị ảnh hưởng | Vì sao | Cách xử lý |
|---|---|---|
| Popup đăng nhập OAuth, cổng thanh toán | COOP cắt window.opener, popup không gửi kết quả về được | Chỉ bật trên route cần SAB; dùng redirect thay popup |
| Ảnh, font, script từ CDN bên thứ ba | Thiếu CORP hoặc CORS thì bị chặn dưới require-corp | Dùng credentialless, thêm crossorigin, hoặc tự host |
| Iframe nhúng (video, bản đồ) | Iframe cũng phải opt-in | Thuộc tính credentialless cho iframe nơi được hỗ trợ, hoặc tách trang |
| Quảng cáo, widget chat | Thường không gửi CORP | Không bật isolation trên những trang đó |
Lần đầu team mình bật hai header này cho toàn site để chạy một thư viện WASM đa luồng, nút "Đăng nhập bằng Google" dạng popup ngừng hoạt động và mọi video nhúng biến mất. Bài học: chỉ bật cross-origin isolation trên đúng route cần nó.
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
async headers() {
return [
{
// Chỉ trang editor cần SharedArrayBuffer
source: "/editor/:path*",
headers: [
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
{ key: "Cross-Origin-Embedder-Policy", value: "credentialless" },
],
},
];
},
};
export default nextConfig;
Lưu ý: điều hướng client-side từ một trang không isolated sang /editor sẽ không làm trang trở thành isolated — header chỉ có hiệu lực khi document được tải. Link vào editor nên là điều hướng đầy đủ (thẻ a thường, không qua router), và luôn kiểm tra crossOriginIsolated trước khi dùng SharedArrayBuffer, với đường fallback khi nó là false.
Atomics: đọc ghi an toàn trên bộ nhớ chung#
// main.ts — cờ huỷ và tiến độ dùng chung
const control = new Int32Array(new SharedArrayBuffer(8)); // [0] = cờ huỷ, [1] = tiến độ (phần nghìn)
worker.postMessage({ type: "crunch", control, input });
cancelButton.onclick = () => Atomics.store(control, 0, 1);
function renderProgress() {
progressBar.value = Atomics.load(control, 1) / 1000;
if (!done) requestAnimationFrame(renderProgress);
}
requestAnimationFrame(renderProgress);
function crunch(control: Int32Array, input: Float64Array) {
for (let i = 0; i < input.length; i++) {
if ((i & 0xfff) === 0) {
if (Atomics.load(control, 0) === 1) throw new DOMException("Cancelled", "AbortError");
Atomics.store(control, 1, Math.floor((i / input.length) * 1000));
}
// ... tính toán nặng, đồng bộ
}
}
Điểm hay: worker đang chạy một vòng lặp đồng bộ vẫn thấy cờ huỷ ngay lập tức, vì nó đọc bộ nhớ chứ không chờ message. Lưu ý Atomics.wait() không được phép trên main thread của trình duyệt (nó sẽ chặn UI); ở main thread, dùng Atomics.waitAsync() nơi được hỗ trợ.
Tự viết một lớp RPC kiểu Comlink#
Code worker thô thường bắt đầu bằng một switch (event.data.type) và kết thúc thành một mớ message type không ai nhớ. Comlink giải quyết đúng chuyện này bằng cách biến worker thành một object có method trả về Promise. Mình khuyên dùng Comlink cho hầu hết dự án — nhưng tự viết một bản nhỏ một lần giúp bạn hiểu chính xác cái gì đi qua đường ống, và cho phép thêm thứ Comlink không có sẵn: huỷ việc, tiến độ, và dọn dẹp khi worker chết.
Giao thức#
export type Call = { kind: "call"; id: number; method: string; input: unknown };
export type Cancel = { kind: "cancel"; id: number };
export type Progress = { kind: "progress"; id: number; value: number };
export type SerializedError = { name: string; message: string; stack?: string };
export type Result =
| { kind: "result"; id: number; ok: true; value: unknown }
| { kind: "result"; id: number; ok: false; error: SerializedError };
export type TaskContext = { signal: AbortSignal; progress(value: number): void };
export type Api = Record<string, (input: any, ctx: TaskContext) => unknown>;
// Đánh dấu giá trị cần transfer thay vì clone
const marked = new WeakMap<object, Transferable[]>();
export function transfer<T extends object>(value: T, list: Transferable[]): T {
marked.set(value, list);
return value;
}
export function takeTransfer(value: unknown): Transferable[] {
if (typeof value !== "object" || value === null) return [];
const list = marked.get(value) ?? [];
marked.delete(value);
return list;
}
Mỗi hàm trong API nhận đúng một input và một TaskContext. Giới hạn này cố ý: nó giữ kiểu dữ liệu đơn giản và để chỗ cho signal và progress mà không phải đoán tham số cuối là gì.
Phía worker: expose#
/// <reference lib="webworker" />
import { takeTransfer, type Api, type Call, type Cancel, type Result, type TaskContext } from "./protocol";
declare const self: DedicatedWorkerGlobalScope;
export function expose(api: Api) {
const running = new Map<number, AbortController>();
self.addEventListener("message", async (event: MessageEvent<Call | Cancel>) => {
const msg = event.data;
if (msg.kind === "cancel") {
running.get(msg.id)?.abort(new DOMException("Cancelled", "AbortError"));
return;
}
const controller = new AbortController();
running.set(msg.id, controller);
const ctx: TaskContext = {
signal: controller.signal,
progress: (value) => self.postMessage({ kind: "progress", id: msg.id, value }),
};
try {
const fn = api[msg.method];
if (!fn) throw new Error(`Unknown method "${msg.method}"`);
const value = await fn(msg.input, ctx);
self.postMessage({ kind: "result", id: msg.id, ok: true, value } satisfies Result, takeTransfer(value));
} catch (err) {
const e = err instanceof Error ? err : new Error(String(err));
self.postMessage({
kind: "result",
id: msg.id,
ok: false,
error: { name: e.name, message: e.message, stack: e.stack },
} satisfies Result);
} finally {
running.delete(msg.id);
}
});
}
Phía main thread: wrap#
import { takeTransfer, type Api, type Call, type Progress, type Result } from "./protocol";
export type CallOptions = { signal?: AbortSignal; onProgress?: (value: number) => void };
export type Remote<T extends Api> = {
[K in keyof T]: (input: Parameters<T[K]>[0], opts?: CallOptions) => Promise<Awaited<ReturnType<T[K]>>>;
};
type Pending = { resolve(v: unknown): void; reject(e: unknown): void; onProgress?: (v: number) => void };
export function wrap<T extends Api>(worker: Worker) {
let nextId = 1;
const pending = new Map<number, Pending>();
worker.addEventListener("message", (event: MessageEvent<Result | Progress>) => {
const msg = event.data;
const p = pending.get(msg.id);
if (!p) return; // đã huỷ hoặc đã xong
if (msg.kind === "progress") return p.onProgress?.(msg.value);
pending.delete(msg.id);
if (msg.ok) p.resolve(msg.value);
else p.reject(Object.assign(new Error(msg.error.message), msg.error));
});
const failAll = (reason: unknown) => {
for (const p of pending.values()) p.reject(reason);
pending.clear();
};
worker.addEventListener("error", (e) => failAll(new Error(`Worker crashed: ${e.message}`)));
worker.addEventListener("messageerror", () => failAll(new Error("Could not deserialize worker message")));
const api = new Proxy({} as Remote<T>, {
get(_target, method) {
// Không có dòng này, `await api` sẽ coi proxy là thenable và treo mãi
if (method === "then" || typeof method !== "string") return undefined;
return (input: unknown, { signal, onProgress }: CallOptions = {}) =>
new Promise((resolve, reject) => {
if (signal?.aborted) return reject(signal.reason);
const id = nextId++;
const onAbort = () => {
pending.delete(id);
worker.postMessage({ kind: "cancel", id });
reject(signal!.reason);
};
signal?.addEventListener("abort", onAbort, { once: true });
const cleanup = () => signal?.removeEventListener("abort", onAbort);
pending.set(id, {
resolve: (v) => (cleanup(), resolve(v)),
reject: (e) => (cleanup(), reject(e)),
onProgress,
});
worker.postMessage({ kind: "call", id, method, input } satisfies Call, takeTransfer(input));
});
},
});
const dispose = (reason: unknown = new DOMException("Worker disposed", "AbortError")) => {
worker.terminate();
failAll(reason);
};
return { api, dispose };
}
Ba chi tiết đáng chú ý:
- Bẫy
thencủa Proxy. Proxy trả về function cho mọi tên property, kể cảthen. Khi ai đóawaitcái proxy (hoặc trả nó từ một hàmasync), JavaScript coi nó là thenable và gọithen— một RPC call không bao giờ resolve. Comlink cũng phải xử lý đúng chuyện này. errorvàmessageerror. Worker crash (lỗi cú pháp, hết bộ nhớ) mà không reject các promise đang chờ thì UI của bạn sẽ quay spinner mãi mãi.disposereject mọi promise đang chờ.terminate()một mình thì không làm điều đó — các promise chỉ đơn giản là treo.
Dùng thì gọn và có kiểu đầy đủ:
import { expose } from "@/lib/rpc/expose";
import type { TaskContext } from "@/lib/rpc/protocol";
const api = {
async primes(limit: number, { signal, progress }: TaskContext) {
const found: number[] = [];
for (let n = 2; n <= limit; n++) {
if (n % 50_000 === 0) {
progress(n / limit);
await new Promise((r) => setTimeout(r)); // nhường để nhận được message huỷ
signal.throwIfAborted();
}
if (isPrime(n)) found.push(n);
}
return found.length;
},
};
export type MathApi = typeof api;
expose(api);
function isPrime(n: number) {
for (let d = 2; d * d <= n; d++) if (n % d === 0) return false;
return true;
}
import { wrap } from "@/lib/rpc/wrap";
import type { MathApi } from "@/workers/math.worker";
const { api, dispose } = wrap<MathApi>(
new Worker(new URL("./workers/math.worker.ts", import.meta.url), { type: "module" }),
);
const controller = new AbortController();
const count = await api.primes(5_000_000, {
signal: controller.signal,
onProgress: (p) => (bar.value = p),
});
import type chỉ lấy kiểu, không kéo code của worker vào bundle chính — chi tiết nhỏ nhưng quên là bạn đóng gói worker hai lần.
Cấu hình bundler: Next.js, Vite và TypeScript#
Pattern chuẩn được Vite, webpack 5 và Turbopack cùng nhận diện:
const worker = new Worker(new URL("./search.worker.ts", import.meta.url), { type: "module" });
Bundler thấy new URL(..., import.meta.url) đặt trực tiếp trong new Worker(...), tách file đó thành một entry riêng, biên dịch TypeScript và thay bằng URL đã có hash. Điều kiện là biểu thức phải viết nguyên văn như vậy: đưa đường dẫn vào một biến, dựng chuỗi động, hay bọc trong hàm helper là bundler không nhận ra nữa, và bạn nhận về một lỗi 404 ở production.
Vite#
import { defineConfig } from "vite";
export default defineConfig({
worker: {
// Mặc định là "iife"; "es" cần thiết khi worker dùng dynamic import / code splitting
format: "es",
},
});
Vite còn có cú pháp riêng import SearchWorker from "./search.worker?worker", tiện nhưng không chuẩn — mình ưu tiên pattern new URL để code chạy được qua nhiều bundler.
Next.js: chỉ tạo worker ở client#
Worker không tồn tại trên server. Tạo nó ở top-level của module trong một Client Component vẫn sẽ chạy lúc server render và ném Worker is not defined. Chỗ đúng là trong useEffect, hoặc lười tạo trong event handler.
"use client";
import { useEffect, useRef } from "react";
import { wrap, type Remote } from "@/lib/rpc/wrap";
import type { Api } from "@/lib/rpc/protocol";
export function useWorker<T extends Api>(create: () => Worker) {
const ref = useRef<Remote<T> | null>(null);
useEffect(() => {
const { api, dispose } = wrap<T>(create());
ref.current = api;
// StrictMode chạy effect hai lần ở dev — cleanup đảm bảo không rò worker
return () => {
ref.current = null;
dispose();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return ref;
}
// Sử dụng: pattern new URL phải nằm nguyên văn ở đây để bundler nhận ra
// const search = useWorker<SearchApi>(() =>
// new Worker(new URL("../workers/search.worker.ts", import.meta.url), { type: "module", name: "search" }),
// );
Option name hiện tên worker trong DevTools — rất đáng thêm khi bạn có nhiều hơn một worker.
TypeScript cho file worker#
File worker chạy trong DedicatedWorkerGlobalScope, không phải Window. Cách gọn nhất là thêm /// <reference lib="webworker" /> ở đầu file và khai báo self như trong expose.ts ở trên. Với dự án lớn, mình tách một tsconfig.worker.json riêng có "lib": ["ES2023", "WebWorker"] cho các file *.worker.ts, để IDE báo lỗi ngay khi ai đó lỡ dùng document trong worker.
CSP cũng áp dụng cho worker
Nếu site có Content Security Policy, worker được kiểm soát bởi directive worker-src (rơi về script-src rồi default-src nếu không khai báo). Worker tạo từ blob: URL — một số thư viện làm vậy để nhúng worker inline — cần blob: trong worker-src. Worker có URL riêng cùng origin như pattern new URL ở trên thì chỉ cần 'self'.
Worker pool: song song có kiểm soát#
Một worker chỉ chạy một việc tại một thời điểm — mười ảnh cần resize thì chín ảnh xếp hàng. Nhưng tạo một worker mới cho mỗi việc thì lần nào cũng trả phí khởi động: tạo thread, tải và parse script, khởi tạo heap. Giải pháp là một pool: số worker có giới hạn, tạo lười, tái sử dụng, và tự tắt khi rảnh lâu.
import { wrap, type Remote } from "./wrap";
import type { Api } from "./protocol";
type Slot<T extends Api> = {
api: Remote<T>;
dispose(reason?: unknown): void;
dead: boolean;
idleTimer?: ReturnType<typeof setTimeout>;
};
type Job<T extends Api> = {
method: keyof T & string;
input: unknown;
signal?: AbortSignal;
resolve(value: unknown): void;
reject(reason: unknown): void;
};
const defaultSize = () => Math.max(1, Math.min(4, (navigator.hardwareConcurrency || 2) - 1));
export class WorkerPool<T extends Api> {
private idle: Slot<T>[] = [];
private queue: Job<T>[] = [];
private live = 0;
constructor(
private spawn: () => Worker,
private max = defaultSize(),
private idleMs = 30_000,
) {}
run<K extends keyof T & string>(
method: K,
input: Parameters<T[K]>[0],
{ signal }: { signal?: AbortSignal } = {},
): Promise<Awaited<ReturnType<T[K]>>> {
return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(signal.reason);
const job: Job<T> = { method, input, signal, resolve: resolve as Job<T>["resolve"], reject };
// Việc còn trong hàng đợi: chỉ cần rút ra
signal?.addEventListener(
"abort",
() => {
const i = this.queue.indexOf(job);
if (i !== -1) {
this.queue.splice(i, 1);
reject(signal.reason);
}
},
{ once: true },
);
this.queue.push(job);
this.pump();
});
}
private pump() {
while (this.queue.length) {
const slot = this.idle.pop() ?? (this.live < this.max ? this.create() : undefined);
if (!slot) return;
clearTimeout(slot.idleTimer);
void this.execute(slot, this.queue.shift()!);
}
}
private create(): Slot<T> {
this.live++;
const { api, dispose } = wrap<T>(this.spawn());
return { api, dispose, dead: false };
}
private async execute(slot: Slot<T>, job: Job<T>) {
// Việc đang chạy: cách huỷ chắc chắn duy nhất là giết worker
const onAbort = () => {
slot.dead = true;
slot.dispose(job.signal!.reason);
};
job.signal?.addEventListener("abort", onAbort, { once: true });
try {
job.resolve(await slot.api[job.method](job.input as never));
} catch (err) {
job.reject(err);
} finally {
job.signal?.removeEventListener("abort", onAbort);
if (slot.dead) this.live--;
else this.release(slot);
this.pump();
}
}
private release(slot: Slot<T>) {
slot.idleTimer = setTimeout(() => {
this.idle = this.idle.filter((s) => s !== slot);
this.live--;
slot.dispose();
}, this.idleMs);
this.idle.push(slot);
}
destroy() {
for (const slot of this.idle) slot.dispose();
this.idle = [];
for (const job of this.queue.splice(0)) job.reject(new DOMException("Pool destroyed", "AbortError"));
}
}
Kích thước pool là chỗ dễ làm quá tay:
navigator.hardwareConcurrencykhông phải số core bạn được dùng. Trình duyệt có thể làm tròn hoặc giới hạn giá trị này vì lý do riêng tư, và main thread, thread compositor, GPU cũng cần CPU. Mình trừ đi một và đặt trần thấp, đặc biệt trên mobile.- Mỗi worker là một heap JavaScript riêng. Bốn worker nạp cùng một thư viện 2 MB là bốn bản trong bộ nhớ. Trên điện thoại tầm thấp, đó là đường nhanh tới việc tab bị hệ điều hành kill.
- Worker có trạng thái thì đừng pool. Search index nằm trong một worker; pool bốn worker nghĩa là bốn bản index, hoặc truy vấn rơi vào worker chưa có index. Worker sống lâu có trạng thái nên là một worker riêng.
Huỷ việc bằng AbortSignal#
Huỷ việc là thứ mà các ví dụ worker trên mạng hay bỏ qua, nhưng trong UI thật nó xảy ra liên tục: người dùng gõ thêm một ký tự, đổi bộ lọc, rời trang.
AbortSignal không clone được qua postMessage, nên ta không gửi được signal sang worker. Thay vào đó, main thread lắng nghe signal và gửi một message cancel — đúng như wrap ở trên. Nhưng có một cái bẫy mà mình đã mất một buổi chiều để hiểu:
Worker cũng có event loop — và nó cũng có thể bị chặn
Nếu hàm trong worker chạy một vòng lặp đồng bộ năm giây, message cancel nằm trong hàng đợi của worker suốt năm giây đó, không bao giờ được xử lý cho tới khi vòng lặp kết thúc. Huỷ bằng message chỉ hoạt động khi code trong worker thỉnh thoảng nhường lượt (một await thật sự đi qua event loop, ví dụ đọc stream hay setTimeout) rồi kiểm tra signal.aborted.
Vậy có ba cách huỷ, theo thứ tự mình hay dùng:
- Nhường lượt và kiểm tra signal trong worker — như hàm
primesở trên, hoặc tự nhiên khi bạn đọc dữ liệu theo stream. Rẻ, worker vẫn sống, trạng thái giữ nguyên. - Cờ trong
SharedArrayBufferđọc bằngAtomics.load— phản hồi tức thì kể cả trong vòng lặp đồng bộ, nhưng đòi cross-origin isolation. terminate()rồi tạo worker mới — luôn hoạt động, bất kể code bên trong làm gì. Đổi lại bạn mất trạng thái và trả phí khởi động lại. Pool ở trên dùng cách này cho việc đang chạy.
Pattern typeahead mình dùng nhiều nhất — mỗi lần gõ huỷ truy vấn trước:
let current: AbortController | null = null;
export async function search(q: string) {
current?.abort(new DOMException("Superseded", "AbortError"));
const controller = (current = new AbortController());
try {
return await searchApi.query({ q, limit: 20 }, { signal: controller.signal });
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return null; // bị thay thế, không phải lỗi
throw err;
}
}
Ví dụ thực tế#
Parse một file CSV lớn#
Sai lầm kinh điển: await file.text() trên main thread, rồi gửi chuỗi 200 MB sang worker. Thay vào đó, gửi chính File — nó được clone theo tham chiếu — và đọc theo stream trong worker. Đọc stream còn có lợi phụ: mỗi await reader.read() là một lần nhường lượt, nên message huỷ được xử lý giữa chừng.
import { expose } from "@/lib/rpc/expose";
import { transfer, type TaskContext } from "@/lib/rpc/protocol";
type Summary = { rows: number; revenueByCountry: Map<string, number>; amounts: Float64Array };
const api = {
async summarize(file: File, { signal, progress }: TaskContext): Promise<Summary> {
const reader = file.stream().pipeThrough(new TextDecoderStream()).getReader();
const revenueByCountry = new Map<string, number>();
let amounts = new Float64Array(1 << 16);
let rows = 0;
let carry = "";
let header: string[] | null = null;
let readChars = 0;
let lastReported = 0;
for (;;) {
const { value, done } = await reader.read();
signal.throwIfAborted();
if (done) break;
readChars += value.length;
const lines = (carry + value).split("\n");
carry = lines.pop() ?? "";
for (const line of lines) {
// Ví dụ đơn giản: không xử lý dấu phẩy trong ngoặc kép.
// Dữ liệu thật nên dùng một parser CSV đầy đủ, vẫn chạy trong worker.
const cols = line.split(",");
if (!header) {
header = cols;
continue;
}
const amount = Number(cols[3]);
if (rows === amounts.length) {
const bigger = new Float64Array(amounts.length * 2);
bigger.set(amounts);
amounts = bigger;
}
amounts[rows++] = amount;
revenueByCountry.set(cols[2], (revenueByCountry.get(cols[2]) ?? 0) + amount);
}
const ratio = readChars / file.size; // xấp xỉ: ký tự so với byte
if (ratio - lastReported > 0.01) {
progress(Math.min(ratio, 1));
lastReported = ratio;
}
}
const result = { rows, revenueByCountry, amounts: amounts.slice(0, rows) };
return transfer(result, [result.amounts.buffer]);
},
};
export type CsvApi = typeof api;
expose(api);
Kết quả trả về là một Map nhỏ và một Float64Array được transfer — không phải mảng hai trăm nghìn object. Main thread nhận về gần như tức thì và vẽ biểu đồ từ dữ liệu dạng cột. Đó chính là bài học từ lần làm sai ở đầu bài.
Search index sống trong worker#
import MiniSearch from "minisearch";
import { expose } from "@/lib/rpc/expose";
type Doc = { id: string; title: string; body: string; url: string };
let index: MiniSearch<Doc> | null = null;
const titles = new Map<string, { title: string; url: string }>();
const api = {
async load(url: string) {
// fetch và JSON.parse ngay trong worker: main thread không bao giờ chạm vào vài chục MB JSON
const docs: Doc[] = await (await fetch(url)).json();
index = new MiniSearch<Doc>({ fields: ["title", "body"], storeFields: [] });
index.addAll(docs);
for (const d of docs) titles.set(d.id, { title: d.title, url: d.url });
return docs.length;
},
query({ q, limit }: { q: string; limit: number }) {
if (!index) throw new Error("Index not loaded");
return index
.search(q, { prefix: true, fuzzy: 0.2 })
.slice(0, limit)
.map((hit) => ({ id: hit.id as string, score: hit.score, ...titles.get(hit.id as string)! }));
},
};
export type SearchApi = typeof api;
expose(api);
Đây là ví dụ điển hình của "dữ liệu ở lại worker": tài liệu đi vào worker một lần qua mạng, mỗi truy vấn chỉ gửi một chuỗi ngắn và nhận về 20 kết quả. Đây cũng là lý do worker này là một worker riêng, không nằm trong pool.
Xử lý ảnh trước khi upload#
import { expose } from "@/lib/rpc/expose";
const api = {
async thumbnail({ file, maxSize }: { file: Blob; maxSize: number }) {
const bitmap = await createImageBitmap(file);
const scale = Math.min(1, maxSize / Math.max(bitmap.width, bitmap.height));
const canvas = new OffscreenCanvas(Math.round(bitmap.width * scale), Math.round(bitmap.height * scale));
const ctx = canvas.getContext("2d")!;
ctx.imageSmoothingQuality = "high";
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
bitmap.close();
return canvas.convertToBlob({ type: "image/webp", quality: 0.85 });
},
};
export type ImageApi = typeof api;
expose(api);
const pool = new WorkerPool<ImageApi>(
() => new Worker(new URL("./workers/image.worker.ts", import.meta.url), { type: "module" }),
);
const thumbs = await Promise.all(files.map((file) => pool.run("thumbnail", { file, maxSize: 1600 })));
Cả đầu vào lẫn đầu ra đều là Blob, đi qua đường ống theo tham chiếu. Việc decode, resize và encode — phần đắt nhất — hoàn toàn nằm ngoài main thread. Đây là việc không có trạng thái, nên pool là lựa chọn tự nhiên.
WebAssembly trong worker#
WASM và worker là cặp đôi tự nhiên: module WASM thường làm việc CPU nặng, và việc compile module cũng tốn thời gian. Đặt cả hai trong worker giữ main thread sạch hoàn toàn.
import { expose } from "@/lib/rpc/expose";
import { transfer } from "@/lib/rpc/protocol";
type Exports = {
memory: WebAssembly.Memory;
alloc(len: number): number;
free(ptr: number, len: number): void;
blur(ptr: number, width: number, height: number, radius: number): void;
};
// Compile một lần khi worker khởi động; server phải trả Content-Type: application/wasm
const ready = WebAssembly.instantiateStreaming(fetch(new URL("./image_ops.wasm", import.meta.url))).then(
({ instance }) => instance.exports as unknown as Exports,
);
const api = {
async blur({ pixels, width, height, radius }: { pixels: Uint8ClampedArray; width: number; height: number; radius: number }) {
const wasm = await ready;
const len = pixels.byteLength;
const ptr = wasm.alloc(len);
// Đọc lại memory.buffer SAU khi alloc: memory có thể grow và buffer cũ bị detached
new Uint8ClampedArray(wasm.memory.buffer, ptr, len).set(pixels);
wasm.blur(ptr, width, height, radius);
const out = new Uint8ClampedArray(wasm.memory.buffer.slice(ptr, ptr + len));
wasm.free(ptr, len);
return transfer(out, [out.buffer]);
},
};
export type WasmApi = typeof api;
expose(api);
Trong thực tế, các toolchain như wasm-bindgen hay Emscripten sinh sẵn phần glue này; ví dụ trên chỉ để thấy dữ liệu đi đâu. Lưu ý chi tiết về memory.buffer: khi bộ nhớ WASM grow, mọi view tạo trước đó trỏ vào buffer đã detached — một bug rất khó hiểu nếu bạn chưa gặp. Và WASM đa luồng thì đòi hỏi SharedArrayBuffer, nên mọi điều ở phần cross-origin isolation đều áp dụng.
Khi chưa cần worker: scheduler.yield, postTask và chia nhỏ#
Phần lớn long task mình gặp không cần worker. Chúng là việc cần DOM — khởi tạo widget, render, đo layout — hoặc là việc vừa phải mà chỉ cần nhường lượt cho trình duyệt xử lý input. Bài Event loop, microtask và rendering đi sâu vào cơ chế; ở đây là bộ công cụ mình dùng.
type SchedulerLike = {
yield?: () => Promise<void>;
postTask?: <T>(cb: () => T, opts?: { priority?: string; signal?: AbortSignal; delay?: number }) => Promise<T>;
};
const scheduler = (globalThis as { scheduler?: SchedulerLike }).scheduler;
export function yieldToMain(): Promise<void> {
// scheduler.yield() nhường lượt nhưng phần tiếp theo được ưu tiên chạy trước các task khác
if (scheduler?.yield) return scheduler.yield();
return new Promise((resolve) => setTimeout(resolve, 0));
}
export async function processInChunks<T>(items: T[], fn: (item: T) => void, budgetMs = 8) {
let deadline = performance.now() + budgetMs;
for (const item of items) {
fn(item);
if (performance.now() > deadline) {
await yieldToMain();
deadline = performance.now() + budgetMs;
}
}
}
scheduler.postTask() cho phép đặt độ ưu tiên — user-blocking, user-visible, background — và huỷ bằng signal. Mình dùng nó để đẩy việc không gấp (prefetch dữ liệu, dựng index phụ) xuống sau:
if (scheduler?.postTask) {
const controller = new AbortController();
scheduler.postTask(() => warmUpCaches(), { priority: "background", signal: controller.signal });
// rời trang hoặc không còn cần: controller.abort()
} else {
globalThis.requestIdleCallback?.(() => warmUpCaches());
}
Hỗ trợ của scheduler.yield() và scheduler.postTask() khác nhau giữa các trình duyệt, nên luôn feature-detect như trên. Trong React, useTransition và useDeferredValue cho bạn đúng tinh thần này ở cấp render: cập nhật tốn kém được đánh dấu có thể bị ngắt để input được xử lý trước.
| Cách | Việc chạy ở đâu | Tổng thời gian | Dùng khi |
|---|---|---|---|
Chia nhỏ + scheduler.yield() | Main thread | Như cũ hoặc hơi dài hơn | Việc cần DOM, hoặc vừa phải |
scheduler.postTask background / requestIdleCallback | Main thread, lúc rảnh | Như cũ, chạy muộn hơn | Việc không gấp |
useTransition / useDeferredValue | Main thread, React ngắt được | Như cũ | Re-render tốn kém theo input |
| Web Worker | Thread riêng | Cộng thêm chi phí truyền dữ liệu | CPU nặng, không DOM, đầu ra nhỏ |
| Worker + WASM | Thread riêng, code native | Thường ngắn hơn cho tính toán số | Thuật toán nặng có sẵn bản C/C++/Rust |
Những lỗi mình hay gặp#
Trả về dữ liệu thô khổng lồ. Worker parse xong rồi postMessage hai trăm nghìn object — main thread trả phí deserialize, long task quay lại ở đầu bên kia.
Đọc file thành chuỗi trên main thread rồi mới gửi. Gửi File hoặc Blob, đọc trong worker.
Tạo worker mới cho mỗi lần gọi. Mỗi lần trả phí khởi động; dùng worker sống lâu hoặc pool.
Quên terminate() trong cleanup của effect. StrictMode ở dev chạy effect hai lần, mỗi lần một worker mồ côi; ở production, mỗi lần mount lại là thêm một worker.
Đưa việc phụ thuộc DOM vào worker. Phần chậm là render thì worker không giúp được; cần virtualization hoặc giảm DOM.
Dùng buffer sau khi đã transfer. Nó đã detached, độ dài bằng không. Và transfer .buffer của subarray là transfer cả buffer gốc.
Không xử lý error và messageerror. Worker crash và UI quay spinner mãi mãi.
Huỷ bằng message trong khi worker chạy vòng lặp đồng bộ. Message không bao giờ được đọc kịp; nhường lượt, dùng cờ SAB, hoặc terminate().
Bật COOP/COEP cho toàn site. Popup đăng nhập, video nhúng, ảnh từ CDN lần lượt hỏng. Chỉ bật trên route cần.
Tạo worker ở top-level module trong Next.js. Worker is not defined lúc server render.
Đưa đường dẫn worker vào biến. Bundler không nhận ra pattern, file worker không được build, 404 ở production.
Tóm tắt#
- Đo trước: chỉ chuyển sang worker khi profile chỉ ra việc CPU nặng, không cần DOM, và đầu ra đủ nhỏ.
- Gửi tham chiếu (
File,Blob), để worker tự fetch và parse, giữ dữ liệu ở lại worker, trả về bản tóm tắt. - Dùng transferable cho dữ liệu nhị phân và
OffscreenCanvas; nhớ buffer bị detached, cẩn thận với subarray. - Chỉ dùng
SharedArrayBufferkhi thật cần; bật COOP/COEP trên đúng route, kiểm tracrossOriginIsolated, có fallback. - Bọc worker bằng RPC có kiểu (Comlink hoặc tự viết): xử lý
thencủa Proxy,error,messageerror, và reject mọi promise khi dispose. - Dùng nguyên văn
new Worker(new URL("./x.worker.ts", import.meta.url), { type: "module" }); tạo worker tronguseEffectvàterminate()khi cleanup. - Pool cho việc không trạng thái, kích thước nhỏ có trần; worker có trạng thái (index, model) là worker riêng sống lâu.
- Huỷ việc: nhường lượt và kiểm tra signal trong worker, cờ SAB nếu có isolation,
terminate()khi cần chắc chắn. - Việc cần DOM hoặc vừa phải: chia nhỏ với
scheduler.yield(),postTaskcho việc nền,useTransitioncho render. - Kiểm tra lại bằng INP thật trên thiết bị yếu, không chỉ bằng cảm giác "main thread rảnh rồi".