JavaScript Event Loop: Cơ Chế Bất Đồng Bộ và Vòng Lặp Sự Kiện Trong JavaScript
Khám phá JavaScript Event Loop — cơ chế giúp JavaScript xử lý bất đồng bộ dù là single-threaded. Tìm hiểu Call Stack, Web APIs, Microtask Queue, Macrotask Queue và cách chúng phối hợp với nhau.

Bạn đã bao giờ tự hỏi: “Tại sao setTimeout(fn, 0) không chạy ngay lập tức?” hoặc “Sao Promise lại chạy trước setTimeout dù timeout = 0?”
Câu trả lời nằm ở JavaScript Event Loop — cơ chế cốt lõi giúp JavaScript, một ngôn ngữ single-threaded, vẫn có thể xử lý hàng tá tác vụ bất đồng bộ (API calls, timer, user interactions) một cách mượt mà.
Trong bài viết này, chúng ta sẽ mổ xẻ Event Loop từ A đến Z — từ Call Stack, Web APIs, đến Microtask và Macrotask Queue — với vô vàn ví dụ thực tế.
JavaScript là Single-threaded — Vậy thì sao?
JavaScript chạy trên một luồng duy nhất (single thread). Nói cách khác, tại bất kỳ thời điểm nào, chỉ có một đoạn code được thực thi.
console.log('Bắt đầu');
console.log('Giữa');
console.log('Kết thúc');
// Output: Bắt đầu → Giữa → Kết thúc
Điều này nghe có vẻ hạn chế, nhưng thực ra là một thiết kế tuyệt vời:
- Không race conditions — Không cần lock, mutex, semaphore phức tạp
- Đơn giản cho lập trình viên — Không phải lo về đồng bộ dữ liệu giữa các thread
- Tương tác người dùng mượt — DOM manipulation an toàn vì chỉ một thread
Nhưng nếu chỉ có một thread, làm sao JavaScript xử lý được hàng loạt tác vụ cùng lúc — như fetch API, setTimeout, đọc file, xử lý sự kiện click?
Câu trả lời: Event Loop + Web APIs (trong browser) hoặc libuv (trong Node.js).
Cấu Trúc Của Event Loop
Event Loop không phải là một “vật” cụ thể — nó là một cơ chế phối hợp giữa các thành phần sau:
1. Call Stack (Ngăn xếp lời gọi)
Khi bạn gọi một hàm, nó được push vào Call Stack. Khi hàm kết thúc, nó được pop ra.
function multiply(a, b) {
return a * b;
}
function square(n) {
return multiply(n, n);
}
console.log(square(5));
// Call Stack:
// 1. Push console.log
// 2. Push square(5)
// 3. Push multiply(5, 5)
// 4. Pop multiply → trả về 25
// 5. Pop square → trả về 25
// 6. Pop console.log → in ra 25
Stack Overflow xảy ra khi Call Stack bị đầy — thường là do đệ quy vô hạn:
function infinite() {
return infinite();
}
infinite(); // ❌ RangeError: Maximum call stack size exceeded
2. Web APIs (Browser APIs)
Đây là các API do browser cung cấp, không phải của JavaScript engine:
setTimeout/setIntervalfetch(AJAX)- DOM Events (
click,keydown, …) consolelocalStorage
Khi bạn gọi setTimeout(callback, 1000), JavaScript không đợi 1 giây trong Call Stack. Nó gửi callback sang Web APIs, và timer được browser quản lý ở một thread riêng.
3. Callback Queue (Macrotask Queue)
Khi một Web API hoàn thành (ví dụ: setTimeout hết 1 giây), nó không đưa callback ngay vào Call Stack (vì Call Stack có thể đang bận). Thay vào đó, callback được đưa vào Callback Queue (hay còn gọi là Macrotask Queue).
4. Microtask Queue (Job Queue)
Đây là queue có ưu tiên cao hơn Macrotask Queue. Các tác vụ sau được đưa vào Microtask Queue:
Promise.then(),Promise.catch(),Promise.finally()queueMicrotask()MutationObserver(trong browser)process.nextTick()(trong Node.js — ưu tiên cao nhất)
5. Event Loop
Event Loop là vòng lặp vô tận kiểm tra:
while (true) {
// 1. Xử lý hết tất cả synchronous code trong Call Stack
// 2. Khi Call Stack rỗng:
// a. Dọn sạch TOÀN BỘ Microtask Queue
// b. Lấy MỘT macrotask từ Macrotask Queue
// c. Quay lại bước 1
// (Trong browser: UI rendering xảy ra giữa các macrotask)
}
Quy Tắc Ưu Tiên (Priority Rules)
Đây là quy tắc quan trọng nhất cần nhớ:
- Synchronous code trong Call Stack luôn chạy trước
- Microtasks (Promise.then, queueMicrotask) — xử lý hết toàn bộ trước khi chuyển sang macrotask
- Macrotasks (setTimeout, setInterval, I/O) — xử lý một cái mỗi lần lặp
- UI Rendering (trong browser) — xảy ra sau khi microtask queue trống, trước macrotask tiếp theo
Ví Dụ Thực Tế
Ví dụ 1: Cơ bản nhất
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
console.log('3');
// Output: 1 → 3 → 2
Giải thích từng bước:
console.log('1')→ push vào Call Stack, in ra 1, pop rasetTimeout(cb, 0)→ push vào Call Stack, gửi callback sang Web APIs (timer 0ms), pop raconsole.log('3')→ push vào Call Stack, in ra 3, pop ra- Call Stack rỗng → Event Loop kiểm tra Microtask Queue (rỗng)
- Event Loop lấy callback từ Macrotask Queue → in ra 2
Ví dụ 2: Promise vs setTimeout
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
Promise.resolve().then(() => {
console.log('3');
});
console.log('4');
// Output: 1 → 4 → 3 → 2
Giải thích:
console.log('1')→ in 1setTimeout(cb, 0)→ gửi callback vào Web APIsPromise.resolve().then(cb)→ callback được đưa vào Microtask Queueconsole.log('4')→ in 4- Call Stack rỗng → Event Loop dọn sạch Microtask Queue → in 3
- Event Loop lấy một macrotask từ Macrotask Queue → in 2
Đây là lý do Promise luôn chạy trước setTimeout!
Ví dụ 3: Nested Microtasks
console.log('1');
setTimeout(() => {
console.log('2');
Promise.resolve().then(() => {
console.log('3');
});
}, 0);
Promise.resolve().then(() => {
console.log('4');
setTimeout(() => {
console.log('5');
}, 0);
});
console.log('6');
// Output: 1 → 6 → 4 → 2 → 3 → 5
Giải thích từng bước:
| Bước | Call Stack | Microtask Queue | Macrotask Queue | Output |
|---|---|---|---|---|
| 1 | console.log(1) | 1 | ||
| 2 | setTimeout | timeout cb | ||
| 3 | Promise.then | promise cb1 | timeout cb | |
| 4 | console.log(6) | promise cb1 | timeout cb | 6 |
| 5 | (rỗng) | promise cb1 | timeout cb | |
| 6 | Chạy promise cb1 → console.log(4) + setTimeout | 4 | ||
| 7 | setTimeout (trong promise cb1) | timeout cb1, timeout cb2 | ||
| 8 | (rỗng) | (rỗng) | timeout cb1, timeout cb2 | |
| 9 | Chạy timeout cb1 → console.log(2) + Promise.then | timeout cb2 | 2 | |
| 10 | Promise.then (trong timeout cb1) | promise cb2 | timeout cb2 | |
| 11 | (rỗng) | promise cb2 | timeout cb2 | |
| 12 | Chạy promise cb2 → console.log(3) | timeout cb2 | 3 | |
| 13 | (rỗng) | (rỗng) | timeout cb2 | |
| 14 | Chạy timeout cb2 → console.log(5) | 5 |
Ví dụ 4: Event Loop trong DOM Events
button.addEventListener('click', () => {
console.log('Click handler 1');
Promise.resolve().then(() => {
console.log('Microtask từ click');
});
});
button.addEventListener('click', () => {
console.log('Click handler 2');
});
// Khi người dùng click vào button:
// Output: Click handler 1 → Click handler 2 → Microtask từ click
Khi người dùng click, browser đưa cả hai event handlers vào Macrotask Queue như một shared macrotask (nếu cùng một sự kiện). Sau khi handler 1 chạy, microtask queue được dọn sạch trước khi handler 2 chạy?
Thực tế: Trong môi trường browser, sau khi click:
- Cả hai click handlers được đưa vào cùng một macrotask
- Handler 1 chạy → đưa Promise.then vào Microtask Queue
- Handler 2 chạy tiếp (vì vẫn còn trong cùng macrotask)
- Call Stack rỗng → dọn Microtask Queue → in “Microtask từ click”
Ví dụ 5: Async/Await và Event Loop
async function test() {
console.log('1');
await Promise.resolve('2');
console.log('3'); // Phần này chạy như microtask
}
test();
console.log('4');
// Output: 1 → 4 → 3
Giải thích:
test()được gọi → push vào Call Stackconsole.log('1')→ in 1await Promise.resolve('2')→ function tạm dừng, trả về Promise, phần còn lại (console.log(‘3’)) được đưa vào Microtask Queueconsole.log('4')→ in 4- Call Stack rỗng → dọn Microtask Queue → in 3
async function test() {
console.log('A');
await null; // await bất kỳ giá trị nào
console.log('B');
}
test();
setTimeout(() => console.log('C'), 0);
Promise.resolve().then(() => console.log('D'));
console.log('E');
// Output: A → E → B → D → C
// (B và D đều là microtask, B được queue trước nên chạy trước)
Pattern Nâng Cao
Pattern 1: Chunking — Chia nhỏ tác vụ nặng
Khi bạn có một vòng lặp xử lý hàng triệu item, nó sẽ block Event Loop và làm page bị đơ:
// ❌ BAD: Block Event Loop ~5 giây
const items = Array.from({ length: 10_000_000 }, (_, i) => i);
items.forEach(item => {
heavyProcessing(item); // Block!
});
Giải pháp: Chia nhỏ thành từng chunk, dùng setTimeout để nhường cho Event Loop:
// ✅ GOOD: Chunking với setTimeout
function processChunk(items, index, chunkSize = 1000) {
const end = Math.min(index + chunkSize, items.length);
for (let i = index; i < end; i++) {
heavyProcessing(items[i]);
}
if (end < items.length) {
// Nhường cho Event Loop xử lý UI, events, v.v.
setTimeout(() => processChunk(items, end, chunkSize), 0);
} else {
console.log('✅ Hoàn thành xử lý tất cả items!');
}
}
processChunk(items, 0);
Pattern 2: queueMicrotask cho ưu tiên cao
function saveData(data) {
// Lưu ngay lập tức
localStorage.setItem('data', JSON.stringify(data));
// Đồng bộ lên server — không block, ưu tiên cao
queueMicrotask(async () => {
try {
await fetch('/api/save', { method: 'POST', body: JSON.stringify(data) });
} catch (e) {
console.warn('Sync server thất bại, dữ liệu vẫn an toàn trong localStorage');
}
});
}
Pattern 3: requestAnimationFrame cho Animation
// ❌ BAD: Dùng setTimeout cho animation
function animateBad() {
element.style.transform = `translateX(${pos}px)`;
pos++;
setTimeout(animateBad, 16); // ~60fps
}
// ✅ GOOD: Dùng requestAnimationFrame
function animateGood() {
element.style.transform = `translateX(${pos}px)`;
pos++;
requestAnimationFrame(animateGood); // Đồng bộ với màn hình
}
requestAnimationFrame là một macrotask đặc biệt — nó chạy trước khi browser render frame tiếp theo, đảm bảo animation mượt mà.
Event Loop trong Node.js
Node.js cũng có Event Loop, nhưng có một số khác biệt:
- Không có Web APIs — Thay vào đó là
libuv(C library) - Các phase (giai đoạn): timers → I/O callbacks → idle/prepare → poll → check (setImmediate) → close callbacks
- process.nextTick() — ưu tiên cao hơn cả Microtask Queue (Promise.then)
// Node.js specific
Promise.resolve().then(() => console.log('Promise'));
process.nextTick(() => console.log('nextTick'));
// Output: nextTick → Promise
Quy tắc trong Node.js:
nextTick → Promise (Microtask) → timers (setTimeout) → I/O → setImmediate → close
Lỗi Thường Gặp Và Cách Tránh
1. “setTimeout(fn, 0) không chạy ngay” 🚫
// Nhầm tưởng: setTimeout(fn, 0) chạy ngay sau synchronous code
setTimeout(() => {
console.log('setTimeout');
}, 0);
// Thực tế: Nó chỉ chạy SAU KHI microtask queue trống
Promise.resolve().then(() => {
console.log('Promise'); // Chạy TRƯỚC setTimeout
});
2. Block Event Loop với vòng lặp nặng 🚫
// ❌ Sẽ làm page đơ (freeze)
while (true) {
// Không bao giờ kết thúc → Event Loop không bao giờ chạy
}
Giải pháp: Luôn dùng async patterns hoặc Web Workers cho tác vụ nặng.
3. Quên xử lý lỗi Promise 🚫
// ❌ Unhandled Promise Rejection
fetch('/api/data').then(data => {
// Nếu fetch thất bại, không ai catch lỗi
});
// ✅ Luôn có catch
fetch('/api/data')
.then(data => console.log(data))
.catch(err => console.error('Lỗi:', err));
4. Starvation (Bỏ đói) Macrotask Queue 🚫
function microtaskHell() {
Promise.resolve().then(() => {
// Microtask này lại tạo thêm microtask khác
microtaskHell();
});
}
microtaskHell();
setTimeout(() => {
console.log('Không bao giờ chạy!');
}, 1000);
// ❌ Macrotask (setTimeout) không bao giờ được xử lý
// vì Microtask Queue không bao giờ trống
So Sánh Các Async Patterns
| Pattern | Queue | Ưu tiên | Dùng khi |
|---|---|---|---|
| Synchronous code | Call Stack | 🔴 Cao nhất | Code thông thường |
| Promise.then() | Microtask | 🟠 Cao | Sau async operation |
| queueMicrotask() | Microtask | 🟠 Cao | Cần chạy ngay sau sync code |
| MutationObserver | Microtask | 🟠 Cao | Theo dõi DOM changes |
| setTimeout(fn, 0) | Macrotask | 🟡 Thấp | Chia nhỏ tác vụ, defer work |
| setInterval | Macrotask | 🟡 Thấp | Tác vụ lặp định kỳ |
| I/O (fetch, file) | Macrotask | 🟡 Thấp | Network/File operations |
| requestAnimationFrame | Render | 🟢 Trước render | Animation, UI updates |
| postMessage | Macrotask | 🟡 Thấp | Cross-window communication |
| process.nextTick() (Node) | nextTick | 🔴 Cao nhất (Node) | Before next event loop phase |
Kết Luận
JavaScript Event Loop là trái tim của toàn bộ hệ thống bất đồng bộ trong JavaScript. Hiểu rõ nó giúp bạn:
- ✅ Debug dễ dàng hơn — Biết chính xác thứ tự thực thi code
- ✅ Tránh blocking — Không làm page đơ với tác vụ nặng
- ✅ Tối ưu hiệu năng — Biết khi nào dùng microtask vs macrotask
- ✅ Viết code đúng — Không bị ngạc nhiên bởi thứ tự output
- ✅ Phỏng vấn tự tin — Event Loop là câu hỏi kinh điển trong phỏng vấn JavaScript
Nguyên tắc vàng cần nhớ:
Synchronous chạy trước → Microtask (Promise) chạy giữa → Macrotask (setTimeout) chạy cuối
Hãy thử nghiệm với các đoạn code trong bài viết này bằng cách mở Console trong DevTools (F12) và chạy thử. Cách tốt nhất để hiểu Event Loop là tự tay viết code và quan sát output!
Bạn muốn tìm hiểu sâu hơn về một khía cạnh cụ thể nào của Event Loop không? Hãy để lại bình luận bên dưới! 🚀