Getting INP under 200ms on simple pages is straightforward — defer scripts, break up long tasks, optimize handlers. But complex applications with rich interactivity, heavy state management, and dozens of third-party integrations require advanced techniques.
This guide covers optimization strategies for teams that have already addressed the basics and need to push INP from "Needs Improvement" to "Good."
The Scheduler API: Prioritized Task Management
The Scheduler API (scheduler.postTask() and scheduler.yield()) gives you fine-grained control over task priority on the main thread.
Priority Levels
| Priority | Use Case | Example |
|---|---|---|
user-blocking | Direct response to user action | Updating clicked button state |
user-visible | Important but not blocking input | Loading content below fold |
background | Non-urgent work | Analytics, prefetching |
Yielding During Long Tasks
async function processData(items) {
const results = []
for (const item of items) {
results.push(transform(item))
// Yield with user-visible priority so user interactions take precedence
await scheduler.yield()
}
return results
}
Scheduling Non-Urgent Work
// Analytics and tracking can wait
scheduler.postTask(
() => {
trackPageView(pageData)
},
{ priority: "background" }
)
// UI updates from user action are urgent
scheduler.postTask(
() => {
updateSearchResults(query)
},
{ priority: "user-blocking" }
)
Fallback for Unsupported Browsers
const yieldToMain = globalThis.scheduler?.yield
? () => scheduler.yield()
: () => new Promise(resolve => setTimeout(resolve, 0))
Rendering Optimization
CSS Containment
The contain property tells the browser that an element's subtree is independent, limiting the scope of style recalculation and layout:
/* Limit layout/paint scope for independent components */
.card {
contain: layout style;
}
/* Full containment for off-screen sections */
.below-fold-section {
content-visibility: auto;
contain-intrinsic-size: auto 500px;
}
content-visibility: auto is particularly powerful — the browser skips rendering for off-screen content entirely, reducing layout costs when interactions trigger style recalculation.
Avoiding Forced Reflows
Certain property reads force the browser to synchronously compute layout. In event handlers, this creates a cascade:
Layout-triggering reads (partial list):
offsetTop,offsetLeft,offsetWidth,offsetHeightscrollTop,scrollLeft,scrollWidth,scrollHeightclientTop,clientLeft,clientWidth,clientHeightgetComputedStyle()getBoundingClientRect()
Pattern to avoid:
// Forces layout twice per loop iteration
elements.forEach(el => {
const width = el.offsetWidth // Read → force layout
el.style.width = width * 2 + "px" // Write → invalidate layout
})
Fixed pattern:
// Batch reads, then batch writes
const widths = elements.map(el => el.offsetWidth)
elements.forEach((el, i) => {
el.style.width = widths[i] * 2 + "px"
})
Virtual Scrolling for Long Lists
Rendering thousands of DOM nodes degrades INP because every interaction triggers style recalculation across the entire tree. Virtual scrolling renders only visible items:
- React:
react-windowor@tanstack/react-virtual - Vue:
vue-virtual-scroller - Vanilla: Intersection Observer + dynamic DOM insertion
Target: keep rendered DOM under 1,500 nodes for optimal interaction performance.
Framework-Specific Optimization
React
Use useTransition for non-urgent updates:
function SearchComponent() {
const [query, setQuery] = useState("")
const [results, setResults] = useState([])
const [isPending, startTransition] = useTransition()
function handleChange(e) {
setQuery(e.target.value) // Urgent: update input immediately
startTransition(() => {
setResults(filterResults(e.target.value)) // Non-urgent: can be interrupted
})
}
return (
<>
<input value={query} onChange={handleChange} />
{isPending ? <Spinner /> : <ResultsList results={results} />}
</>
)
}
Memoize expensive renders:
const ExpensiveList = memo(function ExpensiveList({ items }) {
return items.map(item => <ComplexCard key={item.id} item={item} />)
})
Avoid re-rendering from context:
Split context into frequently-changing values (user input) and stable values (theme, config). Use useSyncExternalStore for external state to prevent unnecessary re-renders.
Next.js
- Use Server Components for static content — zero client-side JavaScript
- Stream responses with Suspense to reduce hydration blocking
- Use
next/dynamicwithssr: falsefor client-only interactive widgets - Implement
useOptimisticfor instant UI feedback on mutations
Vue
- Use
v-memoto skip re-rendering static list items - Prefer
shallowReffor large objects that change identity but not content - Use
defineAsyncComponentfor below-fold interactive sections
Third-Party Script Isolation
Web Worker Proxy (Partytown)
Move third-party scripts entirely off the main thread using Partytown or a similar library:
<script type="text/partytown">
// This analytics script runs in a Web Worker
(function() {
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'GA_ID');
})();
</script>
Interaction-Triggered Loading
Load scripts only when a user first interacts with the relevant feature:
const chatButton = document.getElementById("chat-trigger")
chatButton.addEventListener(
"click",
async function initChat() {
chatButton.textContent = "Loading..."
const { initWidget } = await import("./chat-widget.js")
initWidget()
chatButton.removeEventListener("click", initChat)
},
{ once: true }
)
Resource Hints for Anticipated Scripts
If a script will load on interaction, use preconnect to establish the connection early:
<link rel="preconnect" href="https://chat-widget.example.com" />
Real-User Monitoring Strategy
Attribution for Debugging
The web-vitals library provides attribution data that identifies the exact element and handler causing poor INP:
import { onINP } from "web-vitals/attribution"
onINP(metric => {
if (metric.rating !== "good") {
console.log("Slow interaction:", {
element: metric.attribution.interactionTarget,
type: metric.attribution.interactionType,
inputDelay: metric.attribution.inputDelay,
processingDuration: metric.attribution.processingDuration,
presentationDelay: metric.attribution.presentationDelay,
longestScript: metric.attribution.longAnimationFrameEntries?.[0]
})
}
})
Page-Level Segmentation
Aggregate INP by page template to identify which page types need work:
| Page Type | p75 INP | Status |
|---|---|---|
| Homepage | 145ms | Good |
| Product pages | 312ms | Needs improvement |
| Blog posts | 89ms | Good |
| Dashboard | 487ms | Poor |
Regression Detection
Set up alerting for INP regressions in your CI/CD pipeline:
- Run Lighthouse in CI with TBT budget assertions
- Monitor CrUX weekly for origin-level INP changes
- Alert when p75 INP crosses 200ms threshold
FAQ
Is scheduler.yield() supported in all browsers?
As of 2026, it's supported in Chromium browsers. For Safari and Firefox, use the setTimeout(fn, 0) fallback. The behavior is similar for INP purposes since CrUX only measures Chrome users.
How much does virtual scrolling improve INP? For pages with 1,000+ list items, virtual scrolling typically reduces INP by 40-60% by keeping rendered DOM under 100 items regardless of total list size.
Should I use Partytown for all third-party scripts? No. Partytown proxies DOM access through a synchronous communication channel, which can break scripts that rely on immediate DOM reads. Test each script individually. It works well for analytics and tracking, less reliably for interactive widgets.
What INP target should I set for a complex SPA? Aim for under 200ms (Good threshold). For rich interactive applications like dashboards or editors, 150-200ms is realistic. Under 100ms typically requires significant architectural investment.