Skip to main content
Beta: Front-End Checklist is currently in beta. Some issues are still being fixed. Thanks for your patience.

Optimize interaction to next paint

Page responds to user interactions within 200ms, ensuring good responsiveness.

Utilities
Quick take
Typical fix time 30 min
  • INP measures responsiveness to ALL interactions—target under 200ms
  • Break up long tasks into smaller chunks
  • Use web workers for heavy computation
  • Optimize event handlers and avoid blocking the main thread
Why it matters: INP replaced FID as a Core Web Vital in 2024—it measures how quickly your page responds to user interactions throughout the session, not just the first interaction.

Rule Details

Interaction to Next Paint measures responsiveness throughout the user session.

Code Example

// Bad: One long synchronous task
function processLargeArray(items) {
  items.forEach(item => {
    // Heavy processing blocks main thread
    expensiveOperation(item)
  })
}
 
// Good: Yield to main thread periodically
async function processLargeArrayAsync(items) {
  for (let i = 0; i < items.length; i++) {
    expensiveOperation(items[i])
 
    // Yield every 50ms to allow interactions
    if (i % 100 === 0) {
      await yieldToMain()
    }
  }
}
 
function yieldToMain() {
  return new Promise(resolve => {
    setTimeout(resolve, 0)
  })
}
 
// Even better: Use scheduler API
async function processWithScheduler(items) {
  for (const item of items) {
    expensiveOperation(item)
 
    // Yield if there's pending user input
    if ('scheduler' in window) {
      await scheduler.yield()
    }
  }
}

Why It Matters

INP replaced FID as a Core Web Vital in 2024—it measures how quickly your page responds to user interactions throughout the session, not just the first interaction.

INP Score Thresholds

ScoreRatingUser Perception
0–200msGoodInstant feedback
200–500msNeeds improvementNoticeable delay
> 500msPoorSluggish, frustrating

INP vs FID

MetricMeasuresReplaced
INPAll interactions throughout sessionNo
FIDFirst interaction onlyYes (by INP)

Common INP Issues

IssueImpactSolution
Long tasks (>50ms)HighBreak into chunks
Heavy JavaScriptHighCode split, web workers
Complex event handlersMediumDebounce, optimize
Layout thrashingMediumBatch DOM reads/writes
Third-party scriptsMediumLazy load, defer

Use Web Workers for Heavy Computation

// worker.ts
self.onmessage = (e: MessageEvent) => {
  const { data, type } = e.data
 
  if (type === 'process') {
    // Heavy computation happens off main thread
    const result = heavyComputation(data)
    self.postMessage({ type: 'result', data: result })
  }
}
 
function heavyComputation(data: any[]) {
  // Complex processing that would block main thread
  return data.map(item => expensiveTransform(item))
}
// React component using web worker
import { useEffect, useRef, useState } from 'react'
 
function DataProcessor({ data }) {
  const workerRef = useRef<Worker>()
  const [result, setResult] = useState(null)
 
  useEffect(() => {
    workerRef.current = new Worker(
      new URL('./worker.ts', import.meta.url)
    )
 
    workerRef.current.onmessage = (e) => {
      if (e.data.type === 'result') {
        setResult(e.data.data)
      }
    }
 
    return () => workerRef.current?.terminate()
  }, [])
 
  useEffect(() => {
    if (data && workerRef.current) {
      workerRef.current.postMessage({ type: 'process', data })
    }
  }, [data])
 
  return <div>{result ? <Results data={result} /> : <Loading />}</div>
}

Optimize Event Handlers

// Bad: Heavy work in click handler
function BadButton() {
  const handleClick = () => {
    // Blocks main thread during interaction
    const result = expensiveCalculation()
    updateUI(result)
  }
 
  return <button onClick={handleClick}>Process</button>
}
 
// Good: Defer heavy work
function GoodButton() {
  const handleClick = () => {
    // Show immediate feedback
    setLoading(true)
 
    // Defer heavy work
    requestIdleCallback(() => {
      const result = expensiveCalculation()
      updateUI(result)
      setLoading(false)
    })
  }
 
  return <button onClick={handleClick}>Process</button>
}
 
// Better: Use startTransition for non-urgent updates
import { startTransition } from 'react'
 
function BetterButton() {
  const handleClick = () => {
    // Urgent: Show loading state
    setLoading(true)
 
    // Non-urgent: Can be interrupted
    startTransition(() => {
      const result = expensiveCalculation()
      setData(result)
      setLoading(false)
    })
  }
 
  return <button onClick={handleClick}>Process</button>
}

Avoid Layout Thrashing

// Bad: Multiple forced reflows
function badLayoutCode(elements) {
  elements.forEach(el => {
    const height = el.offsetHeight // Read (forces layout)
    el.style.height = height + 10 + 'px' // Write (invalidates layout)
  })
}
 
// Good: Batch reads and writes
function goodLayoutCode(elements) {
  // Batch all reads
  const heights = elements.map(el => el.offsetHeight)
 
  // Batch all writes
  elements.forEach((el, i) => {
    el.style.height = heights[i] + 10 + 'px'
  })
}

Debounce Frequent Events

// Debounce scroll/resize handlers
function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState(value)
 
  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay)
    return () => clearTimeout(timer)
  }, [value, delay])
 
  return debouncedValue
}
 
// Usage
function SearchComponent() {
  const [query, setQuery] = useState('')
  const debouncedQuery = useDebounce(query, 300)
 
  useEffect(() => {
    if (debouncedQuery) {
      // Only search after user stops typing
      performSearch(debouncedQuery)
    }
  }, [debouncedQuery])
 
  return <input onChange={e => setQuery(e.target.value)} />
}

Measuring INP

// Using web-vitals library
import { onINP } from 'web-vitals'
 
onINP(metric => {
  console.log('INP:', metric.value, 'ms')
  console.log('Interaction type:', metric.entries[0]?.name)
 
  if (metric.value > 200) {
    // Log for debugging
    console.warn('Slow interaction detected:', {
      value: metric.value,
      target: metric.entries[0]?.target,
    })
  }
})

Verification

Automated Checks

  • Use Chrome DevTools Performance panel
  • Check PageSpeed Insights for field data
  • Run Lighthouse (shows INP in newer versions)

Manual Checks

  • Test with CPU throttling (6x slowdown)
  • Monitor real user metrics with RUM

Use with AI

Copy these prompts to use with your AI assistant, or install the MCP server to use directly from Claude, Cursor, or Windsurf.

Check

Verify implementation

Measure INP using Chrome DevTools or PageSpeed Insights. Verify interaction responses occur within 200ms.

Fix

Auto-fix issues

Optimize event handlers, break up long tasks, use web workers for heavy computation, and reduce main thread blocking.

Explain

Learn more

Explain how INP measures overall page responsiveness by tracking the latency of all user interactions during a page visit.

Review

Code review

Review the routes, assets, and loading behavior that affect Optimize interaction to next paint. Flag exact files, requests, or rendering steps that add unnecessary network, CPU, or layout cost, and describe the measurement method used to confirm the issue.

Sources

References used to support the guidance in this rule.

Further Reading

Tools and supplementary material for exploring the topic in more depth.

PageSpeed Insights
pagespeed.web.devTool

Rules that often go hand-in-hand with this one.

Optimize largest contentful paint

The largest content element loads within 2.5 seconds for a good user experience.

Performance
Optimize first contentful paint

First content renders within 1.8 seconds, providing quick visual feedback that the page is loading.

Performance
Keep page load time under 3 seconds

Page fully loads in under 3 seconds on a standard connection.

Performance

Was this rule helpful?

Your feedback helps improve rule quality. This stays internal for now.

Loading feedback...
0 / 385