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.

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

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 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:

  1. JS gọi setTimeout(fn, 1000) — host đăng ký timer.
  2. JS tiếp tục chạy; stack rảnh.
  3. Sau ~1000 ms host đặt fn lên macrotask queue (task queue).
  4. 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ớ.

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

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:

  1. Đồng bộ: 1, đăng ký timer, schedule microtask cho 3, đồng bộ 4 → console 1, 4.
  2. Stack rỗng → drain microtask → 3 → console 3.
  3. Macrotask timer → 2 → console 2.

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.then vô 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

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

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

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 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 queueMicrotask khi 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 .then trong vòng lặp chặt.
  • Thư viện reactive flush hàng trăm microtask trước khi yield.
  • Vòng while + await vô 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.

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 engineer chủ yếu ở cột browser; biết thứ tự Node tránh bug copy-paste trong script SSR.


Checklist debug

  1. Log có nhãnconsole.log('sync', id) vs trong .then / setTimeout.
  2. Performance → Main — block vàng dài = stack không yield.
  3. Breakpoint queueMicrotask trong DevTools khi thứ tự lạ.
  4. Hỏi: Đây là sync stack, microtask, macrotask, hay rAF?.

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

Tóm lại

  1. 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. Promise và await ở microtask queue — thắng setTimeout(0).
  3. Timer là macrotask với delay bị clamp — không phải zero, không trước microtask.
  4. Starvation có thật — microtask không giới hạn chặn input và paint.
  5. 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.