Skip to content
Malik Hamza Shabbir
Web Performanceinpcore web vitalsreactperformance

INP Is Now an Equal Ranking Signal and 'Good' LCP Dropped to 2.0s: A React Engineer's Fix List

HSMalik Hamza ShabbirUpdated 9 min read

In short

As of 18 March 2026, Google made INP a co-equal ranking signal and lowered the "Good" LCP bar to 2.0s. The fixes are React architecture work: mark non-urgent updates with useTransition, break long tasks with scheduler.yield(), turn on the React Compiler, stream the LCP element server-side, and put third-party scripts on a hard budget. Measure with field data from CrUX, not lab tools. If a page is badly tangled, that is the kind of thing I fix through app rescue and optimization.

INP Is Now an Equal Ranking Signal and 'Good' LCP Dropped to 2.0s: A React Engineer's Fix List
On this page

As of 18 March 2026, Google Search Central confirmed two changes that hit React apps hard: INP (Interaction to Next Paint) is now an equal ranking signal alongside LCP and CLS, and the threshold for a "Good" LCP score dropped from 2.5s to 2.0s. If your React app passed Core Web Vitals last quarter, there is a real chance it does not anymore. The fix is not a config flag. It is JS-architecture work: breaking up long tasks, deferring non-urgent state updates, cutting hydration cost, and putting third-party scripts on a budget.

I have spent the last few years pulling slow React and Next.js apps out of the red, and INP is the metric that exposes lazy front-end engineering more than any other. Here is the triage list I actually run.

What changed on 18 March 2026 and why it matters more for React?

Google made INP a co-equal ranking signal with LCP and CLS, and lowered the "Good" LCP bar from 2.5s to 2.0s. For React specifically this matters because INP measures interaction responsiveness, and interaction responsiveness is exactly where heavy client-side JavaScript and hydration cost show up.

INP replaced FID (First Input Delay) as a Core Web Vital back in March 2024, but until now it sat slightly behind LCP in how Google weighted it in practice. Treating it as an equal signal means a slow interaction can drag a page down even when it paints fast. Around 43% of sites still fail the 200ms "Good" INP threshold, and React-heavy sites are over-represented in that group because the framework does a lot of work on the main thread per interaction.

Here are the current thresholds after the change:





The LCP change is the quieter trap. A page sitting at 2.3s was "Good" on 17 March and "Needs improvement" on 18 March without a single line of code changing. Pull your field data before you assume you are still passing.

How is INP different from FID, and why does my React app fail it?

INP measures the full latency of an interaction from input to the next paint, across the entire visit, not just the first one. FID only measured input delay on the first interaction, so it forgave almost everything React does after a click.

INP captures three phases: input delay (the main thread is busy when you click), processing time (your event handlers and React re-renders), and presentation delay (the browser painting the result). React apps usually fail in the middle phase. A single click triggers a state update, which triggers a synchronous re-render of a large component tree, which blocks the main thread long enough to push you past 200ms.

The classic culprit is an expensive update fired on a high-frequency event. A search box that re-renders a 500-row list on every keystroke. A filter that recalculates a derived dataset inside render. A controlled form where every keypress walks a deep context provider. None of these showed in FID. All of them show in INP.

What are the React-specific INP fixes, in priority order?

Fix the main-thread work per interaction first: break long tasks, mark non-urgent updates with transitions, and stop re-rendering trees that do not need to change. These three account for most INP failures I see.

Start with useTransition and useDeferredValue. If a state update drives an expensive render, mark it non-urgent so React keeps the input responsive and renders the heavy part without blocking the paint.

JSX
import { useState, useTransition } from 'react';

function ProductSearch({ allProducts }) {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState(allProducts);
  const [isPending, startTransition] = useTransition();

  function handleChange(e) {
    // Urgent: keep the input painting immediately.
    setQuery(e.target.value);
    // Non-urgent: the expensive filter render is deferred.
    startTransition(() => {
      setResults(allProducts.filter(p => p.name.includes(e.target.value)));
    });
  }

  return (
    <>
      <input value={query} onChange={handleChange} />
      <ResultList items={results} dimmed={isPending} />
    </>
  );
}

The input now repaints on the urgent update while the list render happens off the critical path. That difference is often the gap between 240ms and 90ms.

Next, break long tasks. Any task over 50ms on the main thread is a "long task" and blocks input. If a handler does heavy synchronous work, yield to the browser so it can paint queued interactions. The modern way is scheduler.yield() where supported, with a setTimeout fallback:

JS
async function processBatch(items) {
  for (const chunk of chunkify(items, 50)) {
    doWork(chunk);
    // Yield so the browser can handle pending input and paint.
    if ('scheduler' in window && 'yield' in scheduler) {
      await scheduler.yield();
    } else {
      await new Promise(r => setTimeout(r, 0));
    }
  }
}

Third, stop the avoidable re-renders. Memoize the components and values that sit downstream of a frequent update. The React Compiler (stable as of React 19) auto-memoizes for you, which I will come back to, but if you are not on it yet, React.memo, useMemo, and stable callbacks on the hot path are still the move. Profile first with the React DevTools Profiler so you memoize the component that is actually expensive, not the one that looks expensive.

Does the React Compiler actually help INP?

Yes. The React Compiler automatically memoizes components and hook values at build time, which cuts the re-render work that drives INP processing time, without you hand-writing useMemo and useCallback everywhere. It is the single highest-leverage INP change for a large existing React codebase.

The reason it helps INP specifically: most React INP failures are wasted re-renders triggered by an interaction. The compiler skips re-rendering subtrees whose inputs did not change, so each click does less main-thread work. You enable it through the Babel plugin and let it run across your tree.

JS
// babel.config.js
module.exports = {
  plugins: [
    ['babel-plugin-react-compiler', { /* defaults are fine to start */ }],
  ],
};

One honest caveat: the compiler is only as good as your code's adherence to the Rules of React. If components mutate props or read refs during render, the compiler bails out of optimizing them and you get nothing. Run the ESLint plugin, fix the violations it flags, and you will see the compiler actually engage. I treat "clean up Rules of React violations" and "turn on the compiler" as one task, not two.

If you are auditing a Next.js app, this overlaps with how crawlers see your JavaScript. A heavy client bundle hurts both INP and machine readability, which I dug into in why your Next.js SPA is invisible to AI crawlers .

How do I hit the new 2.0s LCP bar with React?

Get the largest element rendered server-side and streamed early, and stop blocking it behind JavaScript. The 2.0s bar punishes any LCP image or hero text that waits for hydration before it paints.

Streaming SSR with React Server Components is the biggest lever here. With Suspense boundaries, the server flushes the above-the-fold content immediately and streams the slower parts after, so the LCP element is in the first chunk of HTML.

JSX
// app/page.jsx (Next.js App Router)
import { Suspense } from 'react';

export default function Page() {
  return (
    <>
      {/* LCP element: server-rendered, in the first flush, no JS wait. */}
      <Hero />
      <Suspense fallback={<FeedSkeleton />}>
        {/* Slow data streams in after, does not block LCP. */}
        <PersonalizedFeed />
      </Suspense>
    </>
  );
}

Then protect the LCP element directly. Set priority on the Next.js for your hero so it gets a fetchpriority="high" and is not lazy-loaded. Preconnect to the image CDN. Inline the critical font or use font-display: optional so the LCP text does not wait on a font swap.

JSX
import Image from 'next/image';

<Image src="/hero.webp" alt="..." width={1200} height={600} priority />

The pattern that breaks the 2.0s bar most often is a hero image discovered late because it is inside a client component that hydrates before it requests the image. Move the LCP element out of the client boundary and into the server tree.

What is a realistic third-party script budget?

Treat third-party JavaScript as a hard budget, not a wishlist: every analytics, chat, A/B, and tag-manager script competes for the same main thread that INP measures. My working rule is that third-party scripts should not own more than about 20% of main-thread time during the interaction window, and anything non-essential loads after first interaction.

Tag managers are the worst offenders because they pull in more scripts you never audited. Here is the budget I apply on a rescue:






MetricGoodNeeds improvementPoorWhat it measures
LCP<= 2.0s2.0s to 4.0s> 4.0sLargest content paint
INP<= 200ms200ms to 500ms> 500msWorst interaction latency
CLS<= 0.10.1 to 0.25> 0.25Layout shift
Script typeLoading strategyINP cost
Analytics (GA, etc.)next/script strategy afterInteractive or workerLow if deferred
Chat / support widgetLazy load on scroll or click intentHigh if eager
A/B testingInline only the assignment, defer the restHigh, causes CLS too
Tag managerAudit contents, set a hard tag limitVery high

In Next.js, push non-critical scripts down with the Script component and consider Partytown for the heaviest offenders so they run in a web worker instead of the main thread:

JSX
import Script from 'next/script';

<Script src="https://example.com/analytics.js" strategy="afterInteractive" />

Measure the cost before and after with the Long Animation Frames API or the performance panel. If a script adds 80ms to your worst interaction, it is buying its way out of "Good" INP, and you get to decide whether that vendor is worth a ranking drop.

How do I measure INP correctly before and after a fix?

Use field data (real users) as the source of truth and lab data only to reproduce and debug. INP is a field metric, and lab tools cannot replay the messy multi-interaction sessions where it actually fails.

Pull your real-user numbers from the Chrome User Experience Report (CrUX) or the Search Console Core Web Vitals report, segmented by mobile and desktop, because mobile main threads are slower and fail INP first. For local debugging, the web-vitals library attributes which element and interaction caused your worst INP, which is what you actually need to fix it:

JS
import { onINP } from 'web-vitals/attribution';

onINP((metric) => {
  console.log(metric.value, metric.attribution.interactionTarget);
}, { reportAllChanges: true });

That interactionTarget tells you the exact DOM node that caused the slow interaction, so you stop guessing. The same field-versus-lab discipline matters for AI visibility too, since the way pages are structured and served changes what gets cited, which I covered in what query fan-out means for page structure .

When a CWV failure is tangled up with bundle size, dead client components, and years of accumulated re-renders, that is the kind of mess I take on as a focused engagement through my app rescue and optimization work. If you want a second pair of eyes on a specific failing page, my contact page is open and I usually reply within a day.

The triage order I actually follow

If I had to compress the whole list into the sequence I run on a real app:

  1. Pull field INP and LCP from CrUX or Search Console, mobile first.

  2. Confirm the new 2.0s LCP bar did not silently flip you to "Needs improvement."

  3. Attribute your worst INP interaction with web-vitals/attribution.

  4. Wrap expensive non-urgent updates in useTransition / useDeferredValue.

  5. Break any long task over 50ms with scheduler.yield().

  6. Clean up Rules of React violations and turn on the React Compiler.

  7. Move the LCP element into the server tree and stream it with Suspense.

  8. Put third-party scripts on a hard budget and defer or worker-load them.


INP being a co-equal signal is good news for engineers who care about how their apps actually feel. The same work that fixes your ranking fixes the snappiness your users notice. The teams that treated FID as a freebie are the ones with work to do now, and most of that work is the React architecture they kept putting off.

FAQ

Is INP now an equal Core Web Vitals ranking signal?

Yes, as of Google Search Central's 18 March 2026 update, INP is weighted as a co-equal ranking signal alongside LCP and CLS.

What is the new 'Good' LCP threshold?

Google lowered the 'Good' LCP threshold from 2.5s to 2.0s, so pages between 2.0s and 2.5s now fall into 'Needs improvement'.

What is the 'Good' INP threshold for React apps?

An INP of 200ms or less is 'Good', and roughly 43% of sites still fail that threshold, with React-heavy sites over-represented.

Does the React Compiler improve INP?

Yes, the React Compiler auto-memoizes components and values at build time, which cuts the wasted re-render work that drives most React INP failures.

Should I use lab or field data to measure INP?

Use field data from CrUX or Search Console as the source of truth, since INP is a real-user metric that lab tools cannot reliably reproduce.

Working on something like this?

I build web apps, AI features, and mobile products for clients. If this article matches a problem you have, tell me about it.

Start a conversation
HS

Malik Hamza Shabbir · Full-Stack & AI Engineer

I build full-stack and AI products solo: a reputation SaaS in production, RAG pipelines, and React Native apps. I write from what I ship, not from documentation summaries.

Related articles