jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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 / libuvqueue để 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}.

ConceptMeaning
Call stackCurrently executing synchronous frames
Run-to-completionOne frame runs entirely before another starts
Main threadWhere 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}:

  1. JS calls setTimeout(fn, 1000) — host registers a timer {JS gọi setTimeout(fn, 1000) — host đăng ký timer}.
  2. JS keeps running; the stack is free {JS tiếp tục chạy; stack rảnh}.
  3. After ~1000 ms the host places fn on a macrotask queue (task queue) {Sau ~1000 ms host đặt fn lên macrotask queue (task queue)}.
  4. 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ạy fn}.

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ớ}.

QueueCommon sourcesWhen it runs
Macrotask (task)setTimeout, setInterval, I/O callbacks, postMessage, user events (click, input)One per event-loop turn (after microtasks from prior turn)
MicrotaskPromise.then / catch / finally, queueMicrotask, MutationObserverAfter 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}:

  1. Sync: 1, register timer, schedule microtask for 3, sync 4 → console 1, 4 {Đồng bộ: 1, đăng ký timer, schedule microtask cho 3, đồng bộ 4 → console 1, 4}.
  2. Stack empty → drain microtasks → 3 → console 3 {Stack rỗng → drain microtask → 3 → console 3}.
  3. Timer macrotask → 2 → console 2 {Macrotask timer → 2 → console 2}.

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.then chain” starves macrotasks and rendering {Chuỗi Promise.then vô 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}

SourceNotes
setTimeout / setIntervalTimer callback after host fires
DOM eventsClick, input, load — one task per dispatch (with coalescing rules)
MessageChannel / postMessageUsed by React 18 scheduler, many libs
requestIdleCallbackLow-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}

APITypical use
Promise reactionsAny .then / .catch / .finally
queueMicrotask(fn)Explicit microtask — prefer over Promise.resolve().then(fn) when you mean it
MutationObserverDOM mutations batched, callback runs as microtask
awaitContinuation 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 queueMicrotask only when you need ordering before the next macrotask, not when you need paint {Chỉ dùng queueMicrotask khi 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ùng requestAnimationFrame}.

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 .then transforms in tight loops {Parse JSON lớn rồi chuỗi transform .then trong 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 + await in async functions that still schedule microtasks faster than macrotasks {Vòng while + await vô 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}.

EnvironmentExtra nuance
BrowserOne task → all microtasks → render step
Nodeprocess.nextTick queue drains before Promise microtasks between phases
WorkerSeparate 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}

  1. Log with labelsconsole.log('sync', id) vs inside .then / setTimeout {Log có nhãnconsole.log('sync', id) vs trong .then / setTimeout}.
  2. Performance → Main — long yellow blocks = stack never yielded {Performance → Main — block vàng dài = stack không yield}.
  3. Break on queueMicrotask in DevTools when order surprises you {Breakpoint queueMicrotask trong DevTools khi thứ tự lạ}.
  4. 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)}

MistakeReality
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 .thenStill microtask — same turn if chained
”MutationObserver is sync”Callback is microtask — after current task, before next macrotask

Takeaways {Tóm lại}

  1. 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)}.
  2. Promises and await live in the microtask queue — they beat setTimeout(0) {Promise và await ở microtask queue — thắng setTimeout(0)}.
  3. 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}.
  4. 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}.
  5. 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}.