Interaction to Next Paint (INP) is the newest Core Web Vitals metric, set to replace First Input Delay (FID) in March 2024. While FID only measures the first interaction, INP observes all interactions throughout a user's visit and reports the worst latency. This comprehensive approach better represents the actual responsiveness users experience across their entire session.
What is Interaction to Next Paint?
INP measures the time from when a user initiates an interaction (click, tap, or key press) until the browser paints the next frame showing visual feedback. It captures the full interaction lifecycle:
- Input delay: Time until event handlers start
- Processing time: JavaScript execution duration
- Presentation delay: Time to compute layout and paint
The metric reports the worst interaction latency (with statistical outlier removal), providing a realistic assessment of a page's responsiveness throughout its lifecycle.
Google's INP thresholds are:
- Good: 200ms or less
- Needs Improvement: 200ms to 500ms
- Poor: More than 500ms
These thresholds ensure that interactions feel responsive across the 75th percentile of page loads.
Why INP Matters: The Evolution from FID
Comprehensive Responsiveness Measurement
FID's limitation was measuring only the first interaction's input delay, missing:
- Subsequent interactions that might be slower
- Processing time after input delay
- Visual feedback delay
INP addresses these gaps by:
- Measuring all user interactions
- Including full interaction duration
- Capturing presentation delay
- Representing ongoing responsiveness
Google's research shows that INP correlates better with user-perceived responsiveness than FID. Pages with good INP scores see:
- 28% higher user satisfaction scores
- 23% longer session durations
- 19% better task completion rates
Real-World Impact
Early adopters optimizing for INP report significant improvements:
RedBus improved INP by 72% and saw:
- 7% increase in sales
- Significantly improved user experience scores
The Economic Times reduced INP from 1,000ms to 200ms:
- 50% reduction in bounce rate
- 43% increase in page views
Tokopedia achieved INP under 200ms:
- 55% better interaction metrics
- 23% increase in add-to-cart actions
SEO Implications
As INP becomes a Core Web Vital in March 2024, it will directly impact:
- Search rankings (especially mobile)
- Featured snippet eligibility
- Top Stories inclusion for news sites
- Google Discover visibility
How INP Works: Technical Architecture
Interaction Types and Measurement
INP measures discrete interactions:
Measured Interactions:
- Mouse clicks
- Touchscreen taps
- Keyboard key presses (physical or onscreen)
Not Measured:
- Hovering (mouse or stylus)
- Scrolling (considered continuous)
- Zooming (continuous action)
The Interaction Lifecycle
Each interaction follows this sequence:
User Input → Input Delay → Processing → Presentation Delay → Next Paint
↑ ↓ ↓ ↓ ↓
Start Event Handler JS Execution Layout/Style Visual Update
Input Delay Phase:
- Waiting for main thread availability
- Affected by other JavaScript execution
- Influenced by long tasks
Processing Phase:
- Event handler execution
- State updates
- DOM manipulation
- Network requests initiated
Presentation Delay Phase:
- Style recalculation
- Layout computation
- Paint preparation
- Compositor work
INP Calculation Algorithm
INP uses sophisticated calculation to avoid outliers:
- Collect all interactions during page lifetime
- Group related events (e.g., pointerdown, pointerup, click)
- Calculate duration for each interaction group
- Select representative value:
- For <50 interactions: Worst interaction
- For ≥50 interactions: 98th percentile
- Report as INP score
This approach ensures one extremely slow interaction doesn't unfairly penalize otherwise responsive pages.
Browser APIs and Implementation
INP relies on the Event Timing API:
// Observe interaction performance
new PerformanceObserver(list => {
for (const entry of list.getEntries()) {
if (entry.interactionId) {
// This is an interaction
const inp = entry.processingEnd - entry.startTime
console.log(`Interaction duration: ${inp}ms`)
console.log(`Type: ${entry.name}`)
console.log(`Target: ${entry.target}`)
}
}
}).observe({ type: "event", buffered: true })
Best Practices for Optimizing INP
1. Minimize JavaScript Execution Time
Break Up Long Tasks
// Bad: Blocking task
function processAllItems(items) {
items.forEach(item => {
complexProcessing(item) // 10ms per item
})
}
// Good: Yielding periodically
async function processItemsWithYield(items) {
for (const item of items) {
complexProcessing(item)
// Yield to browser every 50ms
if (performance.now() % 50 < 10) {
await scheduler.yield()
}
}
}
// Better: Using scheduler.postTask
async function processWithPriority(items) {
for (const item of items) {
await scheduler.postTask(
() => {
complexProcessing(item)
},
{ priority: "background" }
)
}
}
Debounce Event Handlers
// Bad: Executing on every input
input.addEventListener("input", e => {
performExpensiveSearch(e.target.value)
})
// Good: Debounced execution
const debouncedSearch = debounce(value => {
performExpensiveSearch(value)
}, 300)
input.addEventListener("input", e => {
debouncedSearch(e.target.value)
})
2. Optimize Event Handlers
Use Event Delegation
// Bad: Multiple listeners
document.querySelectorAll(".button").forEach(btn => {
btn.addEventListener("click", handleClick)
})
// Good: Single delegated listener
document.addEventListener("click", e => {
if (e.target.closest(".button")) {
handleClick(e)
}
})
Passive Event Listeners
// Enable browser optimizations
document.addEventListener("touchstart", handler, { passive: true })
document.addEventListener("wheel", handler, { passive: true })
Avoid Forced Synchronous Layouts
// Bad: Read-write-read pattern
function moveElements(elements) {
elements.forEach(el => {
const left = el.offsetLeft // Forces layout
el.style.left = left + 10 + "px" // Invalidates layout
})
}
// Good: Batch reads and writes
function moveElementsOptimized(elements) {
// Batch reads
const positions = elements.map(el => el.offsetLeft)
// Batch writes
elements.forEach((el, i) => {
el.style.left = positions[i] + 10 + "px"
})
}
3. Reduce Input Delay
Minimize Main Thread Work
// Move heavy computation to Web Worker
const worker = new Worker("processor.js")
button.addEventListener("click", async () => {
// Show immediate feedback
button.classList.add("loading")
// Offload heavy work
worker.postMessage({ cmd: "process", data })
worker.onmessage = e => {
updateUI(e.data)
button.classList.remove("loading")
}
})
Use requestIdleCallback for Non-Critical Work
// Schedule non-critical updates
requestIdleCallback(
() => {
updateAnalytics()
preloadNextPageResources()
cleanupOldData()
},
{ timeout: 2000 }
)
4. Optimize Rendering Performance
Use CSS Containment
/* Isolate layout calculations */
.component {
contain: layout style paint;
}
/* Strict containment for independent widgets */
.widget {
contain: strict;
content-visibility: auto;
}
Optimize Animations
/* Bad: Animating layout properties */
.menu {
transition: height 0.3s;
}
/* Good: Transform and opacity only */
.menu {
transform: scaleY(0);
transform-origin: top;
transition: transform 0.3s;
}
.menu.open {
transform: scaleY(1);
}
Use will-change Sparingly
/* Prepare for animation */
.modal {
will-change: transform, opacity;
}
/* Remove after animation */
.modal.settled {
will-change: auto;
}
5. Implement Loading States
Optimistic UI Updates
// Immediate visual feedback
async function likePost(postId) {
// Optimistic update
const likeButton = document.querySelector(`#like-${postId}`)
likeButton.classList.add("liked")
likeButton.disabled = true
try {
await api.likePost(postId)
} catch (error) {
// Revert on failure
likeButton.classList.remove("liked")
likeButton.disabled = false
showError("Failed to like post")
}
}
Progressive Enhancement
<!-- Functional without JavaScript -->
<form action="/search" method="GET">
<input name="q" type="search" />
<button type="submit">Search</button>
</form>
<script type="module">
// Enhance when JavaScript loads
import { EnhancedSearch } from "./search.js"
new EnhancedSearch(document.querySelector("form"))
</script>
Common INP Issues and Solutions
Issue 1: Third-Party Scripts
Problem: Analytics, ads, and widgets blocking the main thread
Solutions:
// Load third-party scripts after interaction
let scriptsLoaded = false
function loadThirdPartyScripts() {
if (scriptsLoaded) return
// Load analytics
const script = document.createElement("script")
script.src = "https://analytics.example.com/script.js"
script.async = true
document.body.appendChild(script)
scriptsLoaded = true
}
// Load on first interaction
;["click", "scroll", "touchstart"].forEach(event => {
document.addEventListener(event, loadThirdPartyScripts, {
once: true,
passive: true
})
})
Issue 2: React Re-rendering
Problem: Unnecessary re-renders blocking interactions
Solutions:
// Use React.memo for expensive components
const ExpensiveComponent = React.memo(
({ data }) => {
return <ComplexVisualization data={data} />
},
(prevProps, nextProps) => {
// Custom comparison
return prevProps.data.id === nextProps.data.id
}
)
// Use useMemo for expensive calculations
const ProcessedData = () => {
const data = useSelector(state => state.data)
const processed = useMemo(() => {
return expensiveProcessing(data)
}, [data.id]) // Only recalculate when ID changes
return <Display data={processed} />
}
// Use useTransition for non-urgent updates
const SearchResults = () => {
const [query, setQuery] = useState("")
const [isPending, startTransition] = useTransition()
const handleSearch = e => {
// Urgent: Update input immediately
setQuery(e.target.value)
// Non-urgent: Update results
startTransition(() => {
updateSearchResults(e.target.value)
})
}
return (
<>
<input onChange={handleSearch} value={query} />
{isPending && <Spinner />}
<Results />
</>
)
}
Issue 3: Large DOM Updates
Problem: Updating large lists or tables blocks the main thread
Solutions:
// Virtual scrolling for large lists
import { VirtualList } from "@tanstack/react-virtual"
const LargeList = ({ items }) => {
const virtualizer = useVirtual({
size: items.length,
parentRef,
estimateSize: useCallback(() => 50, [])
})
return (
<div ref={parentRef} style={{ height: 400, overflow: "auto" }}>
<div style={{ height: virtualizer.totalSize }}>
{virtualizer.virtualItems.map(virtualRow => (
<div
key={virtualRow.index}
style={{
position: "absolute",
top: 0,
transform: `translateY(${virtualRow.start}px)`
}}
>
<Item data={items[virtualRow.index]} />
</div>
))}
</div>
</div>
)
}
Tools for Measuring and Debugging INP
Chrome DevTools
Performance Panel:
- Record interactions
- Identify long tasks
- Analyze interaction breakdown
- View main thread activity
Performance Monitor:
- Real-time INP tracking
- CPU usage monitoring
- Layout shifts per second
Web Vitals Library
import { onINP } from "web-vitals"
onINP(metric => {
console.log("INP:", metric.value)
console.log("Rating:", metric.rating) // 'good', 'needs-improvement', 'poor'
// Get attribution data
const attribution = metric.attribution
console.log("Event type:", attribution.eventType)
console.log("Event target:", attribution.eventTarget)
console.log("Input delay:", attribution.inputDelay)
console.log("Processing duration:", attribution.processingDuration)
console.log("Presentation delay:", attribution.presentationDelay)
})
Field Data Collection
// Custom RUM implementation
class INPMonitor {
constructor() {
this.interactions = []
this.observer = new PerformanceObserver(this.handleEntries.bind(this))
this.observer.observe({ type: "event", buffered: true })
}
handleEntries(list) {
for (const entry of list.getEntries()) {
if (entry.interactionId) {
this.interactions.push({
duration: entry.duration,
type: entry.name,
timestamp: entry.startTime,
target: entry.target?.tagName
})
}
}
}
getINP() {
if (this.interactions.length === 0) return 0
const sorted = [...this.interactions].sort(
(a, b) => b.duration - a.duration
)
const index = Math.min(
Math.floor(this.interactions.length * 0.98),
this.interactions.length - 1
)
return sorted[index].duration
}
}
Frequently Asked Questions
How is INP different from FID?
FID only measures the input delay of the first interaction, while INP measures the full duration (input delay + processing + presentation delay) of all interactions throughout the page session, reporting the worst one.
What's a realistic INP target for complex applications?
While Google recommends <200ms as "good," complex applications should:
- Aim for <200ms on simple interactions (buttons, links)
- Accept 200-300ms for complex operations (filters, searches)
- Never exceed 500ms for any interaction
- Provide immediate visual feedback for longer operations
Can scrolling affect INP?
No, scrolling is considered a continuous action and doesn't affect INP. However, scroll-triggered JavaScript that blocks other interactions can indirectly impact INP scores.
How do I optimize INP for mobile devices?
Mobile optimization requires special attention:
- Test on real devices (3-5x slower CPUs than desktop)
- Reduce JavaScript bundle sizes
- Use touch-optimized event handling
- Account for slower network speeds
- Consider reduced motion preferences
Will improving INP hurt other metrics?
When done correctly, INP optimization improves overall performance:
- Better code splitting can improve LCP
- Reduced JavaScript can improve FID
- Proper rendering optimization benefits all metrics
Balance is key—don't sacrifice initial load performance for interaction performance or vice versa.