jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

How Browsers Work · Part 7 — The Event Loop, Tasks & Microtasks

The model that ties JS to rendering: task sources and queues, microtask checkpoints, promises, rAF, long tasks, and cooperative scheduling.

Phần 1 và 5 đã nêu JavaScript và render dùng chung một luồng chính trong mỗi tiến trình renderer. Event loop là bộ lập lịch quyết định cái gì chạy tiếp trên luồng đó — và do đó trang có paint, phản hồi input, hay đứng im.

Nếu V8 (Phần 6) là cách JS thực thi, event loop là khi nào — và là cầu nối giữa callback async, promise, và các bước render từ Phần 5.

┌──────────────────────────────────────────────┐
│              Main thread (one stack)          │
│  ┌─────────────────────────────────────────┐ │
│  │  Run one task selected from task queues │ │
│  └──────────────────┬──────────────────────┘ │
│                     ▼                          │
│  ┌─────────────────────────────────────────┐ │
│  │  Drain microtask queue (all of it)      │ │
│  └──────────────────┬──────────────────────┘ │
│                     ▼                          │
│  ┌─────────────────────────────────────────┐ │
│  │  Rendering steps (if needed this frame) │ │
│  │  → rAF callbacks → style/layout/paint   │ │
│  └──────────────────┬──────────────────────┘ │
│                     ▼                          │
│                 Next task…                     │
└──────────────────────────────────────────────┘

1. Một luồng, nhiều nguồn việc

Trình duyệt đưa việc tới event loop từ nhiều nơi: input người dùng, timer, networking, postMessage, và hơn nữa. Mỗi event loop có nhiều task queue theo task source, không phải một FIFO toàn cục; user agent được chọn queue nhưng phải giữ thứ tự trong cùng source. JavaScript trên main thread vẫn run-to-completion: một stack chạy tại một thời điểm.

Công việc event loop đơn giản đến lừa đảo:

  1. Chọn một task runnable từ task queue và chạy tới hết.
  2. Thực hiện microtask checkpoint, drain queue tới khi rỗng (trừ edge case spec đặc biệt).
  3. Khi có rendering opportunity, chạy thuật toán update the rendering, trong đó có callback requestAnimationFrame.
  4. Lặp lại.

Bước 1–2 có thể xảy ra nhiều lần giữa hai frame; rendering opportunity bám refresh rate/visibility và có thể bị bỏ lỡ khi main thread bận.


2. Macrotask (task)

Macrotask là thuật ngữ cộng đồng; HTML Standard gọi đơn giản là task. Nguồn task phổ biến:

SourceVí dụ
DOM eventsclick, input, keydown handlers
TimerssetTimeout, setInterval callbacks
NetworkNetwork completion tiến fetch algorithm; promise reaction sau đó là microtask
I/Omessage event from postMessage, some WebSocket handlers
Navigationpageshow, history changes

Mỗi task chạy tới khi call stack unwind — không preempt giữa function. Handler click 200 ms chặn mọi thứ xếp hàng sau nó trong 200 ms.


3. Microtask: promise và bạn bè

Microtask checkpoint thường diễn ra sau mỗi task khi stack rỗng và ở một số điểm spec khác. Checkpoint chạy microtask tới khi queue rỗng; microtask mới enqueue tiếp vẫn chạy trong cùng checkpoint.

  • Promise.then / catch / finally và continuation await.
  • queueMicrotask().
  • Callback MutationObserver (được notify trong microtask checkpoint, không chạy đồng bộ ngay tại mutation).

Microtask chạy trước macrotask kế tiếp và trước render cho cơ hội khung hình đó. Đó là lý do “sau handler click này, mọi chuỗi promise xong trước click kế tiếp” — và vì sao vòng lặp Promise.resolve().then(() => enqueue again) vô hạn đói render.


4. Thứ tự: ví dụ đi từng bước

Xét script này:

console.log('1 sync');

setTimeout(() => console.log('2 timeout'), 0);

Promise.resolve()
  .then(() => console.log('3 microtask A'))
  .then(() => console.log('4 microtask B'));

queueMicrotask(() => console.log('5 queueMicrotask'));

console.log('6 sync');

Output chính xác:

1 sync
6 sync
3 microtask A
5 queueMicrotask
4 microtask B
2 timeout

Vì sao:

  1. 1 sync6 sync chạy đồng bộ trong lần thực thi script ban đầu (task đầu tiên).
  2. setTimeout lên lịch macrotask; PromisequeueMicrotask enqueue microtask.
  3. Khi task đầu xong, event loop xả mọi microtask theo thứ tự: 3, rồi 5, rồi 4 (chuỗi từ 3).
  4. Chỉ sau đó macrotask kế chạy: 2 timeout.

Quy tắc ngón cái: task/sync → microtask checkpoint → (có thể update rendering) → task kế → lặp. Đây là mental model; HTML event-loop processing model có nhiều task source và bước chi tiết hơn.


5. Render nằm ở đâu

Giữa các macrotask, trình duyệt có thể chạy giai đoạn “update the rendering” (thuật ngữ spec HTML) — đại khái mỗi lần refresh màn hình, không phải sau mọi task. Giai đoạn đó gồm:

  1. Cập nhật state cần cho resize/scroll/media query/CSS animation theo thứ tự spec.
  2. Callback requestAnimationFrame.
  3. Tiếp tục style/layout, paint và submit frame khi cần. Engine có thể flush style/layout trước hoặc trong callback nếu thuật toán/API đọc geometry yêu cầu.

Thứ tự then chốt: microtask chạy trước rAF và trước paint cho một cơ hội khung hình. Nếu chuỗi promise mutate DOM, mutation hiện với callback rAF cùng khung — nhưng chuỗi microtask dài có thể đẩy rAF qua deadline khung.

Promise.resolve().then(() => {
  element.style.transform = 'translateX(100px)'; // microtask — before rAF
});

requestAnimationFrame(() => {
  // Reading geometry observes the new style; browser may flush style here.
  console.log(element.getBoundingClientRect().left);
});

6. Long task và độ trễ input

Vì task không bị preempt giữa chừng, Long Tasks API báo main-thread task có duration từ khoảng 50 ms trở lên. Ngưỡng này là ngưỡng quan sát, không phải “task 49 ms thì ổn”. Trong lúc task dài chạy:

  • Sự kiện input xếp hàng nhưng handler không chạy.
  • Bước render bị bỏ qua hoặc trễ.
  • Trang cảm giác đóng băng — nguyên nhân gốc luồng chính đằng sau INP (Interaction to Next Paint) kém.

Cách sửa theo mô hình: giảm work, nhường main thread giữa các chunk, hoặc chuyển tính toán phù hợp sang Worker. Mục tiêu là tạo cơ hội cho input/render chạy; không phải ép mọi task dưới đúng 16,7 ms trong mọi hoàn cảnh.


7. API lập lịch: khi nào dùng gì

setTimeout(fn, 0) và clamp tối thiểu

setTimeout(..., 0) không chạy ngay — callback chỉ thành task runnable sau delay và phải chờ event loop chọn. Theo HTML, sau năm lần timer lồng nhau, delay dưới 4 ms bị clamp thành ít nhất 4 ms; tab nền có thể bị throttle mạnh hơn. Dùng để nhường sang task sau với semantics timer, không để đồng bộ visual với refresh.

requestAnimationFrame

Cho animation/update visual bám rendering opportunity, rAF là hook đúng. Callback có thể mutate DOM trước browser hoàn tất frame; geometry read có thể force style/layout ngay trong callback. rAF không hứa callback nào cũng dẫn tới paint, nhất là khi document bị ẩn/throttled.

requestIdleCallback

requestIdleCallback lên lịch việc trong idle period, với deadline ước lượng còn bao nhiêu thời gian. Không dùng cho công việc phải hoàn thành đúng hạn nếu không đặt timeout, và API chưa có hỗ trợ đồng đều; feature-detect hoặc dùng scheduler/task fallback theo browser target.

scheduler.yield()scheduler.postTask()

Bổ sung Prioritized Task Scheduling API cho phép nhường hợp tác:

  • scheduler.yield() — tạm dừng async function, để trình duyệt xử lý việc ưu tiên cao hơn (input, render), rồi tiếp tục.
  • scheduler.postTask() — lên lịch task với ưu tiên rõ (user-blocking, user-visible, background).
async function processChunked(items, chunkSize = 50) {
  for (let i = 0; i < items.length; i += 1) {
    const item = items[i];
    heavyWork(item);
    if ((i + 1) % chunkSize === 0) {
      if (globalThis.scheduler?.yield) await globalThis.scheduler.yield();
      else await new Promise((resolve) => setTimeout(resolve, 0));
    }
  }
}

scheduler.yield() continuation được lên lịch như prioritized task và API vẫn cần feature detection; scheduler.postTask() thêm priority và cancellation. Đừng yield sau từng item rất nhỏ vì scheduling cũng có overhead. Hướng dẫn web.dev hiện cũng không còn khuyến nghị isInputPending() như điều kiện duy nhất để yield: render/animation cũng cần cơ hội chạy dù chưa có input.


Tóm tắt

  • Event loop chọn task từ nhiều task queue/source, rồi thực hiện microtask checkpoint; rendering chỉ chạy khi có opportunity.
  • Microtask (promise, queueMicrotask, MutationObserver) luôn chạy trước macrotask kế và trước paint.
  • Mental model: task/sync → microtask checkpoint → (update rendering, gồm rAF) → task kế.
  • Long task (≥50 ms theo Long Tasks API) chặn input và render; giảm work, chunk/yield hoặc dùng Worker khi phù hợp.
  • Dùng rAF cho visual, idle callback cho best-effort idle work, và feature-detect scheduler.yield()/postTask() cho cooperative scheduling.

Phần tiếp theo: Bộ nhớ và garbage collection — V8/Oilpan quản lý object graph thế nào, vì sao leak xảy ra trong SPA, và DevTools cho bạn thấy gì.