Web Performance · Part 16 — INP Deep & Main-Thread Scheduling
Đào sâu INP: phân rã input delay / processing / presentation, Long Animation Frames (LoAF) để tìm thủ phạm, scheduler.postTask/yield, fallback đa trình duyệt, và Web Worker.
Phần 6 giới thiệu INP và cách tối ưu cơ bản. Phần này đi sâu vào cơ chế main thread quyết định INP, và bộ công cụ lập lịch hiện đại để giữ trang luôn phản hồi. INP (Interaction to Next Paint) là Core Web Vital khó nhất vì nó phản ánh sức khỏe của main thread suốt toàn bộ phiên — không chỉ lúc tải.
Mô hình nền: main thread đơn luồng (như đã biết). Mọi tương tác phải xếp hàng sau tác vụ đang chạy. INP quan sát độ trễ từ tương tác tới khung hình kế; với đa số page visit nó chọn tương tác chậm nhất, nhưng loại dần một outlier cao nhất cho mỗi 50 tương tác ở phiên rất dài.
1. Phân rã INP — ba phần
Một tương tác (click, tap, gõ phím) chia thành ba giai đoạn. Biết phần nào lớn → biết sửa ở đâu (dùng Attribution, Part 13).
Người dùng bấm Khung hình hiển thị kết quả
│ │
├── Input delay ──┬── Processing time ──┬── Presentation delay ┤
│ (main thread │ (chạy event │ (style/layout/paint │
│ đang bận) │ handler) │ cho khung kế) │
| Phần | Nguyên nhân | Cách sửa |
|---|---|---|
| Input delay | main thread bận lúc bấm (task dài, hydration) | chia nhỏ task, yield, ít JS khởi động |
| Processing | event handler nặng | tối ưu handler, debounce, đẩy sang worker |
| Presentation | layout/paint nặng sau handler | giảm DOM update, tránh reflow (Part 17) |
2. Long tasks & ngưỡng quan sát 50ms
Theo Long Tasks API, tác vụ chiếm main thread hơn 50ms được ghi nhận là long task. Trong lúc phần công việc đồng bộ đang chạy, browser không thể bắt đầu handler khác trên cùng main thread; phần vượt 50ms được xem là blocking time.
// Phát hiện long task trong RUM
if (PerformanceObserver.supportedEntryTypes.includes('longtask')) {
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) report('long-task', entry.duration);
}).observe({ type: 'longtask', buffered: true });
}
Mốc 50ms là ngưỡng quan sát, không phải lời hứa rằng task 49ms luôn cho INP tốt: processing của interaction và render kế tiếp vẫn cộng vào tổng. Mục tiêu thực tế là chia work theo time budget, tạo cơ hội cho input và rendering, rồi đo lại trên thiết bị mục tiêu.
3. Long Animation Frames (LoAF) — công cụ chẩn đoán mới
Long Animation Frames API bổ sung cho longtask: nó báo cáo khung hình dài (>50ms) kèm timing render và attribution script/hàm/nguồn khi browser có thể cung cấp. LoAF rất hữu ích cho INP nhưng chưa có coverage đồng đều giữa các engine; không nên bỏ đường chẩn đoán DevTools.
type LoAFEntry = PerformanceEntry & {
scripts?: Array<{
duration: number;
invoker: string;
sourceURL: string;
sourceFunctionName: string;
}>;
};
if (PerformanceObserver.supportedEntryTypes.includes('long-animation-frame')) {
new PerformanceObserver((list) => {
for (const loaf of list.getEntries() as LoAFEntry[]) {
for (const script of loaf.scripts ?? []) {
report('loaf-script', {
duration: script.duration,
invoker: script.invoker,
sourceURL: script.sourceURL,
sourceFunctionName: script.sourceFunctionName,
});
}
}
}).observe({ type: 'long-animation-frame', buffered: true });
}
LoAF cho biết:
- startTime, duration, blockingDuration của khung chậm
- renderStart / styleAndLayoutStart (tách thời gian script vs render)
- scripts[]: từng script với sourceURL + functionName + invoker
→ "khung này chậm 120ms vì hàm X trong vendor.js" — đủ để hành động.
Kết hợp LoAF (gắn script gây chậm) với INP Attribution (gắn tương tác) cho bức tranh đầy đủ. Đây là cách điều tra INP ở mức production.
4. Chia nhỏ tác vụ & yield
Cách trực tiếp nhất giảm input delay: chia tác vụ dài thành mảnh nhỏ và nhường (yield) main thread giữa chừng để xử lý input đang chờ.
// Yield theo time budget để input, rendering và code khác đều có cơ hội chạy.
async function processLargeList(items: Item[]) {
let deadline = performance.now() + 8;
for (const item of items) {
process(item);
if (performance.now() >= deadline) {
await yieldToMain();
deadline = performance.now() + 8;
}
}
}
// Fallback yield khi chưa có scheduler.yield
type TaskPriority = 'user-blocking' | 'user-visible' | 'background';
type BrowserScheduler = {
yield?: () => Promise<void>;
postTask?: <T>(
callback: () => T | PromiseLike<T>,
options?: { priority?: TaskPriority; signal?: AbortSignal }
) => Promise<T>;
};
const browserScheduler = (
globalThis as typeof globalThis & { scheduler?: BrowserScheduler }
).scheduler;
const yieldToMain = (): Promise<void> =>
browserScheduler?.yield
? browserScheduler.yield()
: new Promise((r) => setTimeout(r, 0));
scheduler.yield() giữ continuation ở vị trí ưu tiên tốt hơn fallback setTimeout, nhưng chưa có ở mọi browser nên cần nhánh fallback. navigator.scheduling.isInputPending() từng được dùng để chỉ yield khi có input; hướng dẫn hiện tại không còn khuyên conditional yielding đơn độc vì rendering và công việc từ frame/cross-origin khác cũng cần cơ hội chạy. Time budget nhỏ, có đo đạc, là mặc định an toàn hơn.
5. scheduler.postTask — lập lịch theo ưu tiên
scheduler.postTask cho phép xếp việc với ba mức ưu tiên rõ ràng, thay cho setTimeout (vốn không có khái niệm ưu tiên). API này cũng cần feature detection/fallback.
// user-blocking: gấp, chặn người dùng (cao nhất)
// user-visible: bình thường (mặc định)
// background: không gấp (thấp nhất)
if (browserScheduler?.postTask) {
void browserScheduler.postTask(() => renderCriticalUI(), { priority: 'user-blocking' });
void browserScheduler.postTask(() => prefetchData(), { priority: 'background' });
// postTask trả Promise; abort sẽ reject Promise đó nên phải xử lý rejection.
const controller = new AbortController();
const pending = browserScheduler.postTask(work, { signal: controller.signal });
controller.abort();
void pending.catch((error) => {
if (!(error instanceof DOMException && error.name === 'AbortError')) console.error(error);
});
} else {
renderCriticalUI();
setTimeout(prefetchData, 0);
}
Chọn công cụ lập lịch:
cập nhật UI do tương tác → postTask('user-blocking') / chạy ngay
việc nền không gấp → postTask('background'); idle callback khi thật sự tùy chọn
chia tác vụ dài + giữ phản hồi → vòng lặp có time budget + scheduler.yield/fallback
tính toán thật nặng/độc lập DOM → Web Worker (mục 6)
6. Đẩy việc nặng sang Web Worker
Nếu tính toán nặng và không cần DOM (parse, sort lớn, xử lý ảnh, mã hóa), đẩy sang Web Worker để phần compute không chiếm main thread. Main thread vẫn trả chi phí serialize/transfer, message handler và cập nhật UI. Comlink giúp bọc message protocol thành lời gọi hàm.
// worker.ts
import * as Comlink from 'comlink';
Comlink.expose({ heavySort: (data: number[]) => data.sort((a, b) => a - b) });
// main.ts
import * as Comlink from 'comlink';
const api = Comlink.wrap<{ heavySort(d: number[]): Promise<number[]> }>(
new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' })
);
const sorted = await api.heavySort(bigArray); // compute chạy off-main-thread
Cân nhắc chi phí: chuyển dữ liệu sang worker tốn (structured clone) — với Comlink, dùng
Comlink.transfer(value, [buffer])cho buffer lớn có thể chuyển quyền sở hữu thay vì copy. Worker đáng giá khi tính toán đủ nặng để bù chi phí truyền. Việc nhẹ thìscheduler.yieldđủ.
7. Tóm tắt
- INP phân rã thành input delay / processing / presentation; dùng Attribution + LoAF để biết phần nào lớn.
- Long task (>50ms) là tín hiệu main thread bị chiếm; task ngắn hơn 50ms vẫn có thể góp vào INP xấu, nên đo cả interaction + render.
- Long Animation Frames (LoAF) bổ sung timing + script attribution cho khung chậm trên browser hỗ trợ; feature-detect và giữ fallback DevTools.
- Chia tác vụ dài theo time budget +
scheduler.yield()để nhường main thread cho input/rendering; fallbacksetTimeout(0). scheduler.postTasklập lịch theo ưu tiên (user-blocking/user-visible/background), hủy được quaAbortController; vẫn cần fallback đa trình duyệt.- Đẩy tính toán nặng độc-lập-DOM sang Web Worker (Comlink + transferable), đồng thời tính cả chi phí truyền dữ liệu và cập nhật UI.
Phần tiếp theo: Rendering pipeline & CSS performance — forced reflow/layout thrashing, content-visibility, CSS containment, kỷ luật will-change, và animation chỉ chạm compositor.