Guideintermediate

Optimizing INP: A Practical Guide to Faster Page Interactions

Step-by-step guide to improving Interaction to Next Paint scores with task chunking, event handler optimization, and third-party script management techniques.

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

Interaction to Next Paint (INP) measures how quickly your page responds to user interactions — clicks, taps, key presses. A Good INP score (under 200ms) means every interaction feels instant. A Poor score (over 500ms) means users experience visible lag when they try to do anything.

This guide walks through a systematic approach to diagnosing and fixing INP problems.


Step 1: Measure Your Baseline

Before optimizing, know where you stand.

Field Data (What Matters for Rankings)

Check your real-user INP in Google Search Console under Core Web Vitals → Interactivity. This data comes from Chrome User Experience Report (CrUX) and represents actual user experience over 28 days.

Lab Testing (For Debugging)

Open Chrome DevTools → Performance panel. Record yourself interacting with the page — click buttons, open dropdowns, submit forms, type in inputs. Look for interactions where total duration exceeds 200ms.

The Web Vitals Chrome extension displays INP in real-time as you interact with the page, highlighting each interaction's duration.


Step 2: Identify the Bottleneck Phase

Each interaction has three phases. Identify which one is slow:

Input Delay

The time between the user's input and the event handler starting. This is high when the main thread is busy with other work (long tasks, third-party scripts).

Diagnosis: In DevTools Performance panel, look for long tasks (red corners) happening right before your interaction marker.

Fixes:

  • Break long tasks using scheduler.yield() or setTimeout(fn, 0)
  • Defer non-critical JavaScript with async/defer attributes
  • Move heavy initialization to idle callbacks with requestIdleCallback

Processing Time

How long the event handler itself takes to run. This is high when handlers do expensive DOM operations, complex state calculations, or synchronous network requests.

Diagnosis: Click on the interaction in DevTools and expand the call stack. Identify which functions take the most time.

Fixes:

  • Debounce rapid input handlers (search-as-you-type, resize)
  • Move heavy computation to Web Workers
  • Avoid forced synchronous layout (reading layout properties after writing them)
  • Use requestAnimationFrame for visual updates

Presentation Delay

Time for the browser to recalculate styles, run layout, and paint after the handler finishes. This is high on pages with large DOM trees or complex CSS.

Diagnosis: In DevTools, look for long "Recalculate Style" and "Layout" entries after your event handler completes.

Fixes:

  • Reduce DOM size (target under 1,500 nodes)
  • Avoid triggering layout on large portions of the page
  • Use CSS contain property to limit layout scope
  • Prefer transform and opacity for animations (compositor-only properties)

Step 3: Optimize Long Tasks

Long tasks (over 50ms) are the most common cause of poor INP because they block the main thread, preventing the browser from processing user input.

Task Chunking with yield()

Break long synchronous operations into smaller chunks that yield to the browser:

async function processLargeList(items) {
  for (let i = 0; i < items.length; i++) {
    processItem(items[i])

    // Yield every 5 items to let the browser handle interactions
    if (i % 5 === 0) {
      await scheduler.yield()
    }
  }
}

If scheduler.yield() isn't available in your target browsers, use the fallback:

function yieldToMain() {
  return new Promise(resolve => setTimeout(resolve, 0))
}

Code Splitting

Load JavaScript only when needed. Route-based and component-based code splitting prevent unused code from blocking interactions.

// Instead of importing everything upfront
const HeavyComponent = lazy(() => import("./HeavyComponent"))

Web Workers for Heavy Computation

Move CPU-intensive work off the main thread entirely:

// main.js
const worker = new Worker("processor.js")
worker.postMessage({ data: largeDataSet })
worker.onmessage = e => updateUI(e.data)

// processor.js
self.onmessage = e => {
  const result = expensiveCalculation(e.data)
  self.postMessage(result)
}

Step 4: Manage Third-Party Scripts

Third-party scripts (analytics, ads, chat widgets) often contribute 30-50% of main thread blocking time.

Audit Impact

  1. Open DevTools → Performance panel
  2. Record a page load and several interactions
  3. Group by third-party domain to see which scripts consume the most time
  4. Compare INP with and without specific scripts (use Chrome's request blocking)

Mitigation Strategies

StrategyWhen to Use
async or deferScripts that don't need to run synchronously
Facade patternChat widgets, video embeds (load on first interaction)
Tag manager delayed loadingNon-critical analytics and tracking
Remove entirelyScripts providing minimal value vs. performance cost

Example: Lazy-Loading a Chat Widget

<!-- Instead of loading immediately -->
<script>
  document.addEventListener(
    "click",
    function loadChat() {
      const script = document.createElement("script")
      script.src = "https://chat-widget.example.com/widget.js"
      document.body.appendChild(script)
      document.removeEventListener("click", loadChat)
    },
    { once: true }
  )
</script>

Step 5: Optimize Event Handlers

Debounce Rapid Inputs

For search boxes, resize handlers, and scroll-driven updates:

let timeout
input.addEventListener("input", e => {
  clearTimeout(timeout)
  timeout = setTimeout(() => {
    performSearch(e.target.value)
  }, 150)
})

Avoid Layout Thrashing

Reading layout properties (offsetHeight, getBoundingClientRect) after writing style changes forces the browser to recalculate layout synchronously:

// Bad: Forces layout recalculation
element.style.width = "100px"
const height = element.offsetHeight // Forces sync layout

// Better: Read first, then write
const height = element.offsetHeight
element.style.width = "100px"

Use Passive Event Listeners

For scroll and touch handlers that don't call preventDefault():

element.addEventListener("scroll", handleScroll, { passive: true })

Step 6: Monitor and Iterate

Set Up Real User Monitoring

Use the web-vitals library to track INP in your analytics:

import { onINP } from "web-vitals"

onINP(metric => {
  analytics.track("web_vital", {
    name: metric.name,
    value: metric.value,
    rating: metric.rating,
    entries: metric.entries.map(e => ({
      name: e.name,
      duration: e.duration
    }))
  })
})

Track Progress

  • Check CrUX data monthly (28-day rolling window)
  • Set alerts for INP regression above 200ms
  • Test INP after every deploy that changes JavaScript

FAQ

How long does it take for INP improvements to show in Search Console? CrUX data uses a 28-day rolling window. After deploying fixes, expect 4-6 weeks for full reflection in Search Console and PageSpeed Insights.

What's the difference between TBT and INP? TBT (Total Blocking Time) is a lab metric that measures total main thread blocking during page load. INP is a field metric that measures actual interaction responsiveness throughout the session. TBT is the best lab proxy for INP but they don't map 1:1.

Should I optimize for INP or TBT? Both. Use TBT in development (Lighthouse, CI pipelines) as a fast feedback loop. Monitor INP in production (CrUX, RUM) as the metric that actually affects rankings.

Can React/Next.js apps achieve Good INP? Yes, but it requires attention to hydration performance, state management efficiency, and avoiding expensive re-renders on interaction. React 18's concurrent features and useTransition help by deprioritizing non-urgent updates.

Part of the SEO Fundamentals topic

Newsletter

Stay ahead of AI search

Weekly insights on GEO and content optimization.