Web Performance · Part 6 — Optimizing INP
Janky clicks mean the main thread is blocked. Chunk long tasks, yield with scheduler.yield/setTimeout, split urgent from non-urgent updates, and offload heavy computation to Web Workers.
INP (≤ 200ms) đo độ trễ từ khi người dùng tương tác tới khung hình kế tiếp. Yếu tố chi phối thường là main thread bị chặn hoặc render quá nặng: input phải chờ task hiện tại, handler chạy, rồi trình duyệt còn phải style/layout/paint trước khi phản hồi xuất hiện.
1. Long task — kẻ thù số một của INP
Long task là tác vụ chiếm main thread trên 50ms. Trong lúc đó, click/gõ phím mới phải xếp hàng chờ — đó chính là “input delay” trong INP. Phát hiện long task:
// Long Tasks API is useful but not available in every browser.
if (PerformanceObserver.supportedEntryTypes.includes('longtask')) {
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.warn(`Long task ${entry.duration.toFixed(0)}ms`, entry);
}
}).observe({ type: 'longtask', buffered: true });
}
Ba nguồn long task phổ biến: bundle JS lớn chạy lúc khởi động (Phần 8), event handler nặng, và re-render lớn (render hàng nghìn node — dùng ảo hóa, series thuật toán Phần 4).
2. Phản hồi ngay, làm việc nặng sau (RAIL: Response)
Nguyên tắc RAIL: phản hồi tương tác trong < 100ms. Nếu việc thật cần lâu, hãy cập nhật UI ngay (cho người dùng thấy phản hồi), kết thúc task hiện tại, rồi làm phần nặng ở task sau:
type SchedulerWithYield = { yield?: () => Promise<void> };
function yieldToMain(): Promise<void> {
const scheduler = (
globalThis as typeof globalThis & { scheduler?: SchedulerWithYield }
).scheduler;
return scheduler?.yield
? scheduler.yield()
: new Promise((resolve) => setTimeout(resolve, 0));
}
button.addEventListener('click', async () => {
const originalLabel = button.textContent;
button.disabled = true;
button.textContent = 'Đang xử lý…';
// End the interaction task and give input/rendering a chance to run.
await yieldToMain();
try {
await doHeavyWork();
} finally {
button.disabled = false;
button.textContent = originalLabel;
}
});
Điểm mấu chốt: INP đo tới next paint, nên tách phần nặng sang task sau tạo cơ hội để trạng thái phản hồi được render trước. requestAnimationFrame đơn lẻ không đủ cho mẹo này: callback của nó chạy trước paint, và microtask tiếp tục sau await vẫn có thể chặn chính frame đó.
3. Chia nhỏ long task bằng “yield”
Khi phải làm nhiều việc trên main thread, hãy chia thành khối và trả luồng (yield) giữa các khối để trình duyệt xử lý input + vẽ. scheduler.yield() giữ continuation ở mức ưu tiên phù hợp, nhưng chưa có trên mọi trình duyệt nên dùng helper fallback ở trên:
async function processLargeList(items: Item[]) {
let lastYield = performance.now();
for (let i = 0; i < items.length; i++) {
processItem(items[i]);
// Item cost varies, so yield by elapsed time rather than item count.
if (performance.now() - lastYield >= 40) {
await yieldToMain();
lastYield = performance.now();
}
}
}
scheduler.yield() có ưu điểm: continuation được ưu tiên hơn task cùng loại đang chờ; fallback setTimeout vẫn nhường main thread nhưng continuation đi về cuối task queue. Mốc 40ms chỉ là điểm bắt đầu — điều chỉnh theo workload và trace thực tế.
Đây chính là time-slicing ở series thuật toán (Phần 8) áp dụng cho INP: chia việc + nhường luồng để giữ trang phản hồi.
4. Tách cập nhật khẩn và không khẩn (React)
Không phải cập nhật nào cũng cần ngay. Khi gõ vào ô lọc, việc hiện ký tự vừa gõ là khẩn, còn render kết quả thì có thể trễ một nhịp. React cho useTransition/useDeferredValue để đánh dấu cập nhật không khẩn, giữ input phản hồi trước:
import { useDeferredValue, useMemo, useState } from 'react';
function SearchableList({ items }: { items: Item[] }) {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query); // "delayed" version for heavy work
const isStale = query !== deferredQuery;
// filter uses the deferred value → doesn't block showing the typed character
const filtered = useMemo(
() => items.filter((i) => i.name.includes(deferredQuery)),
[items, deferredQuery],
);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} /> {/* urgent: update immediately */}
<div style={{ opacity: isStale ? 0.7 : 1 }}>
<List items={filtered} /> {/* non-urgent: may lag one beat */}
</div>
</>
);
}
Concurrent rendering không biến một phép tính đồng bộ khổng lồ thành miễn phí. Nếu riêng filter() đã chiếm hàng trăm mili-giây, hãy chunk/worker/index dữ liệu; useDeferredValue chủ yếu giúp React ưu tiên và có thể ngắt phần render không khẩn.
5. Đẩy tính toán nặng sang Web Worker
Time-slicing giúp việc phải ở main thread (đụng DOM). Nhưng tính toán thuần (parse dữ liệu lớn, xử lý ảnh, tìm kiếm phức tạp) nên rời main thread bằng Web Worker — phép tính không chạy trong task UI, dù CPU contention và chi phí truyền dữ liệu vẫn cần đo.
// worker.ts — include an id so concurrent calls don't overwrite each other
self.onmessage = (e: MessageEvent<{ id: number; data: number[] }>) => {
const { id, data } = e.data;
self.postMessage({ id, result: heavyCompute(data) });
};
// main thread — only send data and receive results
const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
const pending = new Map<number, (result: number[]) => void>();
let nextId = 0;
worker.onmessage = (e: MessageEvent<{ id: number; result: number[] }>) => {
pending.get(e.data.id)?.(e.data.result);
pending.delete(e.data.id);
};
function compute(data: number[]): Promise<number[]> {
return new Promise((resolve) => {
const id = nextId++;
pending.set(id, resolve);
worker.postMessage({ id, data });
});
}
Lưu ý: dữ liệu gửi qua worker bị sao chép (structured clone) — với dữ liệu lớn, dùng
Transferable(ArrayBuffer) để chuyển quyền sở hữu thay vì copy. Worker hợp nhất với tác vụ CPU-bound chạy lâu, không hợp việc cần đụng DOM thường xuyên.
6. Giảm chi phí của chính event handler
- Debounce/throttle phần việc không khẩn phát sinh từ input/scroll; đừng debounce việc hiển thị chính ký tự người dùng vừa gõ.
- Tránh layout thrashing trong handler (đọc/ghi layout xen kẽ) — đọc gom, ghi gom.
- Giảm phạm vi re-render: memo hóa, key ổn định, ảo hóa danh sách lớn.
- Hoãn việc phụ (analytics, log) ra khỏi đường tương tác; dùng task sau, beacon hoặc background priority phù hợp thay vì giữ handler mở.
Tóm tắt
- INP tệ thường do main thread bị chặn hoặc render nặng; long task trên 50ms là tín hiệu chẩn đoán quan trọng.
- Phản hồi ngay, làm nặng sau (vẽ trạng thái trước khi chạy việc nặng) kéo INP xuống.
- Chia nhỏ + yield (
scheduler.yield()với fallbacksetTimeout) để input/paint chen vào — time-slicing. - React:
useDeferredValue/useTransitiontách cập nhật khẩn và không khẩn. - Tính toán CPU-bound → Web Worker (dùng
Transferablecho dữ liệu lớn).
Phần tiếp theo: Tối ưu CLS — đặt chỗ trước cho ảnh/quảng cáo/font để bố cục không nhảy.
Đọc sâu hơn ở Phần 16: phân rã INP, Long Animation Frames, scheduler.postTask() và chiến lược scheduling production.