Guideadvanced

INP Optimization: Advanced Techniques for Sub-200ms Interactions

Advanced strategies for achieving Good INP scores including scheduler APIs, rendering optimization, framework-specific patterns, and real-user monitoring approaches.

Rankwise Team·Updated Mar 30, 2026·4 min read

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

PriorityUse CaseExample
user-blockingDirect response to user actionUpdating clicked button state
user-visibleImportant but not blocking inputLoading content below fold
backgroundNon-urgent workAnalytics, 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, offsetHeight
  • scrollTop, scrollLeft, scrollWidth, scrollHeight
  • clientTop, clientLeft, clientWidth, clientHeight
  • getComputedStyle()
  • 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-window or @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/dynamic with ssr: false for client-only interactive widgets
  • Implement useOptimistic for instant UI feedback on mutations

Vue

  • Use v-memo to skip re-rendering static list items
  • Prefer shallowRef for large objects that change identity but not content
  • Use defineAsyncComponent for 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 Typep75 INPStatus
Homepage145msGood
Product pages312msNeeds improvement
Blog posts89msGood
Dashboard487msPoor

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.

Part of the SEO Fundamentals topic

Newsletter

Stay ahead of AI search

Weekly insights on GEO and content optimization.