Optimizing INP with scheduler.yield() — Elevating Web Responsiveness in 2026
Posted on: 4/17/2026 5:15:33 PM
Table of contents
- FID is dead — Why INP replaced it
- Anatomy of an interaction — INP's 3 phases
- scheduler.yield() — A breakthrough API for INP
- Measuring INP in the real world with web-vitals
- Optimizing INP for Vue.js — Battle-tested patterns
- Server-side INP optimization — .NET Core rendering
- INP optimization checklist — Per phase
- Real-world case study: e-commerce product listing
- Tools to measure and debug INP
- content-visibility and CSS containment — Cutting Presentation Delay
- Web Workers — Moving heavy compute off the main thread
- Priority Hints — Controlling load order
- Wrap-up
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.
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
Azure Container Apps — Run Production Containers Without Kubernetes
Vite+ 2026 — One Toolchain to Replace Webpack, ESLint, and Prettier
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.