JavaScript Event Loop — Microtask vs Macrotask trong async

Phong Hy

Mở đầu

Hồi mới học JavaScript, mình nhớ có lần viết một đoạn code xử lý API call — fetch xong thì update UI, log ra console, đủ thứ. Rồi tự nhiên thấy cái log ra sai thứ tự, UI update chậm hơn cái log mặc dù mình viết nó ở sau. Ngồi gãi đầu mãi không hiểu, tới khi đọc về Event Loop mới vỡ lẽ ra bao nhiêu thứ. Hoá ra cái thằng cha chạy ngầm trong JS này quyết định thứ tự thực thi của mọi thứ — mà không hiểu nó thì code kiểu gì cũng sai.

Hôm nay mình muốn kể lại cái chuyện Event Loop này, đặc biệt là cái khái niệm MicrotaskMacrotask — thứ mà nếu không biết thì async code của bạn sẽ chạy kiểu "trời kêu ai nấy dạ".

Code JavaScript trên màn hình Ảnh: Paras Katwal — Pexels

Event Loop — cái vòng lặp "vô hình" mà ai cũng cần hiểu

JavaScript là single-threaded — một thời điểm chỉ chạy được một việc. Nhưng nhờ Event Loop, nó vẫn xử lý được hàng ngàn request, hàng chục animation, và cả đống callback mà không blocking.

Cái cốt lõi của Event Loop là: khi Call Stack rỗng, nó nhìn vô Task Queue, lấy cái đầu tiên ra chạy. Xong, lại nhìn. Lặp đi lặp lại như thế. Nhưng mà cái queue này không phải chỉ có một — nó có tới hai loại!

// Ví dụ kinh điển — thử đoán thứ tự log
console.log('1');

setTimeout(() => console.log('2'), 0);

Promise.resolve().then(() => console.log('3'));

console.log('4');

// Kết quả: 1, 4, 3, 2 — bất ngờ chưa?

Cái Promise (Microtask) chạy trước setTimeout (Macrotask) dù timeout là 0ms. Đây là cái bẫy cổ điển mà mình thấy rất nhiều bạn junior (và cả không junior) vẫn nhầm.

Microtask vs Macrotask — khác nhau thế nào?

Macrotask (còn gọi là Task) bao gồm: setTimeout, setInterval, setImmediate (Node.js), I/O callbacks, event listeners (click, scroll...).

Microtask bao gồm: Promise.then/catch/finally, queueMicrotask(), MutationObserver, process.nextTick() (Node.js).

Mỗi lần Event Loop "tick", nó làm một chuỗi các bước như sau:

  1. Chọn một Macrotask từ queue
  2. Chạy nó cho tới khi Call Stack rỗng
  3. Chạy hết tất cả Microtask trong Microtask queue
  4. Update render (nếu có)
  5. Quay lại bước 1

Cái step số 3 là keyword — trước khi chạy Macrotask tiếp theo, Event Loop sẽ dọn sạch Microtask queue. Nghĩa là nếu trong lúc xử lý Microtask, bạn thêm Microtask mới, nó cũng chạy luôn trong lượt đó.

// Microtask có thể đẻ ra Microtask khác
Promise.resolve().then(() => {
  console.log('micro 1');
  queueMicrotask(() => console.log('micro 2 — vẫn chạy trong cùng lượt'));
});

setTimeout(() => console.log('macrotask'), 0);

// Output: micro 1, micro 2, macrotask

Đây là lý do vì sao recursive Promise có thể block UI — nó liên tục thêm Microtask mới, Event Loop không bao giờ chạm được tới Macrotask (kể cả render).

Developer đang code với nhiều màn hình Ảnh: Ilya — Pexels

Cái bẫy thực tế — event loop và performance

Mình từng gặp cái bug trong production như thế này: một cái API Gateway viết bằng Node.js bị memory leak nhẹ, tracing mãi không ra. Cuối cùng nguyên nhân là do một thư viện dùng process.nextTick() liên tục ở Node.js — mỗi request nó plan một cái nextTick, mà nextTick lại schedule thêm nextTick khác, tạo thành infinite microtask loop ở cấp độ process.

Trong Node.js, process.nextTick() còn được ưu tiên cao hơn cả Promise Microtask — nó nằm trong một queue riêng gọi là nextTickQueue, và được xử lý trước cả Promise microtask. Nguy hiểm hơn nữa!

// Node.js — nextTickQueue > Promise Microtask > Macrotask
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
setTimeout(() => console.log('timeout'), 0);

// Output: nextTick, promise, timeout

Mẹo nhỏ cho dev

  1. Nếu cần defer một tác vụ nhưng không muốn block UI, dùng setTimeout(fn, 0) — nó là Macrotask, cho render chen vô giữa.
  2. Nếu cần chạy ngay sau current operation, dùng queueMicrotask() — nhanh hơn, nhưng cẩn thận đừng tạo infinite loop.
  3. Trong Node.js, tránh lạm dụng process.nextTick() — ưu tiên queueMicrotask() hoặc setImmediate().
  4. Browser rendering xảy ra giữa các Macrotask, sau khi Microtask queue rỗng — nếu bạn có animation cần mượt, đừng để Microtask chạy quá lâu.

Developer team làm việc cùng nhau Ảnh: Erik Mclean — Pexels

Kết

Event Loop nghe có vẻ lý thuyết, nhưng thực ra nó ảnh hưởng tới từng dòng code async bạn viết mỗi ngày. Hiểu được Microtask và Macrotask không chỉ giúp bạn debug nhanh hơn, mà còn tránh được những bug performance khó chịu.

Có ai từng dính cái bẫy setTimeout vs Promise như mình không? Chia sẻ dưới comment nha!

📋 Phụ lục thuật ngữ

  • Event Loop — cơ chế lặp vô hạn của JavaScript để xử lý các tác vụ bất đồng bộ
  • Call Stack — ngăn xếp chứa các hàm đang được thực thi
  • Task Queue / Macrotask Queue — hàng đợi chứa các tác vụ như setTimeout, I/O callbacks
  • Microtask Queue — hàng đợi ưu tiên cao hơn, chứa Promise.then, queueMicrotask
  • nextTickQueue (Node.js) — queue riêng cho process.nextTick(), ưu tiên cao nhất
  • Single-threaded — chỉ chạy một luồng xử lý tại một thời điểm