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.
Why this matters on real projects {Vì sao điều này quan trọng trên project thật}
You have seen the interview snippet {Bạn đã thấy đoạn phỏng vấn}: setTimeout(..., 0) logs after Promise.then, even though the timer delay is zero {setTimeout(..., 0) log sau Promise.then, dù delay timer là zero}. In production, the same mechanics explain stale UI after state updates, mystery double-fetches, janky scroll handlers, and “it works locally but races in Safari” {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”}.
This post goes deep on one thread, two queues, and a strict drain order {Bài này đi sâu một thread, hai queue, và thứ tự drain nghiêm ngặt}. We stay on the main thread — for Workers and WASM concurrency, see the dedicated concurrency post {Ta ở main thread — với Workers và WASM concurrency, xem bài concurrency riêng}. For generators and yield, see the generators guide {Với generators và yield, xem bài generators}.
Mental model {Mô hình tư duy}: JavaScript runs one stack frame at a time on a single call stack {JavaScript chạy một stack frame mỗi lần trên một call stack}. The host (browser or Node) provides Web APIs / libuv and queues so I/O and timers do not block that stack forever {Host (browser hoặc Node) cung cấp Web APIs / libuv và queue để I/O và timer không block stack mãi}.
Interactive visualizer {Visualizer tương tác}
Step through call stack, Web APIs, microtask queue, macrotask queue, and console output {Bước qua call stack, Web APIs, microtask queue, macrotask queue, và console output}.
Open the full demo {Mở demo đầy đủ}: /tools/js-event-loop-demo/.
The single-threaded call stack {Call stack đơn luồng}
When JavaScript executes, it pushes function frames onto a call stack (LIFO) {Khi JavaScript thực thi, nó push frame hàm lên call stack (LIFO)}. Each frame holds local variables, the current instruction pointer, and where to return {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
Run-to-completion {Chạy tới khi xong}: synchronous code on the stack runs until it returns or throws {code đồng bộ trên stack chạy đến khi return hoặc throw}. Nothing else on the main thread interleaves mid-function — no other click handler, no timer callback, no Promise.then {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}.
That is why a 200 ms synchronous loop freezes the tab {Đó là lý do vòng lặp đồng bộ 200 ms đơ tab}: the stack never clears, so the event loop never gets a turn to process input or paint {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 are not JavaScript {Host environment: Web APIs không phải JavaScript}
setTimeout, fetch, DOM events, requestAnimationFrame, and network sockets are not part of the ECMAScript language {setTimeout, fetch, DOM events, requestAnimationFrame, và socket mạng không thuộc ngôn ngữ ECMAScript}. The host (browser or Node) implements them in native code or separate threads and exposes callbacks back to your JS realm {Host (browser hoặc Node) implement chúng bằng native code hoặc thread riêng và expose callback về JS realm}.
Typical flow for a timer {Luồng điển hình cho timer}:
- JS calls
setTimeout(fn, 1000)— host registers a timer {JS gọisetTimeout(fn, 1000)— host đăng ký timer}. - JS keeps running; the stack is free {JS tiếp tục chạy; stack rảnh}.
- After ~1000 ms the host places
fnon a macrotask queue (task queue) {Sau ~1000 ms host đặtfnlên macrotask queue (task queue)}. - When the stack is empty and microtasks are drained, the event loop dequeues one macrotask and runs
fn{Khi stack rỗng và microtask đã drain, event loop dequeue một macrotask và chạyfn}.
The timer lives in Web APIs / Timers while waiting — not on the stack, not in a JS queue yet {Timer nằm ở Web APIs / Timers khi chờ — không trên stack, chưa trong queue JS}.
Two queues: macrotasks vs microtasks {Hai queue: macrotask vs microtask}
HTML and Node both distinguish tasks (macrotasks) and microtasks {HTML và Node đều phân biệt task (macrotask) và microtask}. The names differ in specs, but the drain order is what engineers memorize {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 |
Golden rule {Quy tắc vàng}: finish the current synchronous stack → drain the entire microtask queue → (optionally render) → take one macrotask → repeat {xong stack đồng bộ hiện tại → drain hết microtask queue → (có thể render) → lấy một macrotask → lặp}.
That is why microtasks are “between” macrotasks — they piggyback on the turn that just finished {Đó là lý do microtask nằm “giữa” macrotask — chúng đi kèm lượt vừa kết thúc}.
Event loop algorithm (browser-shaped) {Thuật toán event loop (theo hình browser)}
A simplified but accurate loop {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.
}
Rendering is not a separate “queue” you enqueue like setTimeout {Render không phải “queue” riêng bạn enqueue như setTimeout}. The browser inserts update the rendering steps at well-defined checkpoints — after microtasks, before the next task in many cases {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}.
Worked example: the classic ordering {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 {Trace}:
- Sync:
1, register timer, schedule microtask for3, sync4→ console1,4{Đồng bộ:1, đăng ký timer, schedule microtask cho3, đồng bộ4→ console1,4}. - Stack empty → drain microtasks →
3→ console3{Stack rỗng → drain microtask →3→ console3}. - Timer macrotask →
2→ console2{Macrotask timer →2→ console2}.
Final: 1, 4, 3, 2 {Kết quả: 1, 4, 3, 2}.
The 0 in setTimeout(..., 0) means “soonest macrotask slot”, not “before microtasks” {0 trong setTimeout(..., 0) nghĩa là “slot macrotask sớm nhất”, không phải “trước microtask”}.
Nested microtasks and queueMicrotask {Microtask lồng nhau và queueMicrotask}
Microtasks scheduled during a microtask run in the same drain phase {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');
Order: A, F, C, E, D, B {Thứ tự: A, F, C, E, D, B}.
After sync, microtask queue is [Promise callback, E] (FIFO) {Sau sync, microtask queue là [Promise callback, E] (FIFO)}. Running the Promise callback logs C and enqueues D before E is processed — but E was already queued, so order is C, then E, then D {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}. Only when the microtask queue is empty does the timer macrotask run {Chỉ khi microtask queue rỗng macrotask timer mới chạy}.
Interview trap {Bẫy phỏng vấn}: “Infinite
Promise.thenchain” starves macrotasks and rendering {ChuỗiPromise.thenvô hạn” đói macrotask và rendering}. Same class of bug as microtask starvation in production parsers {Cùng loại bug với microtask starvation trong parser production}.
async / await is sugar over Promises + microtasks {async / await là sugar trên Promise + microtask}
await does not block the thread {await không block thread}. It suspends the async function and schedules the remainder as a microtask when the awaited value settles {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 {Output: start, in foo, end, after await}.
Desugared roughly {Desugar gần đúng}:
function foo() {
return Promise.resolve().then(() => {
console.log('in foo');
return Promise.resolve();
}).then(() => {
console.log('after await');
});
}
Everything after await is a microtask continuation {Mọi thứ sau await là continuation microtask}. Callers keep running synchronously until they hit their own await or return {Caller tiếp tục chạy đồng bộ đến khi gặp await hoặc return}.
Macrotask sources you see every week {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 |
User input feels “slow” when macrotasks queue up behind long tasks {Input cảm giác “chậm” khi macrotask xếp hàng sau long task}. INP (Interaction to Next Paint) measures that gap — see the Core Web Vitals playbook for metrics, not loop mechanics {INP đo khoảng trống đó — xem bài Core Web Vitals cho metric, không phải cơ chế loop}.
Microtask sources beyond Promise.then {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 is why reading layout in a observer callback can still run before paint in the same frame turn — but you should not rely on that for measurement; use requestAnimationFrame or ResizeObserver patterns intentionally {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) is not zero milliseconds {setTimeout(0) không phải zero millisecond}
Browsers clamp nested timer delays {Browser clamp delay timer lồng nhau}. HTML specifies a minimum timeout (commonly 4 ms once nesting depth exceeds a threshold in modern engines) {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();
Implications {Hệ quả}:
setTimeout(fn, 0)is not a reliable “yield to browser” — microtasks and rendering may not run if you keep scheduling sync work {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}.- Prefer
queueMicrotaskonly when you need ordering before the next macrotask, not when you need paint {Chỉ dùngqueueMicrotaskkhi cần thứ tự trước macrotask tiếp, không phải khi cần paint}. - For “after layout” or “before paint”, use
requestAnimationFrame{Cho “sau layout” hoặc “trước paint”, dùngrequestAnimationFrame}.
Microtask starvation {Microtask starvation}
If each microtask schedules another, the loop never reaches the next macrotask or rendering step {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
Real-world variants {Biến thể thực tế}:
- Parsing huge JSON then chaining
.thentransforms in tight loops {Parse JSON lớn rồi chuỗi transform.thentrong vòng lặp chặt}. - Reactive libraries flushing hundreds of microtasks before yielding {Thư viện reactive flush hàng trăm microtask trước khi yield}.
- Accidental
while+awaitin async functions that still schedule microtasks faster than macrotasks {Vòngwhile+awaitvô tình trong async function vẫn schedule microtask nhanh hơn macrotask}.
Fix pattern {Pattern sửa}: chunk work with setTimeout(0), requestAnimationFrame, or scheduler.postTask (where available), and measure long tasks in Performance panel {Chia nhỏ work bằng setTimeout(0), requestAnimationFrame, hoặc scheduler.postTask (nếu có), và đo long task trong Performance panel}.
requestAnimationFrame vs the event loop {requestAnimationFrame vs event loop}
requestAnimationFrame (rAF) callbacks run before the next repaint, in the rendering phase — not in the microtask queue {Callback requestAnimationFrame (rAF) chạy trước repaint tiếp theo, trong phase rendering — không nằm microtask queue}.
Rough ordering in one frame {Thứ tự gần đúng trong một frame}:
macrotask(s) → microtasks (all) → rAF callbacks → style/layout → paint → rAF for next frame
Practical rules {Quy tắc thực hành}:
- DOM reads/writes: batch writes, read in rAF or after microtasks settle to avoid forced sync layout {Đọc/ghi DOM: gom write, đọc trong rAF hoặc sau khi microtask ổn để tránh forced sync layout}.
setTimeout(fn, 16)is a poor man’s animation loop — rAF tracks vsync and backs off in background tabs {setTimeout(fn, 16)là animation loop tạm — rAF theo vsync và giảm tần suất ở tab background}.- Double rAF pattern: first rAF waits for layout; second runs after paint — useful for measuring DOM {Pattern double rAF: rAF đầu chờ layout; cái thứ hai chạy sau paint — hữu ích đo DOM}.
Rendering, long tasks, and input {Render, long task, và input}
The browser needs idle-ish main thread windows to {Browser cần khoảng main thread tương đối rảnh để}:
- Run event loop turns {Chạy lượt event loop}
- Execute rAF {Chạy rAF}
- Compute style and layout {Tính style và layout}
- Paint and composite {Paint và composite}
- Deliver input events as macrotasks {Giao input event như macrotask}
A long task (>50 ms) blocks all of that {Long task (>50 ms) chặn tất cả}. Your Promise.then chain can be “async” in syntax but still synchronous CPU on the main thread between awaits that resolve immediately {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());
Split with macrotasks or Workers when work exceeds a frame budget {Chia bằng macrotask hoặc Workers khi work vượt budget một frame}.
Node.js: same queues, different host details {Node.js: cùng queue, chi tiết host khác}
Node’s event loop has phases (timers, poll, check, close callbacks) {Event loop Node có phase (timers, poll, check, close callbacks)}. Microtasks (process.nextTick and Promise jobs) drain between phases — process.nextTick runs before Promise microtasks in Node {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 engineers mostly live in the browser column; knowing Node ordering prevents copy-paste bugs in SSR scripts {Frontend engineer chủ yếu ở cột browser; biết thứ tự Node tránh bug copy-paste trong script SSR}.
Debugging checklist {Checklist debug}
- Log with labels —
console.log('sync', id)vs inside.then/setTimeout{Log có nhãn —console.log('sync', id)vs trong.then/setTimeout}. - Performance → Main — long yellow blocks = stack never yielded {Performance → Main — block vàng dài = stack không yield}.
- Break on
queueMicrotaskin DevTools when order surprises you {BreakpointqueueMicrotasktrong DevTools khi thứ tự lạ}. - Ask: Is this sync stack, microtask, macrotask, or rAF? {Hỏi: Đây là sync stack, microtask, macrotask, hay rAF?}.
Common mistakes (senior edition) {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 |
Takeaways {Tóm lại}
- One stack, strict order — sync to completion, then all microtasks, then one macrotask (then render hooks) {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)}.
- Promises and
awaitlive in the microtask queue — they beatsetTimeout(0){Promise vàawaitở microtask queue — thắngsetTimeout(0)}. - Timers are macrotasks with clamped delays — not zero, not before microtasks {Timer là macrotask với delay bị clamp — không phải zero, không trước microtask}.
- Starvation is real — unbounded microtasks block input and paint {Starvation có thật — microtask không giới hạn chặn input và paint}.
- rAF is for frames, not deferral — use the right queue for the job {rAF cho frame, không phải defer — dùng đúng queue cho việc}.
Use the visualizer above until predicting console order feels boring {Dùng visualizer trên đến khi đoán thứ tự console trở nên nhàm chán}. That is when the event loop stops being trivia and becomes a tool for designing responsive UIs and fair scheduling {Đó 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}.