Timeline biểu diễn main thread yielding
Web Performance Deep Dive

INP dưới 200ms: scheduler.yield(), startTransition, và nghệ thuật nhường Main Thread

Interaction to Next Paint (INP) thay thế FID làm Core Web Vital từ tháng 3/2024, và nó khó hơn FID rất nhiều. FID chỉ đo input delay của interaction đầu tiên. INP đo toàn bộ latency — từ lúc user click đến lúc browser paint xong — của mọi interaction, rồi lấy giá trị xấu nhất (gần đúng). Nghĩa là bạn không thể chỉ optimize first load nữa.

Anatomy của một interaction

Để optimize INP, trước tiên phải hiểu browser xử lý một interaction thế nào. Khi user click một button, 3 giai đoạn xảy ra:

Input delay — thời gian từ lúc user click đến lúc event handler bắt đầu chạy. Nếu main thread đang bận (long task), input delay tăng.

Processing time — thời gian event handler chạy. Đây là phần code của bạn.

Presentation delay — thời gian từ lúc handler xong đến lúc browser paint frame mới lên màn hình. Bao gồm style recalculation, layout, paint, composite.

User click
    │
    ├─ Input delay ───────  (main thread đang bận?)
    │                    │
    ├─ Processing time ──┤  (event handler chạy)
    │                    │
    ├─ Presentation ─────┘  (browser render)
    │
Next Paint visible

INP = tổng cả 3. Mục tiêu: dưới 200ms.

Kẻ thù số 1: Long Tasks

Bất kỳ task nào chiếm main thread trên 50ms đều là "long task." Nó gây input delay cho interaction tiếp theo, vì browser không thể xử lý input trong khi đang chạy JavaScript.

Vấn đề thường gặp trong React app:

// ❌ Long task: filter + sort + re-render cùng lúc
function handleFilterChange(filter: string) {
  const filtered = flights.filter(f => f.route.includes(filter));  // 20ms
  const sorted = filtered.sort((a, b) => a.price - b.price);       // 15ms
  setFlights(sorted);  // Trigger re-render 200 components → 80ms
  // Tổng: ~115ms blocking main thread
}

User gõ vào filter input, mỗi keystroke trigger 115ms blocking. INP sẽ vượt 200ms ngay.

Giải pháp 1: startTransition — Low-hanging fruit

startTransition báo React rằng state update này không urgent — browser có thể ngắt render giữa chừng để xử lý input mới.

import { startTransition, useState } from 'react';

function handleFilterChange(filter: string) {
  // Urgent: update input value ngay
  setInputValue(filter);

  // Non-urgent: filter + re-render có thể chờ
  startTransition(() => {
    const filtered = flights.filter(f => f.route.includes(filter));
    const sorted = filtered.sort((a, b) => a.price - b.price);
    setFlights(sorted);
  });
}

Với startTransition, React sẽ:

  1. Update input value ngay lập tức (user thấy text mình gõ)
  2. Bắt đầu render filtered list, nhưng nếu user gõ tiếp thì interrupt render cũ, bắt đầu render mới
  3. Không block main thread liên tục — React tự yield giữa các fiber units

💡 startTransition chỉ hoạt động với React state updates. Nó không giúp gì nếu bottleneck là non-React code (DOM manipulation, third-party library, v.v.)

Giải pháp 2: scheduler.yield() — Nhường thread chủ động

scheduler.yield() là API mới (Chrome 129+) cho phép bạn chủ động nhường main thread, nhưng task của bạn được đặt ở đầu hàng đợi — không phải cuối như setTimeout(0).

async function processLargeDataset(items: FlightData[]) {
  const results: ProcessedFlight[] = [];

  for (let i = 0; i < items.length; i++) {
    results.push(processItem(items[i]));

    // Mỗi 5 items, nhường thread cho browser xử lý input/paint
    if (i % 5 === 4) {
      await scheduler.yield();
    }
  }

  return results;
}

Tại sao không dùng setTimeout(0) thay thế? Vì setTimeout đặt continuation ở cuối task queue. Nếu có nhiều pending tasks khác, code của bạn phải chờ lâu hơn. scheduler.yield() đảm bảo continuation chạy ngay sau khi browser xử lý xong pending input events.

// So sánh yielding strategies

// ❌ setTimeout — continuation bị đẩy xuống cuối queue
await new Promise(resolve => setTimeout(resolve, 0));

// ⚠️ requestAnimationFrame — phải chờ đến frame tiếp theo (~16ms)
await new Promise(resolve => requestAnimationFrame(resolve));

// ✅ scheduler.yield() — continuation ưu tiên cao
await scheduler.yield();

Feature detection và fallback

async function yieldToMain() {
  if ('scheduler' in globalThis && 'yield' in scheduler) {
    await scheduler.yield();
  } else {
    // Fallback: setTimeout vẫn tốt hơn không yield
    await new Promise(resolve => setTimeout(resolve, 0));
  }
}

Giải pháp 3: Tránh layout thrashing

Presentation delay tăng khi browser phải recalculate layout nhiều lần. Layout thrashing xảy ra khi code đọc geometry → viết style → đọc geometry → viết style liên tục.

// ❌ Layout thrashing
items.forEach(item => {
  const height = item.element.offsetHeight;     // Force layout (read)
  item.element.style.width = height * 2 + 'px'; // Invalidate layout (write)
  // Vòng tiếp theo: offsetHeight force layout LẠI
});

// ✅ Batch reads, then batch writes
const heights = items.map(item => item.element.offsetHeight);  // All reads
items.forEach((item, i) => {
  item.element.style.width = heights[i] * 2 + 'px';           // All writes
});

Trong React, layout thrashing ít xảy ra vì React batch DOM updates. Nhưng nếu bạn dùng useLayoutEffect hoặc đọc DOM trực tiếp trong event handlers, vẫn có thể gặp.

Giải pháp 4: CSS Containment giảm render scope

Khi một phần của page thay đổi, browser mặc định phải kiểm tra layout/paint impact lên toàn bộ page. CSS containment giới hạn scope này.

/* Flight card trong danh sách — thay đổi bên trong
   không ảnh hưởng layout bên ngoài */
.flight-card {
  contain: layout style paint;
  content-visibility: auto;
  contain-intrinsic-size: 0 180px;
}

content-visibility: auto đặc biệt mạnh cho danh sách dài — browser skip render cho items ngoài viewport hoàn toàn, giảm presentation delay đáng kể.

⚠️ contain: layout nghĩa là element tạo containing block mới. Nếu bạn dùng position: absolute bên trong dựa vào ancestor ở ngoài, nó sẽ break. Test kỹ trước khi apply.

Đo INP trong production

Dùng web-vitals library để collect INP từ real users:

import { onINP } from 'web-vitals';

onINP((metric) => {
  // Gửi về analytics
  sendToAnalytics({
    name: 'INP',
    value: metric.value,
    rating: metric.rating,  // 'good' | 'needs-improvement' | 'poor'
    // Attribution cho biết interaction nào gây INP cao
    attribution: metric.attribution,
  });
});

metric.attribution cực kỳ hữu ích — nó cho biết element nào được interact, event type gì, và breakdown input delay / processing / presentation.

Checklist tối ưu INP

Khi gặp INP cao, debug theo thứ tự: kiểm tra long tasks bằng DevTools Performance panel, xác định interaction nào có INP cao nhất qua RUM data, tách processing nặng bằng startTransition (React) hoặc scheduler.yield() (vanilla JS), kiểm tra layout thrashing trong event handlers, apply CSS containment cho danh sách dài, và cuối cùng consider virtualization nếu render quá nhiều DOM nodes.

Quan trọng nhất — INP là metric từ real users. Lab tools (Lighthouse) không đo INP đúng vì nó cần real interactions. Phải có RUM monitoring để biết INP thực tế.


Có câu hỏi hay góp ý? Reach out trên Twitter hoặc GitHub.