Bắt Đầu Nhanh — Chạy Worker Trong 5 Phút
Sáu tháng trước, tôi ra mắt một dashboard trực quan hóa dữ liệu phải xử lý file CSV với hơn 50.000 dòng ngay phía client. Mỗi lần người dùng upload file, trình duyệt đơ cứng 3–4 giây. Các nút bấm không phản hồi, animation giật lag, thanh cuộn hoàn toàn bị khóa. Cách sửa chỉ cần khoảng 30 dòng code và không cần thư viện bên thứ ba nào — chỉ là Web Workers.
Dưới đây là mức tối thiểu để chuyển công việc ra khỏi main thread. Không framework, không bước build.
Tạo file worker.js:
// worker.js
self.onmessage = function (e) {
const result = heavyComputation(e.data);
self.postMessage(result);
};
function heavyComputation(data) {
// Giả lập tác vụ nặng
let sum = 0;
for (let i = 0; i < data.iterations; i++) {
sum += Math.sqrt(i) * Math.sin(i);
}
return sum;
}
Sau đó kết nối từ script chính:
// main.js
const worker = new Worker('worker.js');
worker.postMessage({ iterations: 10_000_000 });
worker.onmessage = function (e) {
console.log('Kết quả từ worker:', e.data);
// Cập nhật UI tại đây — an toàn, đã quay lại main thread
};
worker.onerror = function (err) {
console.error('Lỗi worker:', err.message);
};
Xong. Trong khi worker xử lý mười triệu vòng lặp, UI thread vẫn rảnh rang. Nút bấm phản hồi, animation chạy trơn tru, người dùng chẳng nhận ra gì cả.
Tìm Hiểu Sâu — Điều Gì Thực Sự Đang Xảy Ra
Vấn Đề Single-Thread
JavaScript chạy trên một thread duy nhất. Event loop xử lý mọi thứ: cập nhật DOM, input người dùng, callback mạng, logic ứng dụng của bạn. Khi một tác vụ chạy lâu — chẳng hạn sắp xếp 100.000 bản ghi hay giải mã một ảnh lớn — mọi tác vụ khác phải chờ đến lượt. Chính sự chờ đợi đó mà người dùng cảm nhận được là giao diện bị đóng băng.
Web Workers giải quyết điều này bằng cách chạy JavaScript trên một OS thread hoàn toàn riêng biệt. Mỗi worker có heap riêng, event loop riêng, và global scope riêng (self thay vì window). Hai thread không bao giờ dùng chung bộ nhớ trực tiếp. Chúng giao tiếp qua message passing, giúp chúng được cô lập an toàn với nhau.
Mô Hình Messaging
Dữ liệu truyền giữa các thread được sao chép theo mặc định bằng thuật toán structured clone. Nó xử lý được object, array, typed array, Map, Set, và ArrayBuffer — nhưng không xử lý được function, DOM node, hay class instance có phương thức.
// Gửi dữ liệu phức tạp
worker.postMessage({
matrix: [[1, 2], [3, 4]],
config: { normalize: true, precision: 4 }
});
// Bên trong worker.js
self.onmessage = function (e) {
const { matrix, config } = e.data;
const result = processMatrix(matrix, config);
self.postMessage(result);
};
Transferable Objects — Zero-Copy Cho Dữ Liệu Lớn
Sao chép một ArrayBuffer lớn — ví dụ như dữ liệu âm thanh thô hay pixel ảnh — rất tốn kém. Thay vào đó, bạn có thể transfer quyền sở hữu sang worker. Việc transfer mất thời gian cố định bất kể kích thước buffer:
// main.js — transfer ArrayBuffer 10MB sang worker
const buffer = new ArrayBuffer(10 * 1024 * 1024);
worker.postMessage({ buffer }, [buffer]);
// Sau dòng này, `buffer` đã bị vô hiệu hóa — main thread không còn đọc được nữa
// worker.js — transfer lại sau khi xử lý
self.onmessage = function (e) {
const buf = e.data.buffer;
// ... xử lý buf ...
self.postMessage({ result: buf }, [buf]);
};
Tôi dùng pattern này để xử lý ảnh canvas trong production. Transfer buffer ảnh 4K giảm từ ~8ms xuống dưới 0,1ms. Với bất kỳ dữ liệu nào trên vài trăm KB, transfer luôn thắng sao chép.
Sử Dụng Nâng Cao
Worker Pool Để Xử Lý Song Song
Một worker cho bạn thêm một thread. Nếu khối lượng công việc có thể chia nhỏ — sắp xếp từng phần dataset, chạy các lời gọi inference song song, xử lý từng frame video riêng lẻ — một pool worker sẽ nhân throughput lên tỷ lệ thuận với số nhân CPU.
// worker-pool.js
class WorkerPool {
constructor(workerScript, poolSize = navigator.hardwareConcurrency || 4) {
this.workers = Array.from({ length: poolSize }, () => ({
worker: new Worker(workerScript),
busy: false
}));
this.queue = [];
}
run(data) {
return new Promise((resolve, reject) => {
const free = this.workers.find(w => !w.busy);
if (free) {
this._dispatch(free, data, resolve, reject);
} else {
this.queue.push({ data, resolve, reject });
}
});
}
_dispatch(slot, data, resolve, reject) {
slot.busy = true;
slot.worker.onmessage = (e) => {
resolve(e.data);
slot.busy = false;
if (this.queue.length > 0) {
const next = this.queue.shift();
this._dispatch(slot, next.data, next.resolve, next.reject);
}
};
slot.worker.onerror = (err) => {
reject(err);
slot.busy = false;
};
slot.worker.postMessage(data);
}
}
// Sử dụng
const pool = new WorkerPool('worker.js', 4);
const promises = chunks.map(chunk => pool.run(chunk));
const results = await Promise.all(promises);
navigator.hardwareConcurrency trả về số nhân CPU logic — 4 trên laptop tầm trung, 12+ trên desktop hiện đại. Điều chỉnh pool theo số này giúp tránh tạo quá nhiều thread trên điện thoại cấu hình thấp trong khi vẫn tận dụng tối đa phần cứng mạnh hơn.
Inline Worker Với Blob URL
Phải ship thêm file worker.js riêng sẽ khá phiền khi dùng bundler. Thay vào đó, hãy định nghĩa worker ngay trong code:
const workerCode = `
self.onmessage = function(e) {
const result = e.data.reduce((acc, n) => acc + n, 0);
self.postMessage(result);
};
`;
const blob = new Blob([workerCode], { type: 'application/javascript' });
const url = URL.createObjectURL(blob);
const worker = new Worker(url);
URL.revokeObjectURL(url); // Dọn dẹp URL sau khi worker được tạo
Vite và webpack 5 đều hỗ trợ new Worker(new URL('./worker.js', import.meta.url)) với đầy đủ hỗ trợ ESM module. Đây là cách sạch hơn nếu bạn đang dùng build setup hiện đại.
Shared Worker Cho Giao Tiếp Đa Tab
Một Worker thông thường chỉ thuộc về một tab. Một SharedWorker tồn tại xuyên suốt tất cả các tab cùng origin — tiện cho kết nối WebSocket dùng chung hoặc lớp cache đa tab:
// shared-worker.js
const connections = [];
self.onconnect = function (e) {
const port = e.ports[0];
connections.push(port);
port.onmessage = function (msg) {
// Phát broadcast đến tất cả các tab đang kết nối
connections.forEach(p => p.postMessage(msg.data));
};
port.start();
};
// main.js (bất kỳ tab nào)
const sw = new SharedWorker('shared-worker.js');
sw.port.onmessage = (e) => console.log('Nhận broadcast:', e.data);
sw.port.start();
sw.port.postMessage('Xin chào từ Tab 1');
Mẹo Thực Tế Từ Production
Biết Khi Nào KHÔNG Nên Dùng Worker
Worker không miễn phí. Khởi tạo một worker tốn khoảng 5–10ms và vài MB bộ nhớ. Với các tác vụ hoàn thành trong dưới 50ms, overhead này có thể triệt tiêu lợi ích. Nguyên tắc của tôi: nếu công việc sẽ chặn UI hơn một animation frame (~16ms), nó nên được đưa vào worker.
Phù hợp: parse JSON payload lớn, mã hóa, nén/giải nén, xử lý tích chập ảnh, ONNX inference, và bất kỳ vòng lặp tính toán nặng nào.
Không phù hợp: sắp xếp vài nghìn phần tử, định dạng chuỗi, hoặc bất cứ thứ gì cần truy cập DOM — worker không có document hay window.
Xử Lý Lỗi Và Graceful Degradation
const worker = new Worker('worker.js');
worker.onerror = (e) => {
console.error(`Lỗi worker tại ${e.filename}:${e.lineno} — ${e.message}`);
// Fallback về thực thi trên main thread
const result = heavyComputation(pendingData);
updateUI(result);
};
// Luôn thêm timeout cho worker chạy lâu
const TIMEOUT_MS = 30_000;
const timeoutId = setTimeout(() => {
console.warn('Worker hết thời gian chờ, đang dừng');
worker.terminate();
}, TIMEOUT_MS);
worker.onmessage = (e) => {
clearTimeout(timeoutId);
updateUI(e.data);
};
Debug Worker Trong Chrome DevTools
Mở DevTools → tab Sources → tìm panel Threads ở bên phải. Các worker hiển thị ở đó với call stack riêng của chúng. Bạn có thể đặt breakpoint bên trong script worker y hệt như code trên main thread. Thật sự, tôi ước mình tìm ra điều này sớm hơn — tôi đã mất hai tiếng log giá trị để tìm một bug mà một breakpoint duy nhất sẽ giải quyết trong mười giây.
Đo Lường Tác Động
Trước khi ship, hãy đo bằng tab Performance. Ghi trace có và không có worker. Tín hiệu then chốt là Long Tasks — bất kỳ tác vụ nào chặn main thread hơn 50ms. Sau khi chuyển CSV parser vào worker, số Long Task trong lúc upload file giảm từ 4 xuống còn 0. Chỉ số Total Blocking Time của Lighthouse giảm từ 680ms xuống 40ms với cùng khối lượng công việc.
Web Workers là một trong những API trông có vẻ đơn giản cho đến khi bạn gặp các trường hợp ngoại lệ. API cốt lõi chỉ có ba phương thức. Phần khó là mọi thứ xung quanh: quyết định kích thước pool, xử lý transferable đúng cách, viết graceful fallback, biết khi nào overhead không đáng. Nắm vững mô hình tư duy — hai thread độc lập giao tiếp qua message — và phần còn lại sẽ tự nhiên vào chỗ. Sau đó, khởi tạo một worker sẽ quen thuộc như viết một hàm async.

