JavaScript chỉ chạy trên một luồng đơn (single-thread), nhưng nó vẫn xử lý hàng ngàn tác vụ bất đồng bộ như setTimeout, fetch, async/await mà không bị “đơ”. Bí mật nằm ở Event Loop – cơ chế giúp JavaScript điều phối thứ tự thực thi giữa các đoạn code đồng bộ và bất đồng bộ. Trong bài này, chúng ta sẽ đi thật chi tiết, từ lý thuyết, ví dụ thực tế, đến best practices trong frontend lẫn backend (Node.js).
1. Vì sao cần Event Loop?
JavaScript là ngôn ngữ đơn luồng – chỉ có một Call Stack để thực thi code. Nếu code chặn (blocking) quá lâu, toàn bộ ứng dụng sẽ bị đứng. Nhưng ứng dụng hiện đại cần:
-
Lắng nghe sự kiện (click, keypress)
-
Gửi / nhận API (fetch, XHR)
-
Hẹn giờ (setTimeout, setInterval)
-
Đọc ghi file (Node.js I/O)
Nếu chỉ dựa vào single-thread thì bất đồng bộ sẽ là điều bất khả thi. Event Loop chính là lời giải.
2. Các thành phần trong Event Loop
Một vòng lặp event loop gồm 4 thành phần quan trọng:
-
Call Stack: Nơi code đồng bộ thực thi.
-
Web APIs (Browser) / libuv (Node.js): Cung cấp API nền như setTimeout, fetch, I/O.
-
Task Queue (Macrotask Queue): Hàng đợi chứa callback từ timers, I/O, DOM events.
-
Microtask Queue (Job Queue): Hàng đợi ưu tiên chứa Promise.then, queueMicrotask, MutationObserver.
3. Event Loop hoạt động như thế nào?
-
Code đồng bộ được đưa vào Call Stack và chạy ngay.
-
Khi gặp code bất đồng bộ (setTimeout, fetch, Promise…), nó được giao cho Web APIs xử lý.
-
Sau khi hoàn thành, callback sẽ được đưa vào Task Queue (macrotask) hoặc Microtask Queue.
-
Event Loop sẽ kiểm tra:
-
Nếu Call Stack trống → chạy tất cả microtasks.
-
Sau đó mới lấy 1 macrotask từ Task Queue để chạy.
-
-
Browser có thể render UI giữa các macrotasks.
Pseudo-code của Event Loop
while (true) { if (CallStack.isEmpty()) { while (MicrotaskQueue.notEmpty()) { run(MicrotaskQueue.shift()); } if (TaskQueue.notEmpty()) { run(TaskQueue.shift()); } // Browser có thể render tại đây } }
4. Ví dụ cơ bản
console.log("A"); setTimeout(() => console.log("B"), 0); Promise.resolve().then(() => console.log("C")); console.log("D");
Kết quả:
A D C B
Giải thích:
-
"A" và "D" chạy ngay (Call Stack).
-
setTimeout → callback "B" vào Task Queue.
-
Promise.then → "C" vào Microtask Queue.
-
Khi stack trống, Event Loop chạy "C" trước rồi mới đến "B".
5. Microtask vs Macrotask
Loại |
Ví dụ |
Khi chạy |
---|---|---|
Microtask |
Promise.then, queueMicrotask, MutationObserver |
Ngay sau khi Call Stack trống, trước macrotask |
Macrotask |
setTimeout, setInterval, I/O, setImmediate (Node) |
Sau khi microtask queue rỗng |
Ví dụ minh họa
setTimeout(() => console.log("Task"), 0); Promise.resolve().then(() => { console.log("Microtask 1"); Promise.resolve().then(() => console.log("Microtask 2")); });
Kết quả:
Microtask 1 Microtask 2 Task
Microtasks được “drain” hết trước khi chạy macrotask.
6. Event Loop trong Browser
Trình duyệt ngoài việc chạy code còn phải:
-
Render (paint/reflow): thường xảy ra giữa các macrotask.
-
Animation: chạy trong requestAnimationFrame trước mỗi frame.
-
Clamping Timers: setTimeout(..., 0) có thể bị delay tối thiểu 4ms nếu lặp nhiều.
Ví dụ animation:
function animateBox() { box.style.left = box.offsetLeft + 1 + "px"; requestAnimationFrame(animateBox); } requestAnimationFrame(animateBox);
Dùng requestAnimationFrame thay vì setInterval để mượt hơn.
7. Event Loop trong Node.js
Node.js dựa vào libuv, event loop có nhiều phase:
-
Timers: xử lý setTimeout, setInterval.
-
Pending callbacks: một số hệ thống I/O callbacks.
-
Idle, prepare.
-
Poll: xử lý I/O, đợi event mới.
-
Check: xử lý setImmediate.
-
Close callbacks: xử lý cleanup.
Khác biệt quan trọng
-
process.nextTick() chạy trước cả microtasks (Promise).
-
setImmediate() có thể chạy trước hoặc sau setTimeout(...,0) tùy ngữ cảnh.
Ví dụ Node.js
console.log("start"); setTimeout(() => console.log("timeout"), 0); setImmediate(() => console.log("immediate")); process.nextTick(() => console.log("nextTick")); Promise.resolve().then(() => console.log("promise")); console.log("end");
Kết quả:
start end nextTick promise timeout immediate
nextTick chạy ngay, rồi đến promise. Sau đó mới đến timers và setImmediate.
8. Những vấn đề thường gặp
Blocking UI
Nếu bạn viết:
while(true) {}
Giao diện sẽ đứng, vì Call Stack không bao giờ trống.
Starvation (đói macrotask)
Quá nhiều microtask liên tiếp khiến macrotask không có cơ hội chạy
Browser không bao giờ render → UI “đóng băng”.
Giải pháp
-
Chia nhỏ công việc (chunking).
-
Dùng setTimeout hoặc requestIdleCallback để yield.
-
Với tính toán nặng → Web Worker.
9. Pattern tối ưu
Chunking công việc lớn
function processBigArray(arr) { const CHUNK = 100; let i = 0; function work() { const end = Math.min(i + CHUNK, arr.length); for (; i < end; i++) { // xử lý arr[i] } if (i < arr.length) { setTimeout(work, 0); // yield cho event loop } } work(); }
Web Worker (frontend)
Cho tính toán nặng, đẩy sang worker để không block main thread.
requestAnimationFrame
Cho UI animation, đảm bảo sync với FPS.
10. Debug Event Loop
-
Chrome DevTools → Performance: Record, tìm “Long Tasks” > 50ms.
-
Async stack traces: thấy chuỗi gọi bất đồng bộ.
-
Node.js Inspector: node --inspect, dùng DevTools hoặc clinic.js.
11. Bài tập cho bạn
1. Đoán kết quả
console.log(1); setTimeout(() => console.log(2), 0); Promise.resolve().then(() => { console.log(3); setTimeout(() => console.log(4), 0); }); console.log(5);
Thứ tự đúng: 1, 5, 3, 2, 4
2. Tạo vòng lặp process.nextTick và xem nó block I/O như thế nào trong Node.
3. Dùng chunking để render 10,000 <li> mà không làm trình duyệt giật lag.
12. Kết luận
-
Event Loop là trái tim bất đồng bộ của JavaScript.
-
Microtasks chạy trước macrotasks.
-
Trong Browser, render thường xảy ra giữa các macrotasks.
-
Trong Node.js, process.nextTick chạy ngay trước microtasks, setImmediate ở phase riêng.
-
Hiểu rõ event loop giúp bạn viết code async hiệu quả, tránh blocking UI, tối ưu hiệu năng.