JavaScript Event Loop — Microtask vs Macrotask trong async
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 Microtask và Macrotask — 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ạ".
Ả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:
- Chọn một Macrotask từ queue
- Chạy nó cho tới khi Call Stack rỗng
- Chạy hết tất cả Microtask trong Microtask queue
- Update render (nếu có)
- 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).
Ả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
- 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. - 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. - Trong Node.js, tránh lạm dụng
process.nextTick()— ưu tiênqueueMicrotask()hoặcsetImmediate(). - 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.
Ả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