BLOG · UPDATED 2026-04-17
INP 150ms Fix Playbook: React, Vue & Angular Working Code for 2026
Google's March 2026 update dropped the "good" INP threshold from 200ms to 150ms. Forty-three percent of sites failed 200ms before the update. Under the new rule, most of those sites now lose rankings. This playbook is the framework-specific code that actually moves the number.
We're skipping the theory. If you want the explainer, read our Core Web Vitals 2026 guide. This page is for engineers with a dashboard showing p75 INP at 280ms and a standup in the morning.
Table of contents
- Measure first: web-vitals + PerformanceObserver
- The schedulers: yield, startTransition, requestIdleCallback
- React 19 patterns
- Vue 3.5 patterns
- Angular 18 signals patterns
- Third-party script diet
- Web Workers and Comlink
- Hydration cost reduction
- Case study: SaaS dashboard 340ms to 118ms
- Deploy checklist
- FAQ
Measure First: web-vitals + PerformanceObserver
Install the web-vitals library and pipe real-user INP to your analytics. Lab scores lie. Field data is all that counts.
import { onINP, onLCP, onCLS } from 'web-vitals';
function send(metric) {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
id: metric.id,
url: location.pathname,
connection: navigator.connection?.effectiveType,
deviceMemory: navigator.deviceMemory,
});
navigator.sendBeacon('/api/rum', body);
}
onINP(send, { reportAllChanges: false });
onLCP(send);
onCLS(send);
Once you have RUM data, instrument the specific interactions that hurt. Long Animation Frames (LoAF) gives you the culprit script inside the bad interaction:
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) {
console.warn('Long task', {
duration: entry.duration,
scripts: entry.scripts?.map(s => ({
source: s.sourceURL,
invoker: s.invoker,
duration: s.duration,
})),
});
}
}
});
observer.observe({ type: 'long-animation-frame', buffered: true });
Copy long-task reports into the clipboard with our JSON formatter for readable output, or run them through the JSON path tester to extract just the fields that matter.
The Schedulers: yield, startTransition, requestIdleCallback
2026's scheduling toolkit has three tools with distinct jobs:
| API | Use for | Avoid for |
|---|---|---|
scheduler.yield() |
Breaking long synchronous loops into chunks | Updating React state (use startTransition) |
React.startTransition |
Marking state updates as non-urgent | Non-React work |
requestIdleCallback |
Deferrable analytics, prefetching | User-visible work (unreliable timing) |
scheduler.yield() in 60 seconds
// BAD: 400ms handler
button.onclick = () => {
for (const item of hugeList) {
processItem(item); // 2ms each × 200 items = 400ms
}
render();
};
// GOOD: yields every 50ms
button.onclick = async () => {
const CHUNK = 25; // ~50ms of work
for (let i = 0; i < hugeList.length; i += CHUNK) {
for (let j = i; j < Math.min(i + CHUNK, hugeList.length); j++) {
processItem(hugeList[j]);
}
await scheduler.yield(); // let browser paint, handle other input
}
render();
};
Fallback for browsers without scheduler.yield:
const yieldToMain = () =>
'scheduler' in window && 'yield' in scheduler
? scheduler.yield()
: new Promise(r => setTimeout(r, 0));
React 19 Patterns
useTransition for expensive renders
If filtering a 10,000-item list on every keystroke drops 200ms in INP, useTransition marks the state update as interruptible so input stays fluid.
import { useState, useTransition, useDeferredValue } from 'react';
function ProductList({ items }) {
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
const deferred = useDeferredValue(query);
const filtered = useMemo(
() => items.filter(i => i.name.includes(deferred)),
[items, deferred]
);
return (
<>
<input
value={query}
onChange={e => {
const v = e.target.value;
startTransition(() => setQuery(v));
}}
/>
{isPending && <Spinner />}
<List items={filtered} />
</>
);
}
Defer non-critical effects
Event handlers should paint immediately and schedule the rest for later.
function LikeButton({ postId }) {
const [liked, setLiked] = useState(false);
const handleClick = () => {
setLiked(true); // immediate UI feedback
// heavy work moved off the critical path
requestIdleCallback(() => {
fetch('/api/like', {
method: 'POST',
body: JSON.stringify({ postId }),
});
trackEvent('like_click', { postId });
});
};
return <button onClick={handleClick}>{liked ? '❤' : '♡'}</button>;
}
Lazy-load below-the-fold
import { lazy, Suspense } from 'react';
const CommentSection = lazy(() => import('./CommentSection'));
const ReviewCarousel = lazy(() => import('./ReviewCarousel'));
function ProductPage() {
return (
<>
<Hero />
<ProductInfo />
<Suspense fallback={null}>
<CommentSection />
</Suspense>
<Suspense fallback={null}>
<ReviewCarousel />
</Suspense>
</>
);
}
Vue 3.5 Patterns
defineAsyncComponent + v-once
<script setup>
import { defineAsyncComponent, ref } from 'vue';
const CommentList = defineAsyncComponent(() =>
import('./CommentList.vue')
);
const showComments = ref(false);
</script>
<template>
<Product v-once :data="product" />
<button @click="showComments = true">Show comments</button>
<CommentList v-if="showComments" />
</template>
Debounce reactive computed
import { ref, computed } from 'vue';
import { refDebounced } from '@vueuse/core';
const query = ref('');
const debounced = refDebounced(query, 150);
const results = computed(() =>
items.value.filter(i => i.name.includes(debounced.value))
);
Chunked processing in Vue
async function processBulk(items) {
const CHUNK = 25;
for (let i = 0; i < items.length; i += CHUNK) {
for (let j = i; j < Math.min(i + CHUNK, items.length); j++) {
processItem(items[j]);
}
await new Promise(r => setTimeout(r, 0));
}
}
Angular 18 Signals Patterns
Signals make defer-able work explicit. Combine with @defer blocks for route-level laziness.
@Component({
template: `
<input (input)="onSearch($event)" />
@defer (on viewport) {
<app-results [query]="query()" />
} @placeholder { <div class="skel"></div> }
`
})
export class SearchPage {
query = signal('');
onSearch(e: Event) {
const value = (e.target as HTMLInputElement).value;
// Zoneless: avoid triggering change detection on every keystroke
queueMicrotask(() => this.query.set(value));
}
}
Third-Party Script Diet
The biggest INP wins usually come from removing scripts, not optimizing your own code. Every third-party tag runs JS on the main thread. The drill:
- Open Chrome DevTools, go to Coverage, run the page. Sort by "unused bytes" on third-party origins.
- For each script, ask: does the business use this data? If no one has looked at the dashboard in 90 days, remove.
- For keepers, evaluate async, defer, or Partytown.
<!-- Partytown loader in <head> -->
<script src="/~partytown/partytown.js"></script>
<!-- GA4 runs in a web worker instead of the main thread -->
<script type="text/partytown" src="https://www.googletagmanager.com/gtag/js?id=G-XXXX"></script>
<script type="text/partytown">
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments)}
gtag('js', new Date());
gtag('config', 'G-XXXX');
</script>
Consent-gate any tracker that isn't essential. Users who decline don't run it; users who accept get a cookie and the tag loads on the next page. Our cookie parser helps debug the consent flow.
Web Workers and Comlink
CPU-heavy work (parsing a 5MB JSON blob, image manipulation, encryption, compile-to-WASM PDF processing) belongs in a worker. Main thread stays free. INP stays low.
// worker.ts
import * as Comlink from 'comlink';
const api = {
async parseAndValidate(jsonText: string) {
const data = JSON.parse(jsonText);
return runSchemaValidation(data);
},
async compressImage(buffer: ArrayBuffer) {
return wasmCompress(buffer, { quality: 0.8 });
},
};
Comlink.expose(api);
// main.ts
import * as Comlink from 'comlink';
const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
const api = Comlink.wrap<typeof import('./worker').api>(worker);
button.onclick = async () => {
setLoading(true);
const result = await api.parseAndValidate(bigJson);
setData(result);
setLoading(false);
};
For quick validation of the JSON output from a worker, pipe the result through our browser JSON validator or JSON schema validator during development.
Hydration Cost Reduction
SSR pages look fast until hydration starts. Every component that hydrates delays interactivity. Two proven patterns for 2026:
Islands architecture (Astro, Fresh)
Only interactive components ship JavaScript. The rest is static HTML. A typical product page drops 200KB of JS and gains 80-120ms of INP headroom.
---
// Astro component
import Hero from './Hero.astro'; // static
import AddToCart from './AddToCart.tsx'; // interactive island
---
<Hero {...product} />
<AddToCart client:idle sku={product.sku} />
Partial hydration (React Server Components)
Server components never hydrate. Client components do. Mark only what needs to be interactive.
// app/product/[id]/page.tsx — server component
import AddToCart from './AddToCart';
async function ProductPage({ params }) {
const product = await db.product.findUnique({ where: { id: params.id } });
return (
<>
<ProductInfo data={product} /> {/* server */}
<AddToCart sku={product.sku} /> {/* client */}
</>
);
}
Case Study: SaaS Dashboard 340ms to 118ms
A multi-tenant SaaS dashboard (200K MAU, mostly desktop, some mobile) hit us with p75 INP of 340ms after the March 2026 update. Rankings on their main commercial term dropped from 3 to 9 in six weeks. Diagnostics with long-animation-frame observer pointed to three culprits.
- Datadog RUM + Segment + Intercom + Hotjar on every page. All four initialized synchronously on load and added ~140ms of main-thread work. Fix: consolidate to Datadog RUM only (which already collects what Segment and Hotjar were giving us). Kept Intercom but gated behind a consent + first-interaction delay. Saved 110ms.
- Chart library re-rendered a full dashboard on every filter change. The chart did a 180ms layout thrash on each keystroke in the filter input. Fix:
useDeferredValueon the filter state and memoize the chart input. Saved 80ms. - Huge Redux action dispatched on every table row click. It synchronously computed 30 selectors. Fix: split the reducer so only the affected slice updated, and moved the expensive selectors behind
reselectwith proper memoization. Saved 50ms.
Total improvement: 222ms. Final p75 INP: 118ms. Rankings recovered to position 4 within 8 weeks of the CrUX data catching up.
The pattern repeats: most INP fixes aren't heroic. They're plumbing. Remove scripts you don't need. Defer work that doesn't need to run now. Memoize things that don't change. The tools already exist; using them is the discipline.
Deploy Checklist
- Install web-vitals and pipe INP to RUM endpoint.
- Audit third-party scripts with Coverage tab. Remove unused.
- Move non-essential tags to Partytown or consent-gate them.
- Wrap expensive state updates in startTransition / useDeferredValue.
- Add scheduler.yield() to loops that process > 25 items.
- Move CPU-heavy work to Web Workers via Comlink.
- Lazy-load below-the-fold components.
- Use server components or islands for static sections.
- Measure p75 INP in CrUX, not the lab score.
- Wait 28-56 days for field data to update after deploy.
Supplement the code work with an asset audit: run hero images through our image compressor, convert to AVIF, and minify bundle files with HTML minifier and CSS minifier. LCP gains compound with INP fixes because less work to paint means more budget for interactions.
Frequently Asked Questions
What is the new INP threshold in 2026?
150ms for "good" (dropped from 200ms). 150-500ms is "needs improvement". Above 500ms is "poor". Site-level aggregation also landed in the same update.
Does useTransition work inside event handlers?
Yes, but it only helps for the state updates wrapped inside startTransition. Synchronous work outside startTransition still runs on the critical path. Combine both patterns.
Can I fix INP without a framework rewrite?
Usually yes. Most real-world INP problems come from third-party scripts and long synchronous handlers. Audit scripts, chunk long loops with scheduler.yield, and lazy-load heavy components. Framework changes (SSR, islands) help but aren't required.
Is useDeferredValue better than debouncing?
Different trade-offs. Debouncing delays the render until typing stops. useDeferredValue renders immediately with the old value while computing the new one in the background. For search boxes where users expect progressive results, useDeferredValue feels snappier.
Does React Compiler help INP?
React Compiler automatically memoizes components, which reduces wasted re-renders. Expected INP gain: 10-30ms on interaction-heavy pages. Not a silver bullet but free after you enable it.
How do I debug a 200ms click that only happens in production?
Enable Long Animation Frame reporting in RUM. It captures the slow interaction with a stack trace of the long-running scripts. You can also turn on React Profiler in production for 1% of sessions to capture render costs of specific components.
Further Reading
- web.dev's INP deep dive.
- Long Animation Frames API documentation.
- React useTransition reference.
- Partytown for moving third-party scripts off the main thread.
- Core Web Vitals 2026 ranking filter guide.
- Image optimization complete guide for LCP partner work.
- SEO audit toolkit masterclass for the broader ranking context.
150ms isn't unreasonable. It's just unforgiving. Sites that shipped slim, well-hydrated code three years ago are already passing. Sites that accumulated ten years of tags and a monolithic React app are struggling. The playbook is the same either way: remove what you don't need, defer what you can, move CPU work off the main thread. The code in this post is the minimum viable version. Ship a piece this week; measure in six.