Skip to main content
JavaScript 15 mins read Devs2

JavaScript Web Workers: Hướng Dẫn Hoàn Chỉnh Để Không Bao Giờ "Đơ" Giao Diện

Học Web Workers từ A-Z: dedicated worker, module worker, structured clone, transferable objects, worker pool, OffscreenCanvas, SharedWorker, Comlink, build với Vite/Astro và cách sửa lỗi INP do main thread bị chặn.

#Web Workers #JavaScript #Performance #Browser API #INP #Main Thread #OffscreenCanvas #Concurrency

Minh họa JavaScript Web Workers với main thread, worker thread riêng và cơ chế postMessage

Bạn bấm một nút, giao diện đứng hình nửa giây. Con trỏ chuột không nhúc nhích, hover không đổi màu, nút “Đang xử lý…” chỉ xuất hiện sau khi công việc đã xong. Người dùng tưởng trang bị treo, bấm thêm ba lần nữa, rồi tải lại.

Đây không phải bug hiếm. Đây là hệ quả tất yếu của việc JavaScript trong trình duyệt chỉ có một luồng.

Mọi thứ bạn viết — xử lý sự kiện, cập nhật DOM, chạy JSON.parse trên file 20MB, tính toán ma trận, lọc 100.000 dòng dữ liệu — đều xếp hàng trên cùng một main thread. Việc render, layout, style và event handling cũng nằm trong hàng đó. Khi một tác vụ chiếm luồng quá lâu, mọi thứ khác phải chờ. Chỉ số đo lường việc này có tên: INP (Interaction to Next Paint), và từ 2024 nó là một trong ba Core Web Vitals chính thức ảnh hưởng đến thứ hạng tìm kiếm.

Web Workers là giải pháp chính thức của nền tảng web: cho JavaScript một luồng thứ hai (và thứ ba, thứ tư…) để chạy tính toán, trong khi main thread vẫn mượt mà phục vụ người dùng.

Trong bài viết này, chúng ta sẽ đi từ việc tạo worker đầu tiên, hiểu sâu về cơ chế truyền dữ liệu (nơi 90% hiệu năng thực sự nằm), tới những mẫu thiết kế dùng trong sản phẩm thật: worker pool, OffscreenCanvas, SharedWorker, Comlink, và cách build với Vite/Astro.

Vấn đề: main thread bị chặn

Hãy bắt đầu bằng một con số cụ thể.

// Chạy trong console của trình duyệt
function countPrimes(limit) {
  let count = 0;
  for (let i = 2; i < limit; i++) {
    let isPrime = true;
    for (let j = 2; j * j <= i; j++) {
      if (i % j === 0) { isPrime = false; break; }
    }
    if (isPrime) count++;
  }
  return count;
}

const t0 = performance.now();
const result = countPrimes(5_000_000);
console.log(result, `${(performance.now() - t0).toFixed(0)}ms`);

Trên một laptop tầm trung, đoạn này mất khoảng 1.5 – 3 giây. Trong toàn bộ khoảng thời gian đó:

  • Không scroll được.
  • Không hover được.
  • Không bấm được nút nào khác.
  • Animation, transition, video đều đứng.
  • Trình duyệt có thể hiện cảnh báo “Page unresponsive”.

Điểm mấu chốt cần nhớ: không có cách “tối ưu” nào cứu được hàm trên nếu nó vẫn chạy trên main thread. Bạn có thể viết thuật toán sàng Eratosthenes nhanh gấp ba, và giao diện sẽ chỉ đơ một giây thay vì ba giây — vẫn đơ.

Cách duy nhất để giao diện không đơ là đưa công việc đó sang một luồng khác. Đó là Web Worker.

// main.js — worker nhận việc, main thread vẫn rảnh
const worker = new Worker(new URL('./prime.worker.js', import.meta.url), {
  type: 'module',
});

worker.postMessage({ command: 'countPrimes', limit: 5_000_000 });

worker.onmessage = (event) => {
  console.log('Kết quả:', event.data);
};

// Trong suốt 2 giây worker làm việc, ta vẫn có thể:
document.querySelector('#spinner').hidden = false; // DOM cập nhật tức thì

Nguyên tắc vàng: Nếu một hàm JavaScript chạy đồng bộ lâu hơn ~50ms, đó là ứng viên cho Web Worker. Nếu lâu hơn 200ms, gần như chắc chắn phải dùng worker.

Web Worker là gì?

Web Worker là một đối tượng luồng do trình duyệt tạo ra, chạy mã JavaScript song song với main thread, trong một global scope riêng biệt.

Ba điểm định nghĩa cần nắm:

1. Luồng thật, không phải “async giả”

async/await, Promise, setTimeout — tất cả đều chạy trên cùng một main thread. Chúng chỉ chia nhỏ công việc ra thành nhiều lượt và nhường luồng cho các tác vụ khác giữa các lượt. Nếu một đoạn code đồng bộ chiếm 2 giây, await trước nó không giúp gì cả.

Web Worker thì khác: nó là một luồng hệ điều hành thật, có stack và heap riêng. Đoạn code 2 giây trong worker chạy đồng thời với main thread.

2. Global scope riêng, không có DOM

Trong worker, biến toàn cục là self (thuộc WorkerGlobalScope / DedicatedWorkerGlobalScope), không phải window. Bạn không có document, không có alert, không có parent.

Cái bạn có trong worker:

CóKhông có
self, postMessage, onmessagewindow, document, DOM
fetch, WebSocket, XMLHttpRequestalert, confirm, prompt
IndexedDB, Cache APIlocalStorage, sessionStorage
crypto, SubtleCryptoparent, top, frames
setTimeout, setInterval, queueMicrotaskHistory, Location
WebAssembly, OffscreenCanvas, ImageBitmapWorker (lồng nhau, hạn chế)
importScripts (classic), import (module)—
structuredClone, TextEncoder, Intl, FileReader—

3. Cô lập nên an toàn

Vì không truy cập được DOM, code trong worker không thể vô tình phá vỡ giao diện. Đây là một lợi ích kiến trúc: bạn buộc phải tách logic tính toán thuần ra khỏi code UI — đúng theo hướng mà “layered architecture” khuyến khích. Bonus: logic trong worker rất dễ unit test vì nó là JS thuần, không cần DOM.

Ba loại worker — đừng nhầm lẫn

Đây là chỗ gây nhầm lẫn nhiều nhất với người mới. Cả ba đều tên là “worker” nhưng giải quyết ba bài toán khác nhau hoàn toàn.

Tiêu chíDedicated WorkerShared WorkerService Worker
Tạo bằngnew Worker(url)new SharedWorker(url)navigator.serviceWorker.register()
Phạm vi1 trang, tắt khi trang đóngNhiều tab cùng originToàn bộ origin, sống nền
Nhận message từTrang sở hữuNhiều trang qua portTrang qua postMessage
Mục đích chínhTính toán nặngChia sẻ state giữa các tabProxy mạng, cache, offline, push
Có DOMKhôngKhôngKhông
Can thiệp requestKhôngKhôngCó (fetch event)
Hỗ trợMọi trình duyệtTốt (Safari hỗ trợ muộn hơn)Mọi trình duyệt hiện đại

Bài viết này tập trung vào Dedicated Worker — chiếm 95% nhu cầu thực tế. Service Worker đã được nói kỹ trong hướng dẫn Progressive Web Apps, còn SharedWorker sẽ có một mục riêng ở cuối.

Worker đầu tiên: từng bước

Bước 1: Tạo file worker

// src/workers/prime.worker.js

// Trong worker, `self` là global scope
self.onmessage = (event) => {
  const { command, limit } = event.data;

  if (command === 'countPrimes') {
    const count = countPrimes(limit);
    // Gửi kết quả về main thread
    self.postMessage({ ok: true, count });
  }
};

function countPrimes(limit) {
  let count = 0;
  for (let i = 2; i < limit; i++) {
    let isPrime = true;
    for (let j = 2; j * j <= i; j++) {
      if (i % j === 0) { isPrime = false; break; }
    }
    if (isPrime) count++;
  }
  return count;
}

Bước 2: Tạo và dùng worker ở main thread

// src/main.js
const worker = new Worker(new URL('./workers/prime.worker.js', import.meta.url), {
  type: 'module',
});

worker.onmessage = (event) => {
  const { ok, count } = event.data;
  if (ok) {
    document.querySelector('#result').textContent = `${count} số nguyên tố`;
    document.querySelector('#spinner').hidden = true;
  }
};

document.querySelector('#run').addEventListener('click', () => {
  document.querySelector('#spinner').hidden = false;
  worker.postMessage({ command: 'countPrimes', limit: 5_000_000 });
});

Cú pháp new URL('./workers/prime.worker.js', import.meta.url) rất quan trọng với bundler. Biến thể dùng chuỗi new Worker('./prime.worker.js') hoạt động khi chạy code không build, nhưng Vite/Rollup sẽ không biết cần bundle file đó thành asset riêng. Dùng new URL(..., import.meta.url) là cách Vite “nhìn thấy” phụ thuộc và xử lý đúng trong cả dev và production.

Bước 3: Chạy qua HTTP, không phải file://

Đây là lỗi số một của người mới:

Uncaught DOMException: Failed to construct 'Worker':
Script at 'file:///C:/project/worker.js' cannot be accessed from origin 'null'.

Worker bắt buộc tải từ http:// hoặc https://. Hãy chạy:

npx serve .          # hoặc
python -m http.server 8000

Bước 4: Dọn dẹp khi xong

// Từ main thread: dừng ngay lập tức, không chờ tác vụ hiện tại xong
worker.terminate();
// Từ trong worker: đóng luồng này sau khi hoàn thành các microtask còn lại
self.close();

terminate() là “công tắc nguồn” — dừng luồng ngay, không có cơ hội cleanup, không có finally. Hãy đảm bảo worker không đang ghi dữ liệu quan trọng khi bạn gọi nó.

Module worker và ES Modules

Classic worker (mặc định) chạy script như một file <script> cổ điển và chỉ import được bằng importScripts():

// classic worker — cách cũ
importScripts('/lib/underscore.js', '/lib/utils.js');
const result = utils.calculateSomething(...);

Nhược điểm: importScripts chạy đồng bộ, thứ tự phụ thuộc, không tree-shake được, không có namespace.

Module worker dùng ES Modules thật:

// main.js
const worker = new Worker(new URL('./data.worker.js', import.meta.url), {
  type: 'module',
  name: 'data-worker', // xuất hiện trong DevTools, rất hữu ích khi debug
});
// data.worker.js
import { parseCSV } from '../lib/csv.js';
import { aggregate } from '../lib/stats.js';

self.onmessage = ({ data }) => {
  const rows = parseCSV(data.text);
  self.postMessage(aggregate(rows));
};

Lợi ích: import có kiểm soát, dùng chung code với main thread, bundler tree-shake được, static import được eager load trước khi worker nhận message đầu tiên.

Lưu ý với Vite: mặc định worker build ra định dạng iife. Nếu bạn dùng type: 'module' và muốn giữ nguyên, hãy thêm vào vite.config.js:

export default { worker: { format: 'es' } };

Truyền dữ liệu: nơi hiệu năng thực sự nằm

Đây là phần quan trọng nhất của bài viết. Rất nhiều người dùng worker xong nhận ra ứng dụng chậm hơn — và lý do gần như luôn là chi phí truyền dữ liệu.

Structured Clone — copy sâu mặc định

postMessage(data) không truyền tham chiếu. Nó chạy structured clone: dữ liệu được serialize ở luồng gửi, deserialize ở luồng nhận, thành một object hoàn toàn mới.

Clone được:

  • Primitives: string, number, boolean, null, undefined, BigInt
  • Object, Array, Map, Set, Date, RegExp
  • ArrayBuffer, TypedArray, DataView
  • Blob, File, FileList, ImageData, ImageBitmap
  • Error (một số loại), DOMException
  • Object lồng nhau, kể cả có chu trình (circular reference) — clone tự xử lý

Không clone được:

worker.postMessage({
  fn: () => {},        // ❌ DataCloneError: function
  el: document.body,   // ❌ DOM node
  sym: Symbol('x'),    // ❌ Symbol
  weak: new WeakMap(), // ❌ WeakMap / WeakSet
  cls: new MyClass(),  // ⚠️ Clone được nhưng MẤT PROTOTYPE
});

Điểm cuối rất hay bị bỏ sót:

class Point {
  constructor(x, y) { this.x = x; this.y = y; }
  get length() { return Math.hypot(this.x, this.y); }
}

worker.postMessage(new Point(3, 4));
// Ở phía nhận: { x: 3, y: 4 }
// p instanceof Point === false
// p.length === undefined  ← getter biến mất!

Worker nhận được dữ liệu thuần (plain object), không phải instance. Cách xử lý: hoặc dùng DTO (data transfer object) rõ ràng, hoặc “hydrate” lại ở đầu nhận, hoặc chỉ truyền primitives/arrays.

Transferable objects — truyền không copy

Với dữ liệu nhị phân lớn, bạn có thể chuyển quyền sở hữu thay vì copy:

// main.js
const buffer = new ArrayBuffer(64 * 1024 * 1024); // 64MB
const view = new Float64Array(buffer);

worker.postMessage({ type: 'process', view }, [buffer]); // ✅ tham số 2 = transfer list

// Ngay sau dòng trên:
console.log(buffer.byteLength); // 0 — buffer đã bị DETACH
console.log(view.length);       // 0

Transfer fee: gần như bằng 0, bất kể kích thước, vì chỉ có con trỏ bộ nhớ được chuyển giữa hai luồng trong cùng tiến trình.

Các kiểu transferable:

  • ArrayBuffer (và mọi TypedArray/DataView dựa trên nó — nhưng truyền ArrayBuffer, không truyền view)
  • MessagePort
  • ImageBitmap
  • OffscreenCanvas
  • ReadableStream, WritableStream, TransformStream
  • FileSystemHandle

Quy tắc: một ArrayBuffer chỉ có một chủ sở hữu tại một thời điểm. Sau khi transfer, phía gửi mất quyền truy cập. Nếu bạn cần dùng lại dữ liệu đó, hãy transfer ngược lại từ worker về main thread.

Đo lường: copy vs transfer

Đây là con số bạn nên tự đo trong dự án của mình:

// main.js
const MB = 32;
const buffer = new ArrayBuffer(MB * 1024 * 1024);
new Uint8Array(buffer).fill(42);

const worker = new Worker(new URL('./echo.worker.js', import.meta.url), { type: 'module' });

// Test 1: clone
let t0 = performance.now();
worker.postMessage({ buffer });
worker.onmessage = () => {
  console.log(`Clone: ${(performance.now() - t0).toFixed(1)}ms`);
  // Test 2: transfer
  t0 = performance.now();
  worker.postMessage({ buffer }, [buffer]);
};
// echo.worker.js
self.onmessage = ({ data }) => {
  // Nhận buffer rồi transfer ngược lại để test tiếp
  self.postMessage({ buffer: data.buffer }, [data.buffer]);
};

Kết quả thường thấy trên máy tầm trung, với buffer 32MB: clone ≈ 40–90ms, transfer ≈ 0.1–0.5ms. Với buffer 200MB, clone có thể lên tới 500ms+ — nhiều hơn cả thời gian tính toán bạn tiết kiệm được.

Bài học: nếu tác vụ nặng liên quan đến dữ liệu nhị phân lớn (ảnh, video frame, mảng số, mô hình 3D), hãy luôn dùng transferable. Nếu dữ liệu chỉ là 10KB metadata, cứ postMessage bình thường — tối ưu ở đây là “premature optimization”.

Xử lý lỗi trong worker

Worker có ba kênh lỗi khác nhau và bạn nên xử lý cả ba:

const worker = new Worker(new URL('./task.worker.js', import.meta.url), { type: 'module' });

// 1. Lỗi không bắt được trong worker (throw, ReferenceError, syntax error khi load)
worker.onerror = (event) => {
  console.error('Worker error:', event.message);
  console.error('File:', event.filename, 'Line:', event.lineno);
  event.preventDefault(); // ngăn lỗi lan ra console global nếu muốn
};

// 2. Lỗi giải mã message (structured clone thất bại hoặc dữ liệu không hợp lệ)
worker.onmessageerror = (event) => {
  console.error('Không deserialize được message từ worker', event);
};

// 3. Lỗi nghiệp vụ → tự định nghĩa protocol trong message
worker.onmessage = ({ data }) => {
  if (!data.ok) {
    console.error('Task thất bại:', data.error, data.stack);
    return;
  }
  handleResult(data.result);
};

Và ở phía worker, hãy bọc mọi thứ:

// task.worker.js
self.onmessage = async ({ data }) => {
  try {
    const result = await runTask(data);
    self.postMessage({ ok: true, id: data.id, result });
  } catch (error) {
    self.postMessage({
      ok: false,
      id: data.id,
      error: error.message,
      stack: error.stack,
    });
  }
};

Vì worker được tạo bởi new Worker(...) — một constructor — bạn cũng cần xử lý trường hợp không tạo được worker (URL sai, bị CSP chặn):

let worker;
try {
  worker = new Worker(new URL('./task.worker.js', import.meta.url), { type: 'module' });
} catch (error) {
  // Fallback: chạy đồng bộ trên main thread (chậm nhưng vẫn hoạt động)
  console.warn('Worker không khả dụng, fallback main thread', error);
  worker = createMainThreadFallback();
}

Fallback này quan trọng nếu bạn hỗ trợ môi trường hạn chế (iframe sandbox, một số webview cũ) — nguyên tắc progressive enhancement.

Ví dụ thực tế #1: Lọc 50.000 dòng dữ liệu

Bài toán: bảng dữ liệu 50k dòng, người dùng gõ vào ô tìm kiếm để lọc real-time. Nếu lọc trên main thread, mỗi keystroke sẽ block 80–200ms → gõ bị giật.

// search.worker.js
let dataset = [];

self.onmessage = ({ data }) => {
  switch (data.type) {
    case 'init':
      dataset = data.rows;
      self.postMessage({ type: 'ready', count: dataset.length });
      break;

    case 'search': {
      const q = data.query.toLowerCase().trim();
      const tokens = q ? q.split(/\s+/) : [];
      const limit = data.limit ?? 100;

      const results = [];
      for (let i = 0; i < dataset.length && results.length < limit; i++) {
        const row = dataset[i];
        // Tất cả token phải xuất hiện (AND search)
        let match = true;
        for (const token of tokens) {
          if (!row._search.includes(token)) { match = false; break; }
        }
        if (match) results.push(row);
      }

      self.postMessage({ type: 'results', query: q, results, total: results.length });
      break;
    }
  }
};

Điểm đáng chú ý: ta tiền xử lý dữ liệu một lần khi init — tạo trường _search viết thường sẵn, để mỗi lần search không phải gọi toLowerCase() trên 50.000 dòng.

// main.js
const worker = new Worker(new URL('./search.worker.js', import.meta.url), { type: 'module' });

worker.postMessage({
  type: 'init',
  rows: rawRows.map((row) => ({
    ...row,
    _search: `${row.name} ${row.email} ${row.company}`.toLowerCase(),
  })),
});

let seq = 0;
const inflight = new Map();

worker.onmessage = ({ data }) => {
  if (data.type === 'ready') {
    console.log(`Đã nạp ${data.count} dòng`);
    return;
  }
  if (data.type === 'results') {
    // Bỏ qua kết quả cũ (đã có query mới hơn)
    if (data.seq < lastSeq) return;
    renderRows(data.results);
  }
};

document.querySelector('#search').addEventListener('input', (event) => {
  lastSeq = ++seq;
  worker.postMessage({ type: 'search', query: event.target.value, limit: 100, seq });
});

Vì worker xử lý message theo thứ tự FIFO, kết quả vẫn về đúng thứ tự — kỹ thuật “sequence number” ở trên chỉ để an toàn nếu bạn thêm async trong worker.

Ví dụ thực tế #2: Resize ảnh với OffscreenCanvas

Đây là use case “killer” của Web Worker: nén/resize ảnh client-side trước khi upload.

// resize.worker.js
self.onmessage = async ({ data }) => {
  const { bitmap, maxWidth, quality = 0.82 } = data;

  const scale = Math.min(1, maxWidth / bitmap.width);
  const width = Math.round(bitmap.width * scale);
  const height = Math.round(bitmap.height * scale);

  const canvas = new OffscreenCanvas(width, height);
  const ctx = canvas.getContext('2d');
  ctx.drawImage(bitmap, 0, 0, width, height);

  const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality });
  self.postMessage({ blob }, [blob]); // transferable — không copy
};
// main.js
const worker = new Worker(new URL('./resize.worker.js', import.meta.url), { type: 'module' });

worker.onmessage = async ({ data }) => {
  const form = new FormData();
  form.append('file', data.blob, 'photo.jpg');
  await fetch('/api/upload', { method: 'POST', body: form });
};

document.querySelector('#file').addEventListener('change', async (event) => {
  const file = event.target.files[0];
  // decode ảnh trên main thread (nhanh) rồi transfer ImageBitmap sang worker
  const bitmap = await createImageBitmap(file);
  worker.postMessage({ bitmap, maxWidth: 1600 }, [bitmap]);
});

ImageBitmap là transferable — nghĩa là ảnh gốc 12MP được chuyển sang worker, không copy. Và OffscreenCanvas cho phép worker vẽ, mã hoá JPEG hoàn toàn ngoài main thread. Với ảnh 12MP, cách này tiết kiệm 300–800ms block trên main thread so với canvas.toBlob() truyền thống.

Kiểm tra hỗ trợ OffscreenCanvas trên caniuse trước khi dùng cho production — Chrome/Edge từ 69, Firefox từ 105, Safari từ 16.4 cho 2D context.

Ví dụ thực tế #3: Phân tích file lớn (CSV / JSON)

Người dùng kéo một file CSV 40MB vào trang. File.text() + JSON.parse/parsing trên main thread = treo trang vài giây.

// csv.worker.js
self.onmessage = async ({ data }) => {
  const { file } = data;

  // File là cloneable — đọc trong worker, main thread không bị ảnh hưởng
  const text = await file.text();

  const rows = [];
  let start = 0;
  let header = null;
  const len = text.length;

  for (let i = 0; i <= len; i++) {
    if (i === len || text.charCodeAt(i) === 10) {
      const line = text.slice(start, i);
      start = i + 1;
      if (!line) continue;
      const cells = splitCSVLine(line);
      if (!header) { header = cells; continue; }
      const row = {};
      for (let c = 0; c < header.length; c++) row[header[c]] = cells[c];
      rows.push(row);
    }

    // Báo tiến độ mỗi 10.000 dòng, không làm ngập message queue
    if (rows.length && rows.length % 10_000 === 0) {
      self.postMessage({ type: 'progress', loaded: rows.length });
    }
  }

  self.postMessage({ type: 'done', rows });
};

Với dữ liệu lớn, hãy gửi kết quả thành từng batch thay vì một message khổng lồ:

// Gửi từng batch 5.000 dòng để tránh clone 40MB trong một lần
for (let i = 0; i < rows.length; i += 5000) {
  self.postMessage({ type: 'chunk', rows: rows.slice(i, i + 5000) });
}
self.postMessage({ type: 'done', total: rows.length });

Chia batch giúp main thread cập nhật DOM dần dần, giữ cảm giác phản hồi.

Worker pool: đừng tạo worker cho mỗi tác vụ

Cách dùng sai phổ biến:

// ❌ Tạo/huỷ worker cho mọi request
async function resizeImage(bitmap) {
  const worker = new Worker(new URL('./resize.worker.js', import.meta.url), { type: 'module' });
  const result = await new Promise((resolve) => {
    worker.onmessage = ({ data }) => resolve(data);
    worker.postMessage({ bitmap }, [bitmap]);
  });
  worker.terminate();
  return result;
}

Mỗi lần gọi tốn 5–30ms chỉ để khởi tạo worker (fetch script, parse, tạo global scope, khởi động V8 isolate). Nếu bạn resize 50 ảnh, đó là hơn một giây lãng phí thuần túy.

Giải pháp là worker pool — một nhóm worker tạo sẵn, nhận việc luân phiên:

// worker-pool.js
export class WorkerPool {
  #workers = [];
  #queue = [];
  #idle = [];

  constructor(url, size = Math.max(2, Math.min(8, (navigator.hardwareConcurrency || 4) - 1))) {
    this.size = size;
    for (let i = 0; i < size; i++) {
      const worker = new Worker(url, { type: 'module', name: `pool-${i}` });
      worker.onmessage = (event) => this.#onMessage(worker, event.data);
      worker.onerror = (event) => this.#onError(worker, event);
      this.#workers.push(worker);
      this.#idle.push(worker);
    }
  }

  #onMessage(worker, data) {
    const task = worker.__currentTask;
    worker.__currentTask = null;
    this.#idle.push(worker);

    if (data.ok) task.resolve(data.result);
    else task.reject(new Error(data.error));

    this.#drain();
  }

  #onError(worker, event) {
    const task = worker.__currentTask;
    worker.__currentTask = null;
    this.#idle.push(worker);
    task?.reject(new Error(event.message));
    this.#drain();
  }

  #drain() {
    while (this.#queue.length && this.#idle.length) {
      const task = this.#queue.shift();
      const worker = this.#idle.pop();
      worker.__currentTask = task;
      worker.postMessage(task.payload, task.transfer);
    }
  }

  run(payload, transfer = []) {
    return new Promise((resolve, reject) => {
      this.#queue.push({ payload, transfer, resolve, reject });
      this.#drain();
    });
  }

  destroy() {
    this.#workers.forEach((w) => w.terminate());
    this.#workers = [];
    this.#idle = [];
    this.#queue = [];
  }
}
// Dùng
const pool = new WorkerPool(new URL('./resize.worker.js', import.meta.url));

const blobs = await Promise.all(
  bitmaps.map((bitmap) => pool.run({ bitmap, maxWidth: 1600 }, [bitmap])),
);

Bao nhiêu worker là hợp lý? Khởi điểm tốt:

const size = Math.max(2, Math.min(8, (navigator.hardwareConcurrency || 4) - 1));

Lý do trừ 1: chừa một lõi cho main thread (render, event handling) và cho hệ điều hành. Trên máy 8 lõi → 7 worker, nhưng thực tế 4–6 là điểm ngọt cho hầu hết tác vụ vì còn tranh chấp băng thông bộ nhớ. Hãy đo trên thiết bị mục tiêu thay vì tin vào công thức.

SharedWorker và giao tiếp giữa các tab

SharedWorker là worker được chia sẻ giữa nhiều tab cùng origin — hữu ích khi bạn muốn một nguồn dữ liệu duy nhất (WebSocket connection, cache tính toán) thay vì mỗi tab giữ một bản.

// shared.worker.js
const connections = new Set();

self.onconnect = (event) => {
  const port = event.ports[0];
  connections.add(port);

  port.onmessage = ({ data }) => {
    if (data.type === 'broadcast') {
      // Chuyển tiếp tới TẤT CẢ tab đang mở
      for (const p of connections) p.postMessage(data.payload);
    }
  };

  port.start();
  port.postMessage({ type: 'welcome', peers: connections.size });
};
// main.js (mỗi tab)
const shared = new SharedWorker(new URL('./shared.worker.js', import.meta.url), {
  type: 'module',
});
shared.port.start();
shared.port.postMessage({ type: 'broadcast', payload: { theme: 'dark' } });

Điểm khác biệt cú pháp quan trọng: SharedWorker giao tiếp qua port, không qua worker.postMessage trực tiếp. Bạn phải gọi port.start() (trừ khi dùng port.onmessage = ..., tự động start).

Thực tế: Với hầu hết nhu cầu “đồng bộ state giữa các tab”, BroadcastChannel đơn giản hơn nhiều:

const channel = new BroadcastChannel('app-sync');
channel.postMessage({ theme: 'dark' });
channel.onmessage = ({ data }) => applyTheme(data.theme);

Chỉ dùng SharedWorker khi bạn cần tính toán chạy nền chung hoặc giữ một connection dài hạn duy nhất.

Code worker thuần khá verbose: postMessage, onmessage, { type: ... }, id tương quan. Comlink (thư viện ~3KB của Google) biến worker thành một object proxy để bạn gọi hàm gần như bình thường.

npm install comlink
// math.worker.js
import * as Comlink from 'comlink';

const api = {
  countPrimes(limit) {
    let count = 0;
    for (let i = 2; i < limit; i++) {
      let isPrime = true;
      for (let j = 2; j * j <= i; j++) if (i % j === 0) { isPrime = false; break; }
      if (isPrime) count++;
    }
    return count;
  },
  async processImage(bitmap, maxWidth) {
    // ...
    return blob;
  },
};

Comlink.expose(api);
// main.js
import * as Comlink from 'comlink';

const worker = new Worker(new URL('./math.worker.js', import.meta.url), { type: 'module' });
const api = Comlink.wrap(worker);

const count = await api.countPrimes(5_000_000);      // trả về Promise
const blob = await api.processImage(bitmap, 1600);   // hỗ trợ async + transferable
worker.terminate();

Điều đáng giá nhất: truyền callback và object qua lại đều được — Comlink tự tạo proxy hai chiều.

// Ở worker: gọi callback truyền từ main thread
Comlink.expose({
  async processAll(items, onProgress) {
    for (let i = 0; i < items.length; i++) {
      await onProgress(i / items.length); // gọi ngược về main thread
    }
  },
});

// Ở main thread
await api.processAll(items, Comlink.proxy((ratio) => updateProgressBar(ratio)));

Đánh đổi: Comlink thêm một lớp proxy (overhead nhỏ, thường không đáng kể), và làm mờ đi bản chất “message-based” của worker. Với dự án nhỏ hoặc ít loại message, viết tay rõ ràng hơn; với API worker lớn, Comlink tiết kiệm rất nhiều code và bug.

Build với Vite và Astro

Vite

// workers/data.worker.js
import { heavyCompute } from '../lib/heavy.js';
self.onmessage = ({ data }) => self.postMessage(heavyCompute(data));

// main.js
const worker = new Worker(new URL('../workers/data.worker.js', import.meta.url), {
  type: 'module',
});

Vite nhận diện pattern new Worker(new URL(..., import.meta.url)) và tự động:

  • Bundle worker thành file riêng (assets/data.worker-abc123.js)
  • Rewrite URL đúng trong production
  • Áp dụng minify và target giống main bundle

Cấu hình liên quan:

// vite.config.js
export default {
  worker: {
    format: 'es', // build worker ra ES module (mặc định là 'iife')
    plugins: () => [ /* plugin dùng chung, ví dụ react() */ ],
  },
};

Ngoài ra Vite hỗ trợ suffix import:

import MyWorker from './worker.js?worker';        // trả về Worker constructor
import InlineWorker from './worker.js?worker&inline'; // nhúng base64 (tránh request riêng)

const worker = new MyWorker();

Astro

Trong component Astro, worker phải chạy client-side, nên hãy đặt trong <script>:

---
// PrimeCounter.astro
---
<div id="prime-ui">
  <button id="run">Đếm số nguyên tố</button>
  <span id="out"></span>
</div>

<script>
  const worker = new Worker(new URL('../workers/prime.worker.js', import.meta.url), {
    type: 'module',
  });

  const out = document.getElementById('out')!;
  worker.onmessage = ({ data }) => {
    out.textContent = `${data.count} số nguyên tố`;
  };

  document.getElementById('run')!.addEventListener('click', () => {
    out.textContent = 'Đang tính...';
    worker.postMessage({ command: 'countPrimes', limit: 5_000_000 });
  });
</script>

Astro (dùng Vite bên dưới) sẽ bundle worker thành asset riêng, và toàn bộ code vẫn không chạy trên server — đúng tinh thần zero-JS-by-default: chỉ component cần worker mới tải worker.

Lưu ý với Astro + Cloudflare: worker là file tĩnh trong dist/_astro/, được CDN phục vụ như mọi asset khác. Không cần cấu hình adapter.

Debug worker trong DevTools

Worker chạy ở luồng riêng, nhưng DevTools debug được đầy đủ:

  1. Chrome DevTools → Sources → panel bên trái, phần “Threads” (trước đây là mục “Workers”): mỗi worker là một mục riêng. Comment trong ngoặc là name bạn truyền khi tạo worker — lý do nên luôn đặt tên.

  2. Breakpoints hoạt động bình thường trong file worker. debugger; cũng dừng đúng.

  3. Console context selector: ở Console có dropdown chọn context — chuyển sang worker để chạy lệnh trực tiếp trong scope của nó.

  4. Performance panel: có thể thấy các luồng song song trong flame chart. Đây là cách tốt nhất để chứng minh worker thực sự giúp ích.

  5. Network panel: worker script là một request riêng — hãy kiểm tra nó không bị cache sai (Cache-Control) hoặc bị tải lại mỗi lần dùng pool.

  6. Application → Service Workers: chỉ dành cho Service Worker, đừng tìm dedicated worker ở đây.

Mẹo: để thấy rõ lợi ích, hãy mở Performance panel, record lúc chạy tác vụ nặng không có worker (một khối dài trên main thread), rồi có worker (main thread rảnh, worker thread bận). Trực quan này thuyết phục hơn mọi con số.

Những hạn chế cần biết trước khi dùng

Hạn chếHệ quả thực tếCách xử lý
Không có DOMKhông render, không đọc input trong workerTrả dữ liệu về main thread để render
Không có localStorageKhông lưu state trực tiếpDùng IndexedDB hoặc gửi về main thread
Chi phí truyền dữ liệuObject lớn → chậm hơn cả tính toánTransferable, batch, cấu trúc dữ liệu phẳng
Chi phí khởi tạo worker5–30ms mỗi lần tạoWorker pool
Mất prototype khi cloneinstanceof sai, method mấtDTO / hydrate lại
Debug phức tạp hơnStack trace xuyên luồng khó đọcĐặt name, log có id tương quan
Bundle size tăngWorker là file JS riêngLazy load worker khi cần
Không share memory mặc địnhKhông chia sẻ biến giữa hai luồngpostMessage, hoặc SharedArrayBuffer (cần COOP/COEP)
Nested worker hạn chếKhông phải nơi nào cũng tạo được worker conTránh nested; dùng pool phẳng

Về SharedArrayBuffer

Nếu bạn thực sự cần chia sẻ bộ nhớ giữa main thread và worker (ví dụ vòng lặp audio, mô phỏng vật lý), SharedArrayBuffer cho phép điều đó — nhưng yêu cầu trang phải ở trạng thái cross-origin isolated, tức server phải gửi:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

Đây là rào cản lớn vì nó chặn hầu hết tài nguyên cross-origin (analytics, font, iframe bên thứ ba) trừ khi chúng có CORP/crossorigin. Chỉ nên cân nhắc khi bạn thực sự cần nó; 99% trường hợp postMessage + transferable là đủ.

Checklist trước khi triển khai

  • Đã đo: tác vụ thực sự block main thread hơn 50ms
  • Worker tạo bằng new URL(..., import.meta.url) để bundler xử lý
  • Worker đặt name để dễ debug trong DevTools
  • Dữ liệu lớn truyền bằng transfer list, không clone
  • Không truyền class instance qua message (hoặc có hydrate lại)
  • Có try/catch trong worker và trả về { ok, error } thay vì throw
  • Có xử lý worker.onerror và worker.onmessageerror
  • Có fallback chạy đồng bộ nếu không tạo được worker
  • Dùng worker pool, không tạo worker theo từng request
  • Pool size dựa trên navigator.hardwareConcurrency (clamp 2–8)
  • terminate() khi component unmount / trang rời đi
  • Message protocol có id tương quan nếu gửi nhiều request song song
  • Kết quả lớn gửi theo batch để main thread render dần
  • Worker chỉ chứa logic thuần — có unit test chạy bằng Node, không cần DOM
  • Đã kiểm tra Performance panel: main thread thực sự rảnh

Kết luận

Web Worker không phải là một tính năng “nâng cao” cần chuyên gia. Nó là một giải pháp cơ bản cho một giới hạn cơ bản của nền tảng web: một luồng thì không thể vừa tính toán vừa phục vụ người dùng.

Ba điều đáng nhớ nhất:

  • Vấn đề thật không phải “đưa code sang worker”, mà là “đưa dữ liệu qua lại”. Rất nhiều người dùng worker xong thấy app chậm hơn vì clone object khổng lồ. Hãy dùng transferable cho nhị phân, batch cho kết quả lớn, và cấu trúc dữ liệu phẳng.
  • Chi phí khởi tạo worker là có thật. Tạo worker theo từng request là phản mẫu (anti-pattern) số một. Worker pool với hardwareConcurrency là mặc định hợp lý cho hầu hết ứng dụng.
  • Worker là công cụ kiến trúc, không chỉ là mẹo tối ưu. Việc buộc phải tách logic thuần khỏi code DOM khiến codebase của bạn dễ test và dễ bảo trì hơn — lợi ích này đôi khi còn lớn hơn cả hiệu năng.

Nếu bạn muốn bắt đầu ngay hôm nay, đây là lộ trình 30 phút:

  1. Mở Performance panel, tìm tác vụ đồng bộ dài nhất trong app của bạn.
  2. Viết worker đầu tiên cho tác vụ đó với module worker + postMessage.
  3. Đo lại bằng Performance panel xem main thread có rảnh thật không.
  4. Nếu tác vụ chạy thường xuyên, nâng cấp lên worker pool.
  5. Nếu dữ liệu là nhị phân, chuyển sang transferable và đo lại lần nữa.

Gợi ý thư viện nên xem: Comlink (proxy hoá worker, bỏ boilerplate), workerpool (pool có sẵn, hỗ trợ cả Node.js), và Partytown (chuyển script bên thứ ba như analytics sang worker — giải pháp trực tiếp cho vấn đề INP do third-party script).


Bài viết trước: Web Components — xây component bằng chuẩn trình duyệt, không phụ thuộc framework.

Recently Used Tools