Tối ưu INP với scheduler.yield() — Nâng tầm Web Responsiveness 2026

Posted on: 4/17/2026 5:15:33 PM

Bạn click vào nút "Thêm vào giỏ hàng" trên một trang e-commerce, nhưng phải đợi gần 1 giây mới thấy phản hồi. Trang không lag, không lỗi — nhưng cảm giác chậm khiến bạn mất kiên nhẫn. Đó chính xác là vấn đề mà Interaction to Next Paint (INP) đo lường, và kể từ tháng 3/2024, Google đã chính thức thay thế FID bằng INP làm Core Web Vital cho responsiveness.

Bài viết này đi sâu vào cơ chế hoạt động của INP, phân tích từng phase gây delay, và đặc biệt là scheduler.yield() — API mới của trình duyệt giúp bạn chia nhỏ long tasks mà không mất quyền ưu tiên trên main thread. Kèm theo là các pattern tối ưu cụ thể cho Vue.js và .NET backend rendering.

200ms Ngưỡng INP "Good" theo Google
96% Interactions bị bỏ qua bởi FID cũ
100x scheduler.yield() nhanh hơn setTimeout workaround
50ms Ngưỡng Long Task cần chia nhỏ

FID đã chết — Tại sao INP thay thế?

First Input Delay (FID) chỉ đo delay của lần tương tác đầu tiên. Nếu trang load xong và lần click đầu diễn ra nhanh, FID = tốt — dù mọi click sau đó đều lag 800ms. Trong thực tế, 96% tương tác của người dùng xảy ra sau lần đầu tiên.

INP khắc phục hoàn toàn nhược điểm này. Nó đo toàn bộ tương tác (click, tap, keypress) trong suốt vòng đời trang và lấy giá trị ở phân vị 75 (p75) — gần như interaction chậm nhất nhưng loại bỏ outlier cực đoan. Nếu trang có 200 interactions, INP là interaction chậm thứ ~150.

graph LR
    subgraph FID["FID (đã bỏ)"]
        direction LR
        F1["Chỉ đo lần click ĐẦU TIÊN"] --> F2["Bỏ qua 96% interactions"]
    end
    subgraph INP["INP (thay thế từ 03/2024)"]
        direction LR
        I1["Đo MỌI interaction"] --> I2["Lấy p75 worst-case"]
    end
    style FID fill:#f8f9fa,stroke:#ff9800,color:#2c3e50
    style INP fill:#f8f9fa,stroke:#4CAF50,color:#2c3e50
    style F2 fill:#ff9800,stroke:#fff,color:#fff
    style I2 fill:#4CAF50,stroke:#fff,color:#fff

Hình 1: FID chỉ nhìn lần đầu, INP đánh giá toàn bộ trải nghiệm tương tác

INP ảnh hưởng trực tiếp đến SEO

INP là một trong ba Core Web Vitals (cùng LCP và CLS) mà Google dùng để xếp hạng tìm kiếm. Một trang có INP >500ms bị đánh dấu "Poor" trong Search Console và có thể bị giảm thứ hạng. Với các trang e-commerce hoặc SaaS, mỗi 100ms INP tăng thêm có thể giảm 1-2% conversion rate.

Giải phẫu một Interaction — 3 Phase của INP

Mỗi interaction được INP đo qua 3 giai đoạn liên tiếp. Hiểu rõ từng phase giúp bạn xác định chính xác bottleneck nằm ở đâu:

graph LR
    U["👆 User click"] --> P1["Phase 1: Input Delay"]
    P1 --> P2["Phase 2: Processing Duration"]
    P2 --> P3["Phase 3: Presentation Delay"]
    P3 --> NP["🖼️ Next Paint"]
    style P1 fill:#ff9800,stroke:#fff,color:#fff
    style P2 fill:#e94560,stroke:#fff,color:#fff
    style P3 fill:#2196F3,stroke:#fff,color:#fff
    style U fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
    style NP fill:#4CAF50,stroke:#fff,color:#fff

Hình 2: Ba giai đoạn tạo nên thời gian INP của một interaction

Phase 1: Input Delay — Main thread đang bận

Khoảng thời gian từ lúc user click đến khi event handler bắt đầu chạy. Nguyên nhân phổ biến nhất: main thread đang bận xử lý task khác — render component, chạy third-party script, hoặc parse JavaScript bundle lớn.

// ❌ Long task chặn main thread 300ms
document.addEventListener('DOMContentLoaded', () => {
  // Third-party analytics script chạy synchronous
  initHeavyAnalytics();  // 150ms
  buildProductRecommendations();  // 120ms
  setupChatWidget();  // 80ms
  // Total: 350ms — mọi click trong thời gian này bị delay
});

Cách đo: Mở Chrome DevTools → Performance tab → record interaction → tìm block màu vàng dài hơn 50ms trước event handler. Đó chính là Input Delay.

Phase 2: Processing Duration — Event handler chạy lâu

Thời gian thực thi toàn bộ event handler (bao gồm cả microtasks). Nếu handler làm quá nhiều việc — validate form, gọi API, update DOM phức tạp — phase này sẽ dài.

// ❌ Event handler quá nặng
button.addEventListener('click', () => {
  const items = getCartItems();        // 5ms
  const total = calculateTotal(items); // 10ms
  validateInventory(items);            // 80ms — sync API call!
  renderCartSummary(items, total);     // 120ms — DOM manipulation nặng
  trackAnalytics('add_to_cart');       // 30ms
  // Total Processing: ~245ms
});

Phase 3: Presentation Delay — Trình duyệt render kết quả

Sau khi event handler hoàn thành, trình duyệt cần tính toán layout, paint pixels lên màn hình. Nếu handler thay đổi nhiều DOM elements hoặc trigger forced reflow, phase này có thể rất nặng.

// ❌ Forced reflow trong loop — Presentation Delay cực cao
items.forEach(item => {
  const height = item.offsetHeight;  // Force layout read
  item.style.height = height + 10 + 'px';  // Force layout write
  // Mỗi vòng lặp = 1 layout thrashing cycle
});

Công thức INP

INP = Input Delay + Processing Duration + Presentation Delay
Ngưỡng: ≤200ms = Good, 200-500ms = Needs Improvement, >500ms = Poor. Google khuyến nghị target dưới 200ms cho p75 trên cả mobile và desktop.

scheduler.yield() — API đột phá cho INP

Trước khi có scheduler.yield(), developer phải dùng setTimeout(fn, 0) để chia nhỏ long tasks. Vấn đề: setTimeout đẩy continuation xuống cuối task queue — nếu có 50 tasks đang chờ, code của bạn phải đợi hết lượt. Thêm nữa, nested setTimeout bị trình duyệt clamp tối thiểu 4ms mỗi lần, biến một tác vụ 200ms thành 2+ phút nếu chia quá nhỏ.

scheduler.yield() giải quyết triệt để: nó yield main thread cho trình duyệt xử lý pending user input (giảm Input Delay), nhưng giữ nguyên quyền ưu tiên cho continuation — code tiếp tục chạy ngay sau khi trình duyệt xử lý xong events quan trọng, không phải xếp hàng cuối queue.

sequenceDiagram
    participant User
    participant Browser as Main Thread
    participant Queue as Task Queue

    Note over Browser: Long Task đang chạy (200ms)
    User->>Browser: Click!
    Note over Browser: ❌ setTimeout: click bị chờ

    rect rgb(232, 245, 233)
    Note over Browser: ✅ scheduler.yield()
    Browser->>Queue: Yield + đặt continuation ưu tiên cao
    Browser->>User: Xử lý click ngay lập tức
    Queue->>Browser: Resume continuation
    end

Hình 3: scheduler.yield() cho phép trình duyệt xử lý user input giữa chừng long task

So sánh setTimeout vs scheduler.yield()

Tiêu chísetTimeout(fn, 0)scheduler.yield()
Vị trí trong queueCuối hàng đợi (back of queue)Đầu hàng đợi (front of queue)
Minimum delay4ms (clamped sau 5 nested calls)~0ms (không bị clamp)
200 chunks × 1ms work~2 phút (4ms gap × 200)~1 giây
User input priorityPhải chờ current task xongĐược xử lý ngay khi yield
Browser support (04/2026)Mọi trình duyệtChrome 129+, Edge 129+, Firefox 131+
PolyfillKhông cầnFallback về setTimeout nếu không hỗ trợ

Pattern cơ bản: Chia nhỏ long task

// ✅ Chia long task thành chunks với scheduler.yield()
async function processLargeDataset(items) {
  const CHUNK_SIZE = 50;
  const results = [];

  for (let i = 0; i < items.length; i += CHUNK_SIZE) {
    const chunk = items.slice(i, i + CHUNK_SIZE);

    for (const item of chunk) {
      results.push(expensiveTransform(item));
    }

    // Yield main thread — trình duyệt xử lý pending input
    await scheduler.yield();
  }

  return results;
}

Pattern nâng cao: Yield với polyfill an toàn

// ✅ Universal yield function — hoạt động trên mọi browser
function yieldToMain() {
  if (globalThis.scheduler?.yield) {
    return scheduler.yield();
  }
  // Fallback: setTimeout — chậm hơn nhưng vẫn yield
  return new Promise(resolve => setTimeout(resolve, 0));
}

// Sử dụng trong event handler
button.addEventListener('click', async () => {
  updateUIOptimistically();  // Phản hồi visual ngay lập tức
  await yieldToMain();       // Cho browser paint

  const data = await fetchCartData();
  await yieldToMain();       // Yield trước heavy DOM update

  renderCartDetails(data);   // DOM update có thể nặng
});

Khi nào nên yield?

Không phải mọi function đều cần yield. Chỉ yield khi: (1) Task mất hơn 50ms — dùng performance.now() để đo, (2) Có user interaction quan trọng có thể xảy ra giữa chừng (form input, scroll, click), (3) Có visual update cần paint giữa task. Yield quá nhiều cũng tạo overhead — mỗi yield là một microtask scheduling cycle.

Đo lường INP thực tế với web-vitals

Google cung cấp thư viện web-vitals để đo INP cả trong lab và field (Real User Monitoring). Đây là cách tích hợp vào Vue.js project:

// src/utils/web-vitals.ts
import { onINP } from 'web-vitals';

export function initINPMonitoring() {
  onINP((metric) => {
    const entry = metric.entries[0];

    console.log('[INP]', {
      value: metric.value,           // Thời gian INP (ms)
      rating: metric.rating,         // 'good' | 'needs-improvement' | 'poor'
      element: entry?.target,        // DOM element gây ra interaction
      inputDelay: entry?.processingStart - entry?.startTime,
      processingDuration: entry?.processingEnd - entry?.processingStart,
      presentationDelay: entry?.startTime + metric.value - entry?.processingEnd
    });

    // Gửi về analytics endpoint
    navigator.sendBeacon('/api/vitals', JSON.stringify({
      name: 'INP',
      value: metric.value,
      rating: metric.rating,
      page: window.location.pathname,
      element: entry?.target?.tagName
    }));
  }, { reportAllChanges: true });
}
// main.ts — khởi tạo monitoring
import { initINPMonitoring } from './utils/web-vitals';

const app = createApp(App);
app.mount('#app');

// Đo INP sau khi app mount
initINPMonitoring();

Tối ưu INP cho Vue.js — Pattern thực chiến

Vue.js có reactivity system mạnh, nhưng nếu không cẩn thận, re-render cascade có thể tạo long tasks. Dưới đây là các pattern cụ thể cho từng phase:

Giảm Input Delay: Lazy hydration và Code splitting

<!-- ❌ Import tất cả component ngay lập tức -->
<script setup>
import HeavyDataGrid from './HeavyDataGrid.vue'
import ChartDashboard from './ChartDashboard.vue'
import CommentSection from './CommentSection.vue'
</script>

<!-- ✅ Lazy load component chỉ khi cần -->
<script setup>
import { defineAsyncComponent, shallowRef } from 'vue'

const HeavyDataGrid = defineAsyncComponent(() =>
  import('./HeavyDataGrid.vue')
)
const ChartDashboard = defineAsyncComponent(() =>
  import('./ChartDashboard.vue')
)

// Comment section chỉ load khi scroll tới
const CommentSection = defineAsyncComponent({
  loader: () => import('./CommentSection.vue'),
  delay: 200,
  loadingComponent: () => h('div', 'Đang tải bình luận...')
})
</script>

Giảm Processing Duration: v-memo và computed caching

<!-- ❌ Re-render toàn bộ list mỗi khi bất kỳ state nào thay đổi -->
<template>
  <div v-for="item in filteredItems" :key="item.id">
    <ProductCard :product="item" />
  </div>
</template>

<!-- ✅ v-memo: chỉ re-render khi dependency thực sự thay đổi -->
<template>
  <div v-for="item in filteredItems" :key="item.id"
       v-memo="[item.id, item.price, item.stock]">
    <ProductCard :product="item" />
  </div>
</template>

<script setup>
// ✅ computed() cache kết quả — không tính lại nếu deps không đổi
const filteredItems = computed(() => {
  return props.items
    .filter(i => i.category === selectedCategory.value)
    .sort((a, b) => a.price - b.price)
})
</script>

Giảm Processing Duration: scheduler.yield() trong Vue event handler

<script setup>
import { ref } from 'vue'

const cartItems = ref([])
const isUpdating = ref(false)

async function addToCart(product) {
  // Phản hồi visual ngay lập tức (optimistic update)
  isUpdating.value = true
  cartItems.value.push({ ...product, quantity: 1 })

  // Yield — cho Vue patch DOM và browser paint
  await scheduler.yield()

  // Heavy operations sau khi user đã thấy phản hồi
  const validated = await validateStock(product.id)

  await scheduler.yield()

  await syncCartToServer(cartItems.value)
  isUpdating.value = false
}
</script>

Giảm Presentation Delay: Virtual scrolling cho danh sách lớn

<!-- ❌ Render 10,000 items → DOM cực nặng → paint chậm -->
<div v-for="item in allItems" :key="item.id">
  <ItemRow :data="item" />
</div>

<!-- ✅ Virtual scroll: chỉ render ~20 items visible -->
<template>
  <VirtualScroller
    :items="allItems"
    :item-height="60"
    :buffer="5"
  >
    <template #default="{ item }">
      <ItemRow :data="item" />
    </template>
  </VirtualScroller>
</template>

<script setup>
// vue-virtual-scroller hoặc @tanstack/vue-virtual
import { VirtualScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
</script>

Vue DevTools — Performance Timeline

Vue DevTools 7+ có Performance tab hiển thị component render time. Kết hợp với Chrome Performance panel: record interaction → tìm "Long Task" markers → xem component nào render lâu nhất. Nếu một component render >16ms, nó đang chiếm hơn 1 frame budget (60fps = 16.6ms/frame).

Tối ưu INP phía Server — .NET Core rendering

INP chủ yếu là client-side metric, nhưng server response time ảnh hưởng gián tiếp. Nếu API response chậm, JavaScript phải giữ main thread chờ fetch → tăng Input Delay cho interactions tiếp theo.

Response streaming với IAsyncEnumerable

// ✅ Stream response thay vì buffer toàn bộ
[HttpGet("products")]
public async IAsyncEnumerable<ProductDto> GetProducts(
    [EnumeratorCancellation] CancellationToken ct)
{
    await foreach (var product in _repo.GetAllAsync(ct))
    {
        yield return _mapper.Map<ProductDto>(product);
    }
    // Client nhận từng item ngay khi có —
    // frontend có thể render progressive thay vì chờ hết
}

Output Caching trên .NET 10 — Giảm TTFB triệt để

// Program.cs
builder.Services.AddOutputCache(options =>
{
    options.AddBasePolicy(builder => builder
        .Expire(TimeSpan.FromMinutes(5))
        .Tag("products"));

    options.AddPolicy("ByCategory", builder => builder
        .SetVaryByQuery("category")
        .Expire(TimeSpan.FromMinutes(10)));
});

// Controller
[HttpGet("products")]
[OutputCache(PolicyName = "ByCategory")]
public async Task<IActionResult> GetProducts(string category)
{
    var products = await _repo.GetByCategoryAsync(category);
    return Ok(products);
}

// Invalidate khi data thay đổi
[HttpPost("products")]
public async Task<IActionResult> CreateProduct(CreateProductDto dto)
{
    await _repo.CreateAsync(dto);
    await _cache.EvictByTagAsync("products", default);
    return Created();
}

Checklist tối ưu INP — Từng Phase cụ thể

PhaseNguyên nhân phổ biếnGiải pháp
Input DelayThird-party script chặn main threadDefer/async loading, Web Workers cho heavy compute
Input DelayHydration nặng khi page loadProgressive hydration, Islands Architecture
Input DelayJavaScript bundle quá lớnCode splitting, dynamic import(), tree-shaking
ProcessingEvent handler synchronous nặngscheduler.yield() chia nhỏ tasks
ProcessingVue re-render cascadev-memo, shallowRef, computed caching
ProcessingSync API calls trong handlerOptimistic update + async fetch sau yield
PresentationLayout thrashing (read-write loop)Batch DOM reads rồi batch writes
PresentationQuá nhiều DOM elementsVirtual scrolling, content-visibility: auto
PresentationCSS containment thiếucontain: layout style paint trên containers

Real-world case study: E-commerce product listing

Giả sử bạn có trang danh sách sản phẩm với 500 items, filter sidebar, và sort dropdown. Trước tối ưu: INP = 680ms (Poor). Hãy xem từng bước cải thiện:

graph TB
    A["INP = 680ms ❌ Poor"] --> B["Bước 1: Code split filter component"]
    B --> C["INP = 420ms"]
    C --> D["Bước 2: scheduler.yield() trong sort handler"]
    D --> E["INP = 280ms"]
    E --> F["Bước 3: Virtual scroll cho product list"]
    F --> G["INP = 150ms"]
    G --> H["Bước 4: v-memo trên ProductCard"]
    H --> I["INP = 95ms ✅ Good"]
    style A fill:#ff5252,stroke:#fff,color:#fff
    style C fill:#ff9800,stroke:#fff,color:#fff
    style E fill:#ff9800,stroke:#fff,color:#fff
    style G fill:#4CAF50,stroke:#fff,color:#fff
    style I fill:#4CAF50,stroke:#fff,color:#fff

Hình 4: Quá trình giảm INP từ 680ms xuống 95ms qua 4 bước tối ưu

// Bước 2 chi tiết: Sort handler với scheduler.yield()
async function sortProducts(criteria) {
  // Optimistic: hiển thị loading indicator ngay
  isSorting.value = true

  await scheduler.yield()  // Cho browser paint loading state

  // Chia sort thành chunks nếu dataset lớn
  const sorted = [...products.value]
  const CHUNK = 100

  for (let i = 0; i < sorted.length; i += CHUNK) {
    sorted.slice(i, i + CHUNK).sort((a, b) => {
      return criteria === 'price'
        ? a.price - b.price
        : a.name.localeCompare(b.name)
    })

    if (i + CHUNK < sorted.length) {
      await scheduler.yield()  // Yield mỗi 100 items
    }
  }

  // Final merge sort (đã gần sorted → nhanh)
  products.value = mergeSort(sorted, criteria)
  isSorting.value = false
}

Công cụ đo và debug INP

Công cụLoạiCách dùng
Chrome DevTools PerformanceLabRecord → Interactions track hiển thị từng phase với thời gian cụ thể
Lighthouse 12+LabTimespan mode → interact với page → xem INP trong báo cáo
web-vitals (npm)Field (RUM)Tích hợp vào production, gửi metric về analytics endpoint
CrUX DashboardFieldDữ liệu thực từ Chrome users — p75 INP theo origin hoặc URL
Web Vitals ExtensionLabBadge hiển thị INP real-time khi tương tác — debug nhanh
PerformanceObserver APILab + FieldCustom monitoring: observe({ type: 'event', buffered: true })

Content-visibility và CSS Containment — Giảm Presentation Delay

Hai CSS property thường bị bỏ qua nhưng cực kỳ hiệu quả cho INP:

/* content-visibility: auto — trình duyệt skip rendering
   cho elements ngoài viewport */
.product-card {
  content-visibility: auto;
  contain-intrinsic-size: 0 300px; /* placeholder height */
}

/* contain: layout style paint — cách ly rendering scope */
.filter-sidebar {
  contain: layout style paint;
  /* Thay đổi bên trong sidebar KHÔNG trigger
     re-layout/re-paint toàn bộ page */
}

/* will-change — hint cho browser tạo composite layer riêng */
.dropdown-menu {
  will-change: transform, opacity;
  /* Animate bằng transform/opacity thay vì top/left/width */
}

content-visibility có thể giảm 50-70% Presentation Delay

Với trang có 200+ elements (product listing, blog feed, dashboard), content-visibility: auto cho phép trình duyệt bỏ qua layout và paint cho elements ngoài viewport. Kết hợp với contain-intrinsic-size để tránh layout shift khi scroll. Chrome DevTools → Rendering tab → bật "Highlight elements with content-visibility" để kiểm tra.

Web Workers — Chuyển heavy compute khỏi main thread

Khi business logic quá nặng (crypto, image processing, data parsing), scheduler.yield() không đủ — bạn cần chuyển hoàn toàn sang background thread. Web Workers chạy trên thread riêng, không ảnh hưởng INP:

// worker.ts — chạy trên thread riêng
self.addEventListener('message', async (event) => {
  const { type, data } = event.data;

  if (type === 'FILTER_AND_SORT') {
    const result = data.items
      .filter(item => matchesFilters(item, data.filters))
      .sort((a, b) => compareBy(a, b, data.sortField));

    self.postMessage({ type: 'RESULT', data: result });
  }
});

// Vue composable — sử dụng Worker
// useProductWorker.ts
import { ref, onUnmounted } from 'vue'

export function useProductWorker() {
  const worker = new Worker(
    new URL('./worker.ts', import.meta.url),
    { type: 'module' }
  );
  const results = ref([]);
  const isProcessing = ref(false);

  worker.addEventListener('message', (event) => {
    if (event.data.type === 'RESULT') {
      results.value = event.data.data;
      isProcessing.value = false;
    }
  });

  function filterAndSort(items, filters, sortField) {
    isProcessing.value = true;
    worker.postMessage({
      type: 'FILTER_AND_SORT',
      data: { items, filters, sortField }
    });
  }

  onUnmounted(() => worker.terminate());

  return { results, isProcessing, filterAndSort };
}

Priority Hints — Kiểm soát thứ tự loading

Giảm Input Delay gián tiếp bằng cách cho trình duyệt biết resource nào quan trọng nhất:

<!-- ✅ fetchpriority cho critical resources -->
<link rel="preload" href="/fonts/inter.woff2" as="font"
      fetchpriority="high" crossorigin>

<img src="hero-banner.webp" fetchpriority="high"
     loading="eager" decoding="async">

<img src="product-thumb-247.webp" fetchpriority="low"
     loading="lazy" decoding="async">

<!-- ✅ Third-party scripts: luôn defer hoặc async -->
<script src="https://analytics.example.com/track.js"
        async fetchpriority="low"></script>

<!-- ✅ Preconnect cho critical origins -->
<link rel="preconnect" href="https://api.example.com">
<link rel="dns-prefetch" href="https://cdn.example.com">

Tổng kết

INP không chỉ là một con số metrics — nó phản ánh trực tiếp cảm nhận "nhanh hay chậm" của người dùng mỗi khi tương tác. Với sự thay thế FID, Google đã nâng cao tiêu chuẩn: không chỉ lần đầu tiên phải nhanh, mà mọi lần đều phải nhanh.

scheduler.yield() là bước tiến quan trọng trong bộ công cụ web performance. Nó giải quyết vấn đề mà developer đã phải hack bằng setTimeout suốt hơn một thập kỷ — chia nhỏ long tasks mà không mất quyền ưu tiên, không bị clamp delay, và không đẩy code xuống cuối queue. Kết hợp với v-memo, content-visibility, virtual scrolling và Web Workers, bạn có bộ toolkit đầy đủ để đưa INP dưới 200ms cho bất kỳ ứng dụng Vue.js nào.

Hãy bắt đầu bằng việc tích hợp web-vitals vào project, đo INP trên production với real users, và tập trung tối ưu interaction chậm nhất trước. Một trang với INP 95ms sẽ khiến người dùng cảm thấy mọi thao tác đều "instant" — và đó chính xác là trải nghiệm web mà chúng ta nên hướng tới trong 2026.

Nguồn tham khảo