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 EleviFounder4 min read

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.

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.

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

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.

Performance·4 min

Critical CSS and Font Loading: The Last 200ms of LCP

Once you’ve fixed images and JS, font and CSS strategy is the next LCP lever. Here’s the pattern.

Professional Services·2 min

Site Speed and Core Web Vitals for Professional Services Firms

Speed is not a nice-to-have. It is the first impression, the ranking factor, and the conversion lever, all at once.