Skip to content

Optimizing INP: The Five Patterns That Fix Interaction Latency

INP is the hardest of the three Core Web Vitals to hit. Five patterns cover most of what we ship to fix it.

John Cravey with AIFounder4 min readUpdated Jul 6, 2026

Interaction to Next Paint (INP) became a Core Web Vital in March 2024, replacing First Input Delay. It’s a tougher metric — it measures the worst-case input latency across the entire user session, not just the first interaction. Sites that comfortably passed FID often fail INP. Here are the five patterns we use to fix it on FH client sites.

Free estimate · 2 minutes

Slow pages lose rankings and leads. Price the fix.

Build the estimate to see what a fast rebuild would look like for your site and what we would ship in the first thirty days.

What INP measures

Every user input (click, tap, keypress) triggers a chain of JavaScript work followed by a render. INP measures the total time from input to the next visible paint. The site’s INP is reported as the 75th percentile of all interactions across the session. Target: under 200ms.

Why INP is hard

Modern JavaScript-heavy sites do too much work on the main thread. Every click triggers React re-renders, state updates, side effects. If any of those take longer than ~50ms, the render is delayed. The first interaction is usually fine; the 95th-percentile interaction (during scroll, while a third-party script is loading, while a video is buffering) is where INP fails.

Pattern 1: break up long tasks with yielding

A long task is any synchronous JavaScript that runs for more than 50ms. If a click handler does 200ms of work synchronously, every other interaction during those 200ms is blocked. Break long tasks into chunks that yield to the browser between them.

async function processItemsWithYield(items: Item[]) {
  for (let i = 0; i < items.length; i++) {
    processItem(items[i]);
    if (i % 50 === 0) {
      // Yield to the browser every 50 items
      await new Promise((r) => setTimeout(r, 0));
    }
  }
}

// Better: use scheduler.yield() (modern API)
async function processItemsWithSchedulerYield(items: Item[]) {
  for (const item of items) {
    processItem(item);
    if ("scheduler" in window && "yield" in (window as any).scheduler) {
      await (window as any).scheduler.yield();
    }
  }
}

Pattern 2: defer heavy work with startTransition

React 18+ ships `useTransition` and `startTransition` to mark state updates as non-urgent. The user input gets handled immediately; the heavy work happens during browser idle time. INP drops because the render isn’t blocked on the heavy work.

"use client";
import { useState, useTransition } from "react";

function SearchBox() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<Item[]>([]);
  const [isPending, startTransition] = useTransition();

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    setQuery(e.target.value);  // urgent — input must respond immediately
    startTransition(() => {
      setResults(searchExpensively(e.target.value));  // non-urgent
    });
  }

  return <input value={query} onChange={handleChange} />;
}

Pattern 3: debounce expensive input handlers

If a search input triggers an API call on every keystroke, fast typing causes a backlog of pending requests and frequent state updates. Debounce: wait until the user pauses (300ms) before triggering the expensive work.

import { useEffect, useState } from "react";

function useDebouncedValue<T>(value: T, delay = 300): T {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const t = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(t);
  }, [value, delay]);
  return debounced;
}

Pattern 4: lazy-load heavy third-party scripts

Chat widgets, tag managers, analytics scripts can each block the main thread for hundreds of ms when they execute. Don’t load them at page-load time. Load on idle (`requestIdleCallback`) or on user interaction (first scroll, first click).

// Next.js Script with strategy="lazyOnload" defers until the page is idle
<Script
  src="https://intercom.io/widget.js"
  strategy="lazyOnload"
/>

Pattern 5: avoid layout-thrashing in event handlers

Reading layout properties (offsetHeight, getBoundingClientRect) inside a tight loop forces the browser to recalculate layout, which is slow. Batch reads, then batch writes. Or use ResizeObserver / IntersectionObserver instead of polling layout properties.

Measuring INP in production

Lighthouse reports lab INP, which is an estimate. Real field INP comes from Chrome’s CrUX report (visible in Search Console) or via the Web Vitals JS library that you embed and ship to your analytics.

import { onINP } from "web-vitals";

onINP((metric) => {
  // Send to analytics
  fetch("/api/inp", {
    method: "POST",
    body: JSON.stringify({
      value: metric.value,
      id: metric.id,
      entries: metric.entries.length,
    }),
  });
});

INP-killer libraries we’ve removed

  • framer-motion: replaced with native View Transitions where possible. INP drops 60-100ms on heavy pages.
  • Heavy chart libraries running on the main thread during initial render. Lazy-load or render to canvas instead.
  • Polyfills for features the user’s browser already supports. Modern browsers don’t need them.
  • Old jQuery plugins running on every page. Anyone still shipping these in 2026 is paying an INP tax.

Component-level optimization

  • Memoize expensive render functions with useMemo where the recomputation cost is real.
  • Wrap callback props in useCallback to prevent unnecessary child re-renders.
  • Use React.memo on leaf components that re-render often with the same props.
  • Avoid context for state that changes often — Context updates re-render every consumer.

When INP fails on mobile but not desktop

Common. Mobile CPUs are 3-5x slower than desktop. Any code that runs in 50ms on desktop can take 200ms+ on a low-end Android. Always test on a real mid-tier mobile device, not just a desktop with throttled CPU.

When 200ms isn’t the right target

Some interactions genuinely take longer than 200ms even with perfect optimization (a search that returns thousands of results, a complex calculation). Show optimistic UI: render the result skeleton immediately, fill it in asynchronously. The user perceives sub-100ms response even though the underlying work takes longer.

How this lands across FH client work

Every FH client site ships with INP under 150ms in field data. The combination of: server-by-default components (less client JS), React Compiler (auto-memoization), lazy-loaded third-party scripts, debounced inputs, and native CSS animations — adds up to consistently fast interactions. If your site’s INP is in the ‘needs improvement’ or ‘poor’ bucket, book a consultation — the remediation usually ships in a single sprint.

Answers

Frequently asked questions

What does Interaction to Next Paint measure?

The delay between a user acting and the page visibly responding, across all interactions on the page, reported near the worst case. It captures the feeling of a page that is loaded but not listening, which the older responsiveness metric largely missed.

Why is this metric hard to fix?

Because it is caused by the main thread being busy rather than by any single asset. There is no one file to shrink. The work is finding what occupies the thread when someone taps, which is diffuse and often comes from code nobody thinks of as heavy.

What does yielding to the main thread do?

It breaks a long task into pieces so the browser can respond between them. A single long task blocks every interaction for its duration; the same work split into chunks lets a tap be handled in the gap. It is the highest-return pattern for this metric.

When should work be deferred as a transition?

When an interaction triggers an expensive update that does not need to be instant, such as filtering a large list. Marking it as non-urgent lets the immediate feedback paint first and the heavy update follow, which is what makes the interface feel responsive.

Which input handlers need debouncing?

Any that run expensive work on every keystroke or scroll event: search-as-you-type, live validation, anything recalculating layout. Debouncing collapses a burst of events into one piece of work, which is often the difference between typing smoothly and typing into treacle.

How do third-party scripts affect this?

Heavily, because they execute on the same thread as your code and you do not control what they do. Chat widgets, tag managers, and advertising scripts are common offenders. Deferring them until after interaction is possible usually improves the metric more than optimizing your own code.

What is layout thrashing?

Reading a layout property and then writing one repeatedly in the same handler, forcing the browser to recalculate layout each time. It turns a cheap-looking loop into a long task. Batching the reads and then the writes fixes it without changing what the code does.

How do I measure this in production?

With field data from real visitors, because it depends entirely on their devices. Lab measurement tells you a page can be fast on a good machine. The metric that counts is what a mid-range phone on a poor connection experienced.

Why does it fail on mobile but not desktop?

Because the same JavaScript takes several times longer on a mid-range phone's processor. A task that is imperceptible on a laptop blocks a phone for hundreds of milliseconds. Testing only on developer hardware is how this metric passes locally and fails in the field.

Are there libraries worth removing outright?

Any heavyweight component library used for one small feature, and animation libraries loaded eagerly for effects below the fold. The question to ask is what this dependency does that a smaller approach cannot, and the answer is often that nobody has checked.

Is 200 milliseconds always the right target?

It is the threshold to pass and not always the goal. Interactions during a genuinely heavy operation may legitimately take longer, and what matters then is feedback: showing that something is happening. A slow response with no acknowledgement is what feels broken.

Where should I start on a failing page?

Find the longest task during interaction, then look at what is executing. In practice it is usually a third-party script or an event handler doing more than it needs to, and fixing one of those two moves the metric more than a general optimization pass.

Question we did not answer? Ask us directly and we will answer it here.

John Cravey, Founder
Written by
John Cravey
Founder

Founder of Frontend Horizon. Writes most of the long-form work on the FH blog.

Newer post
Server Actions for Lead Forms: Replacing Your API Routes Without Losing Sleep
Older post
RAG for SMB Sites: When Retrieval-Augmented Generation Actually Solves a Real Problem
Keep reading

More from the blog

SEO·10 min

Automated Technical SEO Audits: Crawl, Score, and Fix With AI

A once-a-year audit finds problems a year too late. The point of automating it is that it never stops looking.

Next.js·10 min

Speed Is a Ranking Factor: The Next.js Performance Checklist by Business Size

Google measures your speed with real users and ranks you on it. Next.js hands you the tools to win. Most sites leave them switched off.

Performance·4 min

Core Web Vitals 2026: The Metrics That Matter and the Targets That Hold

Three numbers. Hit them and you’re competing on content; miss them and you’re competing one hand tied.