Optimizing INP with scheduler.yield() — Elevating Web Responsiveness in 2026

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

You click "Add to cart" on an e-commerce page, then wait nearly a second before you see any feedback. The page doesn't lag, it doesn't error out — but that feeling of slowness makes you impatient. That's exactly what Interaction to Next Paint (INP) measures, and since March 2024 Google has officially replaced FID with INP as the Core Web Vital for responsiveness.

This article digs into how INP works, breaks down each phase that causes delay, and — most importantly — covers scheduler.yield(), the new browser API that lets you split long tasks without giving up main-thread priority. It also lays out concrete optimization patterns for Vue.js and .NET backend rendering.

200ms Google's "Good" INP threshold
96% Interactions ignored by the old FID
100x scheduler.yield() vs setTimeout workaround
50ms Long Task threshold that needs splitting

FID is dead — Why INP replaced it

First Input Delay (FID) only measured the first interaction's delay. If the page finished loading and the first click was fast, FID was good — even if every click after that lagged 800 ms. In reality, 96% of user interactions happen after the first one.

INP fixes this entirely. It measures every interaction (click, tap, keypress) during the page's lifetime and takes the value at the 75th percentile (p75) — close to the slowest interaction but excluding extreme outliers. If a page has 200 interactions, INP is roughly the 150th-slowest one.

graph LR
    subgraph FID["FID (retired)"]
        direction LR
        F1["Measures only the FIRST click"] --> F2["Ignores 96% of interactions"]
    end
    subgraph INP["INP (replacement since 03/2024)"]
        direction LR
        I1["Measures EVERY interaction"] --> I2["Uses 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

Figure 1: FID only looks at the first click; INP evaluates the whole interaction experience

INP directly affects SEO

INP is one of three Core Web Vitals (along with LCP and CLS) Google uses in search ranking. A page with INP >500 ms is flagged "Poor" in Search Console and can lose rankings. For e-commerce or SaaS pages, every extra 100 ms of INP can drop conversion rate by 1-2%.

Anatomy of an interaction — INP's 3 phases

INP measures every interaction through three consecutive stages. Understanding each phase helps you pinpoint exactly where the bottleneck lives:

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

Figure 2: The three phases that make up an interaction's INP time

Phase 1: Input Delay — The main thread is busy

The time between the user's click and the event handler actually starting. The most common cause: the main thread is busy with something else — rendering components, running third-party scripts, or parsing a big JavaScript bundle.

// ❌ A long task blocks the main thread for 300 ms
document.addEventListener('DOMContentLoaded', () => {
  // Third-party analytics script runs synchronously
  initHeavyAnalytics();  // 150ms
  buildProductRecommendations();  // 120ms
  setupChatWidget();  // 80ms
  // Total: 350ms — every click in this window is delayed
});

How to measure: open Chrome DevTools → Performance tab → record an interaction → look for the yellow block longer than 50 ms before the event handler. That's the Input Delay.

Phase 2: Processing Duration — The event handler runs too long

The full execution time of the event handler (including microtasks). If the handler does too much — validate forms, call APIs, run complex DOM updates — this phase gets long.

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

Phase 3: Presentation Delay — The browser paints the result

After the event handler finishes, the browser still needs to compute layout and paint pixels. If the handler touched many DOM elements or triggered a forced reflow, this phase can be very expensive.

// ❌ Forced reflow inside a loop — Presentation Delay skyrockets
items.forEach(item => {
  const height = item.offsetHeight;  // Force layout read
  item.style.height = height + 10 + 'px';  // Force layout write
  // Each iteration = one layout thrashing cycle
});

The INP formula

INP = Input Delay + Processing Duration + Presentation Delay
Thresholds: ≤200 ms = Good, 200-500 ms = Needs Improvement, >500 ms = Poor. Google recommends targeting under 200 ms at p75 for both mobile and desktop.

scheduler.yield() — A breakthrough API for INP

Before scheduler.yield(), developers had to use setTimeout(fn, 0) to chunk long tasks. The problem: setTimeout pushes the continuation to the back of the task queue — if 50 tasks are waiting, your code waits its turn. On top of that, browsers clamp nested setTimeout calls to a minimum of 4 ms each, turning a 200 ms task into 2+ minutes if split too finely.

scheduler.yield() solves this cleanly: it yields the main thread so the browser can handle pending user input (reducing Input Delay), but keeps priority for the continuation — your code resumes as soon as the browser finishes those critical events, without going to the back of the queue.

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

    Note over Browser: A long task is running (200 ms)
    User->>Browser: Click!
    Note over Browser: ❌ setTimeout: click has to wait

    rect rgb(232, 245, 233)
    Note over Browser: ✅ scheduler.yield()
    Browser->>Queue: Yield + schedule continuation with high priority
    Browser->>User: Handle click immediately
    Queue->>Browser: Resume continuation
    end

Figure 3: scheduler.yield() lets the browser handle user input mid-long-task

setTimeout vs scheduler.yield()

Criterion setTimeout(fn, 0) scheduler.yield()
Queue position Back of the queue Front of the queue
Minimum delay 4 ms (clamped after 5 nested calls) ~0 ms (no clamp)
200 chunks × 1 ms work ~2 minutes (4 ms gap × 200) ~1 second
User-input priority Has to wait for the current task Handled immediately on yield
Browser support (04/2026) Every browser Chrome 129+, Edge 129+, Firefox 131+
Polyfill None needed Fall back to setTimeout if unsupported

Basic pattern: splitting a long task

// ✅ Split a long task into chunks with 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 the main thread — browser handles pending input
    await scheduler.yield();
  }

  return results;
}

Advanced pattern: yield with a safe polyfill

// ✅ Universal yield function — works on every browser
function yieldToMain() {
  if (globalThis.scheduler?.yield) {
    return scheduler.yield();
  }
  // Fallback: setTimeout — slower but still yields
  return new Promise(resolve => setTimeout(resolve, 0));
}

// Use inside an event handler
button.addEventListener('click', async () => {
  updateUIOptimistically();  // Show visual feedback right away
  await yieldToMain();       // Let the browser paint

  const data = await fetchCartData();
  await yieldToMain();       // Yield before a heavy DOM update

  renderCartDetails(data);   // DOM update may be heavy
});

When should you yield?

Not every function needs to yield. Yield when: (1) a task takes over 50 ms — use performance.now() to measure, (2) an important user interaction could happen during it (form input, scroll, click), (3) there's a visual update that needs to paint mid-task. Yielding too often creates overhead — each yield is a microtask-scheduling cycle.

Measuring INP in the real world with web-vitals

Google ships the web-vitals library to measure INP both in the lab and in the field (Real User Monitoring). Here's how to integrate it into a 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,           // INP time (ms)
      rating: metric.rating,         // 'good' | 'needs-improvement' | 'poor'
      element: entry?.target,        // DOM element that triggered the interaction
      inputDelay: entry?.processingStart - entry?.startTime,
      processingDuration: entry?.processingEnd - entry?.processingStart,
      presentationDelay: entry?.startTime + metric.value - entry?.processingEnd
    });

    // Send to 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 — bootstrap monitoring
import { initINPMonitoring } from './utils/web-vitals';

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

// Measure INP after the app mounts
initINPMonitoring();

Optimizing INP for Vue.js — Battle-tested patterns

Vue.js has a powerful reactivity system, but without care the re-render cascade can create long tasks. Here are concrete patterns per phase:

Reducing Input Delay: lazy hydration and code splitting

<!-- ❌ Import every component immediately -->
<script setup>
import HeavyDataGrid from './HeavyDataGrid.vue'
import ChartDashboard from './ChartDashboard.vue'
import CommentSection from './CommentSection.vue'
</script>

<!-- ✅ Lazy load components only when needed -->
<script setup>
import { defineAsyncComponent, shallowRef } from 'vue'

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

// The comment section only loads when scrolled into view
const CommentSection = defineAsyncComponent({
  loader: () => import('./CommentSection.vue'),
  delay: 200,
  loadingComponent: () => h('div', 'Loading comments...')
})
</script>

Reducing Processing Duration: v-memo and computed caching

<!-- ❌ Re-renders the whole list every time any state changes -->
<template>
  <div v-for="item in filteredItems" :key="item.id">
    <ProductCard :product="item" />
  </div>
</template>

<!-- ✅ v-memo: re-render only when dependencies actually change -->
<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() caches results — doesn't recompute when deps are stable
const filteredItems = computed(() => {
  return props.items
    .filter(i => i.category === selectedCategory.value)
    .sort((a, b) => a.price - b.price)
})
</script>

Reducing Processing Duration: scheduler.yield() inside Vue event handlers

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

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

async function addToCart(product) {
  // Instant visual feedback (optimistic update)
  isUpdating.value = true
  cartItems.value.push({ ...product, quantity: 1 })

  // Yield — let Vue patch the DOM and the browser paint
  await scheduler.yield()

  // Heavy work after the user has already seen a response
  const validated = await validateStock(product.id)

  await scheduler.yield()

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

Reducing Presentation Delay: virtual scrolling for large lists

<!-- ❌ Rendering 10,000 items → huge DOM → slow paint -->
<div v-for="item in allItems" :key="item.id">
  <ItemRow :data="item" />
</div>

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

<script setup>
// vue-virtual-scroller or @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+ has a Performance tab showing component render times. Pair it with Chrome's Performance panel: record the interaction → look for "Long Task" markers → find which component renders the longest. If any component takes >16 ms, it's already consuming more than one frame budget (60fps = 16.6 ms/frame).

Server-side INP optimization — .NET Core rendering

INP is primarily a client-side metric, but server response time affects it indirectly. If the API is slow, JavaScript has to keep the main thread busy waiting on fetch → Input Delay rises for the next interactions.

Response streaming with IAsyncEnumerable

// ✅ Stream the response instead of buffering it
[HttpGet("products")]
public async IAsyncEnumerable<ProductDto> GetProducts(
    [EnumeratorCancellation] CancellationToken ct)
{
    await foreach (var product in _repo.GetAllAsync(ct))
    {
        yield return _mapper.Map<ProductDto>(product);
    }
    // The client receives items as soon as they're ready —
    // the frontend can render progressively instead of waiting
}

Output Caching on .NET 10 — Cutting TTFB dramatically

// 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 when data changes
[HttpPost("products")]
public async Task<IActionResult> CreateProduct(CreateProductDto dto)
{
    await _repo.CreateAsync(dto);
    await _cache.EvictByTagAsync("products", default);
    return Created();
}

INP optimization checklist — Per phase

Phase Common cause Fix
Input Delay Third-party scripts blocking the main thread Defer/async loading, Web Workers for heavy compute
Input Delay Heavy hydration at page load Progressive hydration, Islands Architecture
Input Delay JavaScript bundle too large Code splitting, dynamic import(), tree-shaking
Processing Heavy synchronous event handler scheduler.yield() to chunk the task
Processing Vue re-render cascade v-memo, shallowRef, computed caching
Processing Synchronous API calls inside the handler Optimistic update + async fetch after yield
Presentation Layout thrashing (read-write loop) Batch DOM reads, then batch writes
Presentation Too many DOM elements Virtual scrolling, content-visibility: auto
Presentation Missing CSS containment contain: layout style paint on containers

Real-world case study: e-commerce product listing

Say you have a product listing page with 500 items, a filter sidebar, and a sort dropdown. Before optimization: INP = 680 ms (Poor). Here's the step-by-step improvement:

graph TB
    A["INP = 680ms ❌ Poor"] --> B["Step 1: code-split filter component"]
    B --> C["INP = 420ms"]
    C --> D["Step 2: scheduler.yield() in the sort handler"]
    D --> E["INP = 280ms"]
    E --> F["Step 3: virtual scroll for the product list"]
    F --> G["INP = 150ms"]
    G --> H["Step 4: v-memo on 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

Figure 4: Reducing INP from 680 ms to 95 ms in four optimization steps

// Step 2 in detail: sort handler with scheduler.yield()
async function sortProducts(criteria) {
  // Optimistic: show a loading indicator immediately
  isSorting.value = true

  await scheduler.yield()  // Let the browser paint the loading state

  // Chunk the sort if the dataset is large
  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 every 100 items
    }
  }

  // Final merge sort (nearly sorted → fast)
  products.value = mergeSort(sorted, criteria)
  isSorting.value = false
}

Tools to measure and debug INP

Tool Type Usage
Chrome DevTools Performance Lab Record → Interactions track shows each phase with exact timings
Lighthouse 12+ Lab Timespan mode → interact with the page → see INP in the report
web-vitals (npm) Field (RUM) Integrate into production, send metrics to an analytics endpoint
CrUX Dashboard Field Real data from Chrome users — p75 INP per origin or URL
Web Vitals Extension Lab A badge showing real-time INP during interactions — fast debugging
PerformanceObserver API Lab + Field Custom monitoring: observe({ type: 'event', buffered: true })

content-visibility and CSS containment — Cutting Presentation Delay

Two CSS properties often overlooked but extremely effective for INP:

/* content-visibility: auto — browser skips rendering
   elements outside the viewport */
.product-card {
  content-visibility: auto;
  contain-intrinsic-size: 0 300px; /* placeholder height */
}

/* contain: layout style paint — isolates the rendering scope */
.filter-sidebar {
  contain: layout style paint;
  /* Changes inside the sidebar do NOT trigger
     re-layout/re-paint of the whole page */
}

/* will-change — hint for the browser to create its own composite layer */
.dropdown-menu {
  will-change: transform, opacity;
  /* Animate with transform/opacity instead of top/left/width */
}

content-visibility can cut Presentation Delay by 50-70%

For pages with 200+ elements (product listings, blog feeds, dashboards), content-visibility: auto lets the browser skip layout and paint for off-viewport elements. Pair it with contain-intrinsic-size to avoid layout shift while scrolling. Chrome DevTools → Rendering tab → enable "Highlight elements with content-visibility" to check.

Web Workers — Moving heavy compute off the main thread

When business logic is truly heavy (crypto, image processing, data parsing), scheduler.yield() isn't enough — you need to move it to a background thread entirely. Web Workers run on their own thread, not affecting INP:

// worker.ts — runs on its own thread
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 — using the 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 — Controlling load order

Indirectly reduce Input Delay by telling the browser which resources matter most:

<!-- ✅ fetchpriority for 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: always defer or async -->
<script src="https://analytics.example.com/track.js"
        async fetchpriority="low"></script>

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

Wrap-up

INP isn't just another metric — it directly reflects how "fast or slow" users feel every time they interact. By replacing FID, Google raised the bar: it's not just the first interaction that must be fast, every interaction must be.

scheduler.yield() is a big step forward in the web-performance toolkit. It solves what developers have hacked around with setTimeout for over a decade — chunking long tasks without losing priority, without being clamped, without falling to the back of the queue. Combined with v-memo, content-visibility, virtual scrolling, and Web Workers, you have a complete toolkit to bring INP under 200 ms for any Vue.js application.

Start by integrating web-vitals into your project, measuring INP on production with real users, and focusing on the slowest interaction first. A page with INP of 95 ms makes every action feel "instant" — and that's exactly the web experience we should be aiming for in 2026.

References