Skip to main content
CodeRocket
Performancehighweb-vitals

Optimize interaction to next paint

rule · interaction-to-next-paint

Interaction to Next Paint measures responsiveness throughout the user session.

Code Example

JavaScript
// 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

TypeScript
// 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))
}
TSX
// 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

TSX
// 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

JavaScript
// 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

TypeScript
// 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

JavaScript
// 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