Hướng dẫn lập trình

JavaScript Event Loop chuyên sâu: Microtask, Macrotask và async/await

Tìm hiểu cách JavaScript Event Loop điều phối code đồng bộ, Promise, queueMicrotask(), setTimeout(), async/await, process.nextTick() và setImmediate() trong trình duyệt và Node.js.

Xuất bản: 5 thg 6, 2026Cập nhật: 5 thg 6, 2026Thời gian đọc: 8 minLượt xem: 2
JavaScriptEvent LoopAsync AwaitPromiseNode.js

💡Điểm chính của bài viết

  • Tìm hiểu cách JavaScript Event Loop điều phối code đồng bộ, Promise, queueMicrotask(), setTimeout(), async/await, process.nextTick() và setImmediate() trong trình duyệt và Node.js.

Ngày cập nhật: 05/06/2026
Chủ đề: JavaScript Event Loop trong trình duyệt và Node.js
Mục tiêu: Giúp lập trình viên hiểu chính xác thứ tự chạy của code đồng bộ, Promise, queueMicrotask(), setTimeout(), async/await, process.nextTick()setImmediate().

Tóm tắt nhanh

JavaScript thường được mô tả là chạy trên một luồng chính, nhưng điều đó không có nghĩa là mọi việc đều chạy ngay lập tức theo thứ tự nhìn thấy trong file. Event loop là cơ chế điều phối: chạy code đồng bộ trước, sau đó xử lý các hàng đợi bất đồng bộ theo quy tắc cụ thể.

Nếu chỉ nhớ một câu, hãy nhớ câu này: một task chạy xong thì JavaScript sẽ xả toàn bộ microtask trước khi chuyển sang task tiếp theo. Đây là lý do Promise.then() và phần code sau await thường chạy trước setTimeout(..., 0).

Sơ đồ JavaScript Event Loop
Sơ đồ JavaScript Event Loop

Nguồn hình minh họa: sơ đồ PNG tự tạo cho bài viết này, dựa trên tài liệu MDN Web Docs và Node.js. Không sử dụng SVG.

1. Event loop giải quyết vấn đề gì?

JavaScript trong trình duyệt phải xử lý rất nhiều việc cùng lúc: chạy script, phản hồi click, xử lý timer, cập nhật giao diện, nhận dữ liệu mạng và render lại màn hình. Nếu tất cả đều chạy chồng lên nhau, ứng dụng sẽ khó dự đoán. Nếu chỉ chạy tuần tự cứng nhắc, giao diện sẽ bị đơ.

Event loop là vòng lặp điều phối các công việc đó. Mỗi vòng lặp thường lấy một task để chạy, sau đó xử lý microtask, rồi trình duyệt có thể render giao diện trước khi chuyển sang vòng tiếp theo. MDN mô tả mỗi “agent” JavaScript có execution context stack, task queue và microtask queue; event loop điều phối việc chạy task, microtask và rendering trong môi trường trình duyệt. [1][2]

Nói đơn giản hơn: event loop là “bộ điều phối lịch chạy” của JavaScript.

2. Ba khái niệm bắt buộc phải phân biệt

Call stack

Call stack là nơi JavaScript chạy code hiện tại. Code đồng bộ luôn vào call stack và chạy trước.

Ví dụ:

JS
console.log("A");
console.log("B");
console.log("C");

Kết quả chắc chắn là:

Ví dụ

A B C

Không có hàng đợi nào chen vào giữa ba dòng này, vì call stack chưa rỗng.

Task, thường gọi là macrotask

Task là những việc được đưa vào hàng đợi task. Trong trình duyệt, ví dụ phổ biến gồm: chạy script ban đầu, callback của setTimeout(), setInterval(), callback sự kiện click, callback từ một số API bất đồng bộ. MDN mô tả task là những công việc được lên lịch bởi các cơ chế chuẩn như chạy chương trình, dispatch event, timeout hoặc interval. [1]

Ví dụ:

JS
setTimeout(() => {
  console.log("timer");
}, 0);

Dù delay là 0, callback không chạy ngay. Nó được đưa vào hàng đợi task và phải chờ code hiện tại kết thúc, microtask được xả xong, rồi mới đến lượt task tiếp theo.

Microtask

Microtask là hàng đợi ưu tiên cao hơn task. Ví dụ phổ biến gồm callback của Promise, queueMicrotask() và phần tiếp tục sau await khi Promise đã settled. MDN nhấn mạnh rằng sau khi một task kết thúc, event loop sẽ chạy microtask cho đến khi microtask queue rỗng; nếu một microtask tạo thêm microtask mới, microtask mới cũng được chạy trước task tiếp theo. [1][2]

Ví dụ:

JS
Promise.resolve().then(() => {
  console.log("promise");
});

queueMicrotask(() => {
  console.log("microtask");
});

Hai callback này không chạy ngay trong call stack hiện tại. Chúng được đưa vào microtask queue và chạy sau khi code đồng bộ hiện tại kết thúc.

3. Quy tắc chạy quan trọng nhất

Trong trình duyệt, mô hình đơn giản có thể hiểu như sau:

Ví dụ

1. Chạy code đồng bộ trong task hiện tại. 2. Khi call stack rỗng, xả toàn bộ microtask queue. 3. Trình duyệt có thể render lại giao diện. 4. Lấy task tiếp theo. 5. Lặp lại.

Ví dụ:

JS
console.log("A");

setTimeout(() => {
  console.log("B - timeout");
}, 0);

Promise.resolve().then(() => {
  console.log("C - promise");
});

queueMicrotask(() => {
  console.log("D - queueMicrotask");
});

console.log("E");

Kết quả:

Ví dụ

A E C - promise D - queueMicrotask B - timeout

Giải thích:

Dòng AE là code đồng bộ nên chạy trước. setTimeout() đưa callback vào task queue. Promise.then()queueMicrotask() đưa callback vào microtask queue. Khi code đồng bộ kết thúc, microtask queue được xả trước, nên CD chạy trước B.

4. async/await thực ra liên quan đến Promise

async function luôn trả về một Promise. await làm cho code trông giống đồng bộ, nhưng bản chất vẫn là tạm dừng phần còn lại của async function và tiếp tục khi Promise được xử lý. MDN mô tả async function là hàm trả về Promise, còn await dùng để chờ Promise và lấy fulfillment value. [4][5]

Ví dụ:

JS
async function run() {
  console.log("1");
  await Promise.resolve();
  console.log("2");
}

console.log("0");
run();
console.log("3");

Kết quả:

Ví dụ

0 1 3 2

Giải thích:

console.log("0") chạy trước. Khi gọi run(), dòng 1 vẫn chạy ngay vì nó nằm trước await. Đến await Promise.resolve(), phần còn lại của hàm được tách ra để chạy sau trong microtask. Vì vậy console.log("3") chạy trước, rồi microtask mới tiếp tục in 2.

Điểm dễ nhầm: await không biến JavaScript thành đa luồng. Nó chỉ giúp viết Promise theo cú pháp dễ đọc hơn.

5. Vì sao setTimeout(..., 0) không chạy ngay?

setTimeout(..., 0) nghĩa là “đưa callback vào hàng đợi timer càng sớm càng tốt sau ngưỡng thời gian tối thiểu”, không có nghĩa là “chạy ngay lập tức”.

Ví dụ:

JS
setTimeout(() => console.log("timeout"), 0);

for (let i = 0; i < 3_000_000_000; i++) {
  // giả lập tác vụ đồng bộ rất nặng
}

console.log("done");

timeout không thể chạy trong lúc vòng lặp đang chiếm call stack. JavaScript phải đợi call stack rỗng. Nếu code đồng bộ quá nặng, UI có thể bị đơ, timer bị trễ và tương tác người dùng bị chậm.

6. Microtask có thể gây “đói” task

Vì event loop sẽ xả microtask cho đến khi queue rỗng, bạn có thể vô tình chặn task tiếp theo nếu liên tục tạo microtask mới. MDN cảnh báo rằng microtask có thể tự enqueue thêm microtask, dẫn đến nguy cơ event loop xử lý microtask mãi và gây vấn đề hiệu năng. [1]

Ví dụ nguy hiểm:

JS
function loop() {
  queueMicrotask(loop);
}

loop();

setTimeout(() => {
  console.log("Dòng này có thể không bao giờ chạy");
}, 0);

Đoạn code này liên tục thêm microtask mới trước khi event loop có cơ hội chuyển sang task timer. Trong thực tế, không nên dùng microtask cho vòng lặp dài hoặc xử lý nặng.

7. Khi nào nên dùng microtask?

Không phải lúc nào cũng nên dùng queueMicrotask(). MDN cũng lưu ý rằng phần lớn developer không cần dùng microtask thường xuyên; nó phù hợp hơn khi xây thư viện/framework hoặc khi cần đảm bảo thứ tự chạy rất cụ thể. [1]

Nên cân nhắc microtask khi:

  • Bạn cần chạy một đoạn cleanup ngay sau code hiện tại nhưng trước timer/event tiếp theo.
  • Bạn cần đồng bộ thứ tự callback giữa nhánh có Promise và nhánh không có Promise.
  • Bạn đang viết thư viện cần phát event sau khi trạng thái nội bộ đã ổn định.

Không nên dùng microtask khi:

  • Công việc nặng, tốn CPU.
  • Cần nhường thời gian cho UI render.
  • Chỉ muốn delay đơn giản; khi đó setTimeout() hoặc API scheduling khác dễ hiểu hơn.
  • Bạn đang tạo microtask lặp vô hạn hoặc lặp quá nhiều.

8. Khác biệt quan trọng trong Node.js

Trong Node.js, event loop có nhiều phase hơn trình duyệt. Tài liệu Node.js mô tả các phase như timers, pending callbacks, poll, check và close callbacks. Node.js dùng event loop để hỗ trợ non-blocking I/O, dù JavaScript mặc định vẫn chạy trên một thread chính. [3]

Các điểm cần nhớ trong Node.js:

  • setTimeout()setInterval() thuộc timers phase.
  • I/O callback thường được xử lý trong poll phase.
  • setImmediate() chạy trong check phase.
  • process.nextTick() không giống task hay microtask trình duyệt; nó có độ ưu tiên rất cao trong Node.js và có thể làm chậm các callback khác nếu bị lạm dụng.
  • Từ libuv 1.45.0, tương ứng Node.js 20, hành vi timer thay đổi: timer chỉ chạy sau poll phase thay vì cả trước và sau poll phase như trước, nên một số tương tác giữa setImmediate() và timer có thể khác so với phiên bản cũ. [3]

Ví dụ Node.js:

JS
console.log("start");

setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));

Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick"));

console.log("end");

Kết quả thường thấy:

Ví dụ

start end nextTick promise timeout / immediate

Thứ tự giữa timeoutimmediate có thể phụ thuộc ngữ cảnh chạy và phiên bản Node.js, nhất là khi đặt trong hoặc ngoài I/O callback. Vì vậy, không nên viết logic phụ thuộc cứng vào việc setTimeout(..., 0) luôn chạy trước setImmediate().

9. Các lỗi thực tế thường gặp

Lỗi 1: Nghĩ rằng Promise chạy ngay

Sai:

JS
let value = 0;

Promise.resolve().then(() => {
  value = 1;
});

console.log(value); // 0, không phải 1

Đúng hơn:

JS
let value = 0;

await Promise.resolve().then(() => {
  value = 1;
});

console.log(value); // 1

Trong môi trường không hỗ trợ top-level await, hãy đặt code trong async function.

Lỗi 2: Nghĩ setTimeout(..., 0) nhanh hơn Promise

Sai về mặt thứ tự:

JS
setTimeout(() => console.log("timer"), 0);
Promise.resolve().then(() => console.log("promise"));

Kết quả thường là:

Ví dụ

promise timer

Promise callback thuộc microtask, còn timer callback thuộc task.

Lỗi 3: Đưa xử lý nặng vào microtask

Sai:

JS
queueMicrotask(() => {
  heavyCalculation();
});

Nếu heavyCalculation() mất nhiều thời gian, UI vẫn bị chặn. Microtask không tự động chuyển công việc sang thread khác. Với tác vụ nặng trên trình duyệt, hãy cân nhắc Web Worker. Với Node.js, cân nhắc worker threads, job queue hoặc tách tác vụ sang service khác.

Lỗi 4: Dùng async/await nhưng không xử lý lỗi

Sai:

JS
async function loadUser() {
  const res = await fetch("/api/user");
  return res.json();
}

loadUser(); // lỗi có thể trở thành unhandled rejection

Đúng hơn:

JS
async function main() {
  try {
    const user = await loadUser();
    console.log(user);
  } catch (error) {
    console.error("Load user failed:", error);
  }
}

main();

10. Checklist khi debug thứ tự chạy JavaScript

Khi nhìn một đoạn code bất đồng bộ, hãy tự hỏi theo thứ tự này:

  1. Dòng nào là code đồng bộ? Chúng chạy trước.
  2. Dòng nào tạo microtask? Ví dụ: Promise.then(), queueMicrotask(), phần sau await.
  3. Dòng nào tạo task? Ví dụ: setTimeout(), event callback, timer.
  4. Có microtask nào tạo thêm microtask không?
  5. Có code đồng bộ nào quá nặng làm chặn event loop không?
  6. Nếu chạy trong Node.js, có process.nextTick() hoặc setImmediate() không?
  7. Logic có đang phụ thuộc vào thứ tự không được đảm bảo giữa timer và I/O không?

11. Kết luận

Event loop không phải kiến thức lý thuyết xa rời thực tế. Nó quyết định vì sao UI bị đơ, vì sao Promise chạy trước timer, vì sao await không làm code chạy song song, và vì sao một đoạn code nhỏ có thể làm callback khác không bao giờ được thực thi.

Khi viết JavaScript hiện đại, đặc biệt với React, Vue, Node.js, API client, queue worker hoặc realtime app, hiểu event loop giúp bạn debug chính xác hơn và tránh các lỗi bất đồng bộ khó phát hiện.

Nguồn tham khảo

[1] MDN Web Docs — Using microtasks in JavaScript with queueMicrotask()
https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API/Microtask_guide

[2] MDN Web Docs — In depth: Microtasks and the JavaScript runtime environment
https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API/Microtask_guide/In_depth

[3] Node.js Docs — The Node.js Event Loop
https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick

[4] MDN Web Docs — async function
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function

[5] MDN Web Docs — await
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await

[6] WHATWG HTML Standard — Event loop processing model
https://html.spec.whatwg.org/multipage/webappapis.html

PR

Được biên soạn bởi PixelRouter Editorial Team

Chúng tôi cung cấp các bài viết chuyên sâu và chính xác về hạ tầng AI, bảo mật API, quản lý tài chính đám mây và tối ưu hóa hệ thống cho nhà phát triển.

Câu hỏi thường gặp

Event loop trong JavaScript dùng để làm gì?

Event loop là cơ chế điều phối thứ tự chạy của JavaScript: chạy code đồng bộ trong task hiện tại, xả microtask khi call stack rỗng, sau đó mới chuyển sang task tiếp theo và trình duyệt có thể render giao diện.

Microtask khác macrotask như thế nào?

Microtask có độ ưu tiên cao hơn task/macrotask. Callback của Promise, queueMicrotask() và phần tiếp tục sau await thường nằm trong microtask queue, còn setTimeout(), setInterval() và callback sự kiện thường nằm trong task queue.

Vì sao Promise.then() thường chạy trước setTimeout(..., 0)?

Vì sau khi một task kết thúc, JavaScript sẽ xả toàn bộ microtask queue trước khi lấy task tiếp theo. Promise.then() là microtask, còn setTimeout(..., 0) là task, nên Promise thường chạy trước timer.

async/await có làm JavaScript chạy đa luồng không?

Không. async/await chỉ là cú pháp giúp viết Promise dễ đọc hơn. Phần sau await được tiếp tục sau khi Promise được xử lý, thường thông qua microtask, chứ không biến JavaScript thành đa luồng.

Trong Node.js, setTimeout(..., 0) và setImmediate() có luôn chạy theo một thứ tự cố định không?

Không nên phụ thuộc cứng vào thứ tự này. Bài viết nêu rằng thứ tự giữa timeout và immediate có thể phụ thuộc ngữ cảnh chạy và phiên bản Node.js, đặc biệt khi đặt trong hoặc ngoài I/O callback.