JavaScript Event Loop Deep Dive — Microtasks, Macrotasks, and Real Browser Timing
Deep dive into the JS event loop: call stack, macrotask vs microtask queues, await desugaring, timer clamping, rAF, and predictable console ordering.
Vì sao điều này quan trọng trên project thật
Bạn đã thấy đoạn phỏng vấn: setTimeout(..., 0) log sau Promise.then, dù delay timer là zero. Trong production, cùng cơ chế đó giải thích UI stale sau state update, double-fetch khó hiểu, scroll handler giật, và “local chạy nhưng Safari race”.
Bài này đi sâu một thread, hai queue, và thứ tự drain nghiêm ngặt. Ta ở main thread — với Workers và WASM concurrency, xem bài concurrency riêng. Với generators và yield, xem bài generators.
Mô hình tư duy: JavaScript chạy một stack frame mỗi lần trên một call stack. Host (browser hoặc Node) cung cấp Web APIs / libuv và queue để I/O và timer không block stack mãi.
Visualizer tương tác
Bước qua call stack, Web APIs, microtask queue, macrotask queue, và console output.
Mở demo đầy đủ:
Call stack đơn luồng
Khi JavaScript thực thi, nó push frame hàm lên call stack (LIFO). Mỗi frame giữ biến cục bộ, con trỏ instruction hiện tại, và chỗ return.
function a() {
console.log('a');
b();
}
function b() {
console.log('b');
}
a();
// Stack while inside b(): [ a(), b() ]
// Then b pops, then a pops — stack empty
Chạy tới khi xong: code đồng bộ trên stack chạy đến khi return hoặc throw. Không gì khác trên main thread chen giữa giữa hàm — không click handler, timer callback, hay Promise.then khác.
Đó là lý do vòng lặp đồng bộ 200 ms đơ tab: stack không bao giờ clear, nên event loop không có lượt xử lý input hay paint.
| Concept | Meaning |
|---|---|
| Call stack | Currently executing synchronous frames |
| Run-to-completion | One frame runs entirely before another starts |
| Main thread | Where stack + event loop + most DOM APIs live |
Host environment: Web APIs không phải JavaScript
setTimeout, fetch, DOM events, requestAnimationFrame, và socket mạng không thuộc ngôn ngữ ECMAScript. Host (browser hoặc Node) implement chúng bằng native code hoặc thread riêng và expose callback về JS realm.
Luồng điển hình cho timer:
- JS gọi
setTimeout(fn, 1000)— host đăng ký timer. - JS tiếp tục chạy; stack rảnh.
- Sau ~1000 ms host đặt
fnlên macrotask queue (task queue). - Khi stack rỗng và microtask đã drain, event loop dequeue một macrotask và chạy
fn.
Timer nằm ở Web APIs / Timers khi chờ — không trên stack, chưa trong queue JS.
Hai queue: macrotask vs microtask
HTML và Node đều phân biệt task (macrotask) và microtask. Tên khác nhau trong spec, nhưng thứ tự drain là điều engineer nhớ.
| Queue | Common sources | When it runs |
|---|---|---|
| Macrotask (task) | setTimeout, setInterval, I/O callbacks, postMessage, user events (click, input) | One per event-loop turn (after microtasks from prior turn) |
| Microtask | Promise.then / catch / finally, queueMicrotask, MutationObserver | After current script/task completes, before next macrotask |
Quy tắc vàng: xong stack đồng bộ hiện tại → drain hết microtask queue → (có thể render) → lấy một macrotask → lặp.
Đó là lý do microtask nằm “giữa” macrotask — chúng đi kèm lượt vừa kết thúc.
Thuật toán event loop (theo hình browser)
Vòng lặp đơn giản nhưng chính xác:
while (true) {
// 1. Run one macrotask (or initial script)
task = macrotaskQueue.dequeue()
if (task) run(task) // pushes/pops call stack
// 2. Drain ALL microtasks produced during that task
while (microtaskQueue.notEmpty()) {
micro = microtaskQueue.dequeue()
run(micro)
}
// 3. Update rendering (if needed) — rAF callbacks, style, layout, paint
maybeUpdateRendering()
// 4. Idle hooks, etc.
}
Render không phải “queue” riêng bạn enqueue như setTimeout. Browser chèn bước update the rendering tại checkpoint rõ ràng — sau microtasks, trước task tiếp theo trong nhiều trường hợp.
Ví dụ: thứ tự kinh điển
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
Trace:
- Đồng bộ:
1, đăng ký timer, schedule microtask cho3, đồng bộ4→ console1,4. - Stack rỗng → drain microtask →
3→ console3. - Macrotask timer →
2→ console2.
Kết quả: 1, 4, 3, 2.
0 trong setTimeout(..., 0) nghĩa là “slot macrotask sớm nhất”, không phải “trước microtask”.
Microtask lồng nhau và queueMicrotask
Microtask được schedule trong lúc chạy microtask chạy trong cùng phase drain.
console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve().then(() => {
console.log('C');
queueMicrotask(() => console.log('D'));
});
queueMicrotask(() => console.log('E'));
console.log('F');
Thứ tự: A, F, C, E, D, B.
Sau sync, microtask queue là [Promise callback, E] (FIFO). Chạy Promise callback log C và enqueue D trước khi xử lý E — nhưng E đã được queue, nên thứ tự là C, rồi E, rồi D. Chỉ khi microtask queue rỗng macrotask timer mới chạy.
Bẫy phỏng vấn: “Chuỗi
Promise.thenvô hạn” đói macrotask và rendering. Cùng loại bug với microtask starvation trong parser production.
async / await là sugar trên Promise + microtask
await không block thread. Nó suspend hàm async và schedule phần còn lại như microtask khi giá trị await settle.
console.log('start');
async function foo() {
console.log('in foo');
await Promise.resolve();
console.log('after await');
}
foo();
console.log('end');
Output: start, in foo, end, after await.
Desugar gần đúng:
function foo() {
return Promise.resolve().then(() => {
console.log('in foo');
return Promise.resolve();
}).then(() => {
console.log('after await');
});
}
Mọi thứ sau await là continuation microtask. Caller tiếp tục chạy đồng bộ đến khi gặp await hoặc return.
Nguồn macrotask bạn gặp hàng tuần
| Source | Notes |
|---|---|
setTimeout / setInterval | Timer callback after host fires |
| DOM events | Click, input, load — one task per dispatch (with coalescing rules) |
MessageChannel / postMessage | Used by React 18 scheduler, many libs |
requestIdleCallback | Low-priority macrotask-like idle work |
| I/O (Node) | File/network completion via libuv |
Input cảm giác “chậm” khi macrotask xếp hàng sau long task. INP đo khoảng trống đó — xem bài Core Web Vitals cho metric, không phải cơ chế loop.
Nguồn microtask ngoài Promise.then
| API | Typical use |
|---|---|
Promise reactions | Any .then / .catch / .finally |
queueMicrotask(fn) | Explicit microtask — prefer over Promise.resolve().then(fn) when you mean it |
MutationObserver | DOM mutations batched, callback runs as microtask |
await | Continuation after settlement |
MutationObserver là lý do đọc layout trong observer callback vẫn có thể chạy trước paint cùng frame — nhưng đừng dựa vào đó để đo; dùng requestAnimationFrame hoặc pattern ResizeObserver có chủ đích.
setTimeout(0) không phải zero millisecond
Browser clamp delay timer lồng nhau. HTML quy định minimum timeout (thường 4 ms khi độ sâu lồng vượt ngưỡng trong engine hiện đại).
// Nested setTimeout(0) in a loop — delays grow toward 4ms+
function spin() {
setTimeout(spin, 0);
}
spin();
Hệ quả:
setTimeout(fn, 0)không đáng tin để “yield cho browser” — microtask và render có thể không chạy nếu bạn cứ schedule sync work.- Chỉ dùng
queueMicrotaskkhi cần thứ tự trước macrotask tiếp, không phải khi cần paint. - Cho “sau layout” hoặc “trước paint”, dùng
requestAnimationFrame.
Microtask starvation
Nếu mỗi microtask schedule cái tiếp, loop không bao giờ tới macrotask hay bước render.
function bad() {
queueMicrotask(bad);
}
bad(); // tab hangs — no clicks, no paint updates
Biến thể thực tế:
- Parse JSON lớn rồi chuỗi transform
.thentrong vòng lặp chặt. - Thư viện reactive flush hàng trăm microtask trước khi yield.
- Vòng
while+awaitvô tình trong async function vẫn schedule microtask nhanh hơn macrotask.
Pattern sửa: Chia nhỏ work bằng setTimeout(0), requestAnimationFrame, hoặc scheduler.postTask (nếu có), và đo long task trong Performance panel.
requestAnimationFrame vs event loop
Callback requestAnimationFrame (rAF) chạy trước repaint tiếp theo, trong phase rendering — không nằm microtask queue.
Thứ tự gần đúng trong một frame:
macrotask(s) → microtasks (all) → rAF callbacks → style/layout → paint → rAF for next frame
Quy tắc thực hành:
- Đọc/ghi DOM: gom write, đọc trong rAF hoặc sau khi microtask ổn để tránh forced sync layout.
setTimeout(fn, 16)là animation loop tạm — rAF theo vsync và giảm tần suất ở tab background.- Pattern double rAF: rAF đầu chờ layout; cái thứ hai chạy sau paint — hữu ích đo DOM.
Render, long task, và input
Browser cần khoảng main thread tương đối rảnh để:
- Chạy lượt event loop
- Chạy rAF
- Tính style và layout
- Paint và composite
- Giao input event như macrotask
Long task (>50 ms) chặn tất cả. Chuỗi Promise.then “async” về cú pháp nhưng vẫn CPU đồng bộ trên main thread giữa các await resolve ngay.
// Still blocks — microtasks run back-to-back until queue empty
Promise.resolve()
.then(() => heavySyncWork())
.then(() => heavySyncWork());
Chia bằng macrotask hoặc Workers khi work vượt budget một frame.
Node.js: cùng queue, chi tiết host khác
Event loop Node có phase (timers, poll, check, close callbacks). Microtask (process.nextTick và Promise jobs) drain giữa các phase — process.nextTick chạy trước Promise microtask trong Node.
| Environment | Extra nuance |
|---|---|
| Browser | One task → all microtasks → render step |
| Node | process.nextTick queue drains before Promise microtasks between phases |
| Worker | Separate event loop per worker — no shared stack |
Frontend engineer chủ yếu ở cột browser; biết thứ tự Node tránh bug copy-paste trong script SSR.
Checklist debug
- Log có nhãn —
console.log('sync', id)vs trong.then/setTimeout. - Performance → Main — block vàng dài = stack không yield.
- Breakpoint
queueMicrotasktrong DevTools khi thứ tự lạ. - Hỏi: Đây là sync stack, microtask, macrotask, hay rAF?.
Lỗi phổ biến (bản senior)
| Mistake | Reality |
|---|---|
”async runs in parallel” | Only awaiting I/O frees the thread; CPU work still blocks |
”setTimeout(0) before paint” | Microtasks and possibly rAF run first |
| ”Promises are always async” | Promise.resolve().then schedules a microtask, but executor runs sync |
”I’ll defer with .then” | Still microtask — same turn if chained |
| ”MutationObserver is sync” | Callback is microtask — after current task, before next macrotask |
Tóm lại
- Một stack, thứ tự nghiêm — sync tới hết, rồi mọi microtask, rồi một macrotask (rồi hook render).
- Promise và
awaitở microtask queue — thắngsetTimeout(0). - Timer là macrotask với delay bị clamp — không phải zero, không trước microtask.
- Starvation có thật — microtask không giới hạn chặn input và paint.
- rAF cho frame, không phải defer — dùng đúng queue cho việc.
Dùng visualizer trên đến khi đoán thứ tự console trở nên nhàm chán. Đó là lúc event loop không còn là trivia mà là công cụ thiết kế UI responsive và scheduling công bằng.