Skip to content
aviral gupta

Fix INP in Next.js App Router, Keep the Animations

Bad INP in a Next.js App Router site is almost never caused by the animation. It is caused by JavaScript the browser had no reason to download, and you fix it by shrinking the client boundary, not by deleting motion.

Written by Aviral GuptaPublished 9 min read
  • Core Web Vitals
  • INP
  • Next.js
  • App Router
  • Performance
Fix INP in Next.js App Router, Keep the AnimationsLIGHTHOUSE MOBILE · HOME · 08.2026TBT106ms≤ 200msCLS0.000≤ 0.10LCP2.56s≤ 2.5sBUDGET

What does INP actually measure?

Interaction to Next Paint measures one thing: the gap between touching the page and the browser painting a frame that shows something happened. Chrome records that for every click, tap and key press in a visit, then reports close to the worst one. The documented threshold is 200ms at the 75th percentile of visits, and anything over 500ms is rated poor (web.dev on INP).

That single number is three unrelated problems stacked on top of each other, and the fix for each one is different:

  • Input delay — the main thread was busy when the user clicked, so your handler could not even start. Hydration, third-party tags and long tasks live here.
  • Processing duration — the handler ran, React re-rendered, and that took time. Component tree size and where you put state live here.
  • Presentation delay — the work is done but the browser still cannot paint. Style recalculation, layout and compositing live here.

Two of the three have nothing to do with your event handler, which is why "the animations are heavy, take them out" is usually the wrong diagnosis. A CSS transform animation runs on the compositor and contributes nothing to any of the three buckets. A 250KB client bundle parsing while someone taps a nav link contributes to all of them. Before you touch a keyframe, find out which bucket you are in.

Why does Lighthouse pass while INP fails?

Lighthouse never interacts with your page. It loads it, measures paint, and reports Total Blocking Time as a stand-in for responsiveness. TBT is a decent proxy for the first two seconds and a poor one for everything after. A route can score 98 in the lab and still fail INP in the field, because the interaction that fails is an accordion someone opens forty seconds in, on a phone with a quarter of your laptop’s single-core speed.

The field number is the one Google uses. Chrome collects it from real users, aggregates a rolling 28-day window, and publishes the 75th percentile in CrUX; Search Console’s Core Web Vitals report reads the same dataset. So the feedback loop is slow — you ship a fix, the graph moves weeks later — which is why every Core Web Vitals engagement I take on gets its own RUM beacon on day one, and why I never sign one off on a Lighthouse screenshot.

This site is the specimen for the rest of the post. It runs GSAP ScrollTrigger reveals on every section, a Framer Motion accordion, a word-mask heading animation with a staggered delay per word, and a marquee. In Lighthouse mobile against production it records a total blocking time of 106ms and a CLS of 0.000, not because the motion was removed, but because almost none of it touches the main thread and almost none of the page is a Client Component. Largest contentful paint is 2.56s, over the 2.5s budget, and that is a payload and network problem rather than a motion problem. Those are lab numbers, which by the standard set out above are the weaker kind of evidence: this domain has no CrUX field data yet, so read them as an upper bound on what the motion costs rather than as a field result.

Three meters comparing this site against the Core Web Vitals budgets: total blocking time 106 milliseconds against a 200 millisecond budget, CLS 0.000 against 0.10, and largest contentful paint 2.56 seconds against a 2.5 second budget, which it exceeds.LIGHTHOUSE MOBILE · HOME · 08.2026TBT106ms≤ 200msCLS0.000≤ 0.10LCP2.56s≤ 2.5sBUDGET
Lighthouse mobile against production, home page, 21.08.2026, with GSAP, Framer Motion and the heading animation all still running. Total blocking time stands in for INP here because Lighthouse emits no lab INP.

How do you find which interaction is slow?

Guessing is expensive. The web-vitals library ships an attribution build that names the element, the event type and the three-way split, so you stop arguing about theories. Install it (npm i web-vitals) and mount this once.

'use client';

import {useEffect} from 'react';

/**
 * Logs every interaction over 16ms with its attribution breakdown.
 * Mount once in the root layout, behind an env check, and drive the UI.
 */
export function InpProbe() {
  useEffect(() => {
    let cancelled = false;

    import('web-vitals/attribution').then(({onINP}) => {
      if (cancelled) return;

      onINP(
        ({value, rating, attribution}) => {
          console.table({
            inp: Math.round(value),
            rating,
            event: attribution.interactionType,
            target: attribution.interactionTarget,
            inputDelay: Math.round(attribution.inputDelay),
            processing: Math.round(attribution.processingDuration),
            presentation: Math.round(attribution.presentationDelay)
          });
        },
        {reportAllChanges: true, durationThreshold: 16}
      );
    });

    return () => {
      cancelled = true;
    };
  }, []);

  return null;
}
src/components/InpProbe.tsx — a Client Component that reports nothing to the server.

Read the three columns, not the total. If inputDelay dominates, the main thread was blocked before your code ran — look at what is hydrating and what scripts are loading. If processing dominates, your React work is too big for one frame. If presentation dominates, you are thrashing layout or animating a property that forces one.

Which App Router patterns wreck INP?

The App Router makes it trivially easy to ship a server-rendered page that is also a fully hydrated client application, because one 'use client' at the top of a shared component pulls everything below it across the boundary. That single fact explains most failing Core Web Vitals in a Next.js codebase. These are the causes I find most often, mapped to what the attribution output looks like when each one is the problem.

INP symptoms, causes and fixes in a Next.js App Router codebase.
What attribution showsUsual causeFix
High input delay, early interactions onlyThe whole route hydrating while the user is already tappingKeep pages Server Components; hydrate islands, not layouts
High input delay, any interactionA tag manager, chat widget or scroll library holding the main threadLoad third-party scripts on first interaction; make scroll listeners passive
High processing durationOne state update re-rendering a large subtreeMove state down, or mark the non-urgent part with startTransition
High processing on every routeA client provider handed far more data than it needsPass only the slice client components read; split contexts
High presentation delayLayout thrash — measuring in an effect, then writing, in the same frameBatch all reads before writes, or use ResizeObserver
High presentation delay on animated sectionsAnimating width, height, top or box-shadowAnimate transform and opacity only
INP spikes long after an animation endedwill-change left on dozens of elements, pinning compositor layersAdd it immediately before the animation, remove it on transitionend
Fails on mobile, passes on desktopA JS budget set on laptop hardwareBudget per route and verify on a real device

What does one careless client provider cost?

This site is bilingual six ways over, and translations come from next-intl. The documented pattern is to wrap the app in NextIntlClientProvider so Client Components can call useTranslations. What the pattern does not advertise: given no messages prop, the provider defaults to getMessages() and serialises every loaded namespace into the RSC payload of every route.

Measured on the built artifact, the eight namespaces came to 88,908 bytes of JSON per locale. The homepage was carrying the full service catalogue, the entire privacy policy and the contact form’s message set — none of which it renders. The document weighed 269KB. Every byte of it had to be downloaded, parsed and walked by React during hydration, which is input delay by another name.

import type {ReactNode} from 'react';
import {NextIntlClientProvider} from 'next-intl';
import {getMessages, setRequestLocale} from 'next-intl/server';

export default async function LocaleLayout({
  children,
  params
}: {
  children: ReactNode;
  params: Promise<{locale: string}>;
}) {
  const {locale} = await params;
  setRequestLocale(locale);

  // Without a `messages` prop the provider calls getMessages() itself and
  // ships all eight namespaces to the browser on every route. Destructure
  // the one that Navbar, LanguageSwitcher and the banners actually read.
  const {common} = await getMessages();

  return (
    <html lang={locale}>
      <body>
        <NextIntlClientProvider messages={{common}}>
          {children}
        </NextIntlClientProvider>
      </body>
    </html>
  );
}
src/app/[locale]/layout.tsx — only the namespace client components actually read crosses the boundary.

The general rule is worth more than the specific fix: a provider is a serialisation boundary. Whatever you hand it becomes payload on every route it wraps, whether or not that route uses it. Audit your providers the way you would audit an API response.

Which components actually need "use client"?

Far fewer than you have. 'use client' is not a label meaning "this bit is interactive" — it is a boundary. Everything imported below it goes to the browser too. The cheapest INP work available in most App Router codebases is deleting the directive from components that never needed it.

  1. 1

    List every boundary

    Run grep -rn "use client" src/ and treat the result as a bill of materials. Each entry is a subtree you are paying to hydrate.

  2. 2

    Ask what makes it client

    State, effects, event handlers, browser APIs, or a library that uses them. If the answer is "it calls a translation hook", pass the strings in as props from the server instead.

  3. 3

    Try the platform first

    An FAQ accordion needs no JavaScript at all: <details> and <summary> are keyboard accessible, animate with CSS, and let the component stay on the server so the answers are in the HTML for crawlers.

  4. 4

    Push the boundary down

    A page that needs one interactive button should not be a Client Component. Keep the page on the server and make the button the island.

  5. 5

    Re-measure

    Check .next/ chunk sizes per route after each change, and re-run the probe. Bundle size is a leading indicator; the probe is the ground truth.

This is the same discipline behind every Next.js build I ship: the default is server, and every crossing is argued for. It costs nothing at authoring time and compounds on every route.

Which animations are free, and which are taxed?

The browser can animate transform and opacity on the compositor thread, without consulting the main thread at all. Those animations keep running smoothly while JavaScript is busy, and they add nothing to any of the three INP buckets. Animate anything else — width, height, top, margin, box-shadow, filter — and every frame forces style recalculation, layout or paint on the main thread, which lands directly in presentation delay.

So the constraint is not "no animation". It is: move things with transform, fade them with opacity, and use a class toggle plus IntersectionObserver rather than a scroll handler that runs on every frame.

.reveal {
  opacity: 0;
  transform: translate3d(0, 24px, 0);
  transition:
    opacity 560ms cubic-bezier(0.16, 1, 0.3, 1),
    transform 560ms cubic-bezier(0.16, 1, 0.3, 1);
}

/* Added by the observer one frame before the class below, removed on
   transitionend. A layer that is never released is a layer that costs
   memory for the rest of the session. */
.reveal[data-animating='true'] {
  will-change: opacity, transform;
}

.reveal[data-visible='true'] {
  opacity: 1;
  transform: none;
}

@media (prefers-reduced-motion: reduce) {
  .reveal {
    opacity: 1;
    transform: none;
    transition: none;
  }
}
A scroll reveal that never touches layout, with the motion preference honoured first.

Two more rules from the same family. Any scroll, touchstart or wheel listener must be registered {passive: true} so the browser never waits to see whether you will call preventDefault(). And never measure in an effect and write in the same pass: calling getBoundingClientRect() after a style write forces a synchronous layout, and doing it in a loop over a list is the single most reliable way to turn a 40ms interaction into a 400ms one.

prefers-reduced-motion belongs in the same section, not in an accessibility appendix. Honouring it is required by WCAG, and it is also a free performance mode for exactly the users most likely to be on constrained hardware — you skip the work entirely rather than doing it faster.

How do you stop a long task blocking the paint?

Sometimes the work is genuinely necessary — filtering a long list, building an index, initialising a chart. The problem is not that it takes 300ms, it is that it takes 300ms in one uninterrupted task, and the browser cannot paint or dispatch an event in the middle of a task. Break it up and yield.

type SchedulerLike = {yield?: () => Promise<void>};

/** scheduler.yield() where supported, setTimeout(0) everywhere else. */
function yieldToMain(): Promise<void> {
  const scheduler = (globalThis as {scheduler?: SchedulerLike}).scheduler;
  if (scheduler?.yield) return scheduler.yield();
  return new Promise((resolve) => setTimeout(resolve, 0));
}

export async function runInChunks<T>(items: T[], work: (item: T) => void) {
  let started = performance.now();

  for (const item of items) {
    work(item);

    if (performance.now() - started > 50) {
      await yieldToMain();
      started = performance.now();
    }
  }
}
Yield every 50ms so a tap can be acknowledged mid-computation.

scheduler.yield() is better than setTimeout(0) because it returns to the front of the queue rather than the back, so your remaining work is not overtaken by every other pending task. Where the work is not urgent at all — analytics, prefetching, warming a cache — requestIdleCallback is the right tool, and inside React, wrapping a heavy state update in startTransition lets the urgent paint go first.

The pattern to internalise: acknowledge the interaction in the first frame, then do the work. Set the pressed state, show the spinner, close the menu — paint that — and only then run the expensive part. INP stops at the first paint that reflects the interaction, so a fast acknowledgement is a real fix, not a trick.

How do you prove the fix actually worked?

Three sources, in increasing order of authority. Your local probe tells you within seconds whether the interaction you were chasing got faster. Your RUM beacon — the same onINP call, posted to an endpoint with navigator.sendBeacon instead of logged — tells you within a day whether it got faster for real users on real devices. CrUX and Search Console tell you within four to six weeks whether Google agrees, because the 28-day window has to roll over before the old data ages out.

Segment the RUM data by device class and route before you conclude anything. An INP that is fine at the median and terrible at the 75th percentile is nearly always one device tier and one route, and averaging hides it. That segmentation is also what makes the analytics layer worth having: an unverified measurement setup makes every before-and-after claim unfalsifiable.

There is a commercial reason to bother beyond the Search Console badge. Responsiveness feeds Google’s landing page experience rating, and an above-average rating correlates with a cost per click roughly 36% below average (Search Engine Land, 2023). Paid media budgets are where a slow interface bills you every month, quietly.

When does the animation genuinely have to go?

Sometimes it does, and pretending otherwise would be dishonest. Cut it when a library ships a large runtime for one effect — this site carries GSAP purely for an opacity-and-translate reveal that thirty lines of IntersectionObserver and the CSS above would do for free, and that trade is queued for exactly that reason. Cut it when the animation is driven by a scroll handler that recalculates positions every frame. Cut it when it animates a property that forces layout and the design cannot be re-expressed with transform. Cut it when it runs during hydration and competes with the work that makes the page usable.

What you should not do is remove motion as a first response to a red INP score, because in most App Router codebases the animation is not the cause and removing it changes nothing except the design. The order is: measure with attribution, shrink the client boundary, fix the property being animated, yield the long tasks, then and only then argue about the motion itself.

Most of my work is with businesses in the UAE, where e-commerce is projected to grow from USD 12.30bn in 2026 to USD 21.01bn by 2031 (Mordor Intelligence, 2026) — a market that shops on mid-range Android over mobile networks, which is precisely the hardware INP is sampling. If your Search Console report has gone red and someone has told you the animations have to go, send me the URL and I will run the attribution pass. Every project starts with an introductory project call, and you can see what shipped work looks like on the case studies or on the Dubai page.

What should you know about how I work on this?

No, and anyone promising that is selling something. Core Web Vitals are a small, confirmed part of Google’s page experience signals — they help decide between pages of comparable relevance, and they do not make an irrelevant page rank. What a fixed INP reliably does is remove a known negative, improve the experience for people already on the site, and stop Search Console flagging URLs as needing improvement. Treat the ranking effect as a side benefit and the usability effect as the actual return.

In your own RUM data, within a day of deployment. In CrUX and Search Console, four to six weeks. Google reports the 75th percentile over a rolling 28-day window, so the old slow sessions have to age out of the sample before the metric turns green — even if every visitor since the deploy has had a fast experience. Do not roll the change back because the graph has not moved in week two. Watch your own field data instead, and let the public number catch up.

By making the fast thing the default rather than a cleanup task. In practice: Server Components unless a component genuinely needs state or a browser API, providers that receive only the data they use, `transform` and `opacity` for anything that moves, passive listeners, third-party scripts deferred until interaction, and a per-route JavaScript budget checked at build. Performance treated as a phase at the end always loses to deadlines. Treated as a constraint the architecture already satisfies, it costs nothing to maintain.

Almost always fixed in place. INP problems are concentrated: one oversized client boundary, one blocking third-party script, one handler that thrashes layout. Finding them takes an attribution pass, and fixing them is usually a handful of files. A rebuild is only the right answer when the framework itself forces client-side rendering of everything, or when the codebase has no boundary discipline left to recover. I would rather tell you it is a two-day fix than sell you a rebuild you do not need.

Any of them, if they are used correctly — and none of them, if they are not. What matters is which properties are animated and how much runtime ships. A library animating `transform` and `opacity` on the compositor costs nothing at interaction time; the same library animating `height` on twenty elements costs you presentation delay on every frame. The second question is bundle weight: a full animation runtime for one fade is a bad trade, and CSS plus `IntersectionObserver` does that job for free.

It removes a specific, measurable form of friction: taps that appear to do nothing, so users tap again, double-submitting forms or abandoning the flow. That effect is real and it is largest on the interactions closest to money — filters, add-to-cart, form submission. I will not put a percentage on it for your site, because any honest number has to come from your own before-and-after with verified tracking. What I will do is instrument the test so the result means something.

Yes — a before-and-after in field data, not a Lighthouse screenshot. That means the attribution breakdown for the interactions that were failing, the RUM distribution by device class and route, and the CrUX or Search Console movement once the 28-day window has rolled over. The report also lists what was changed and why, so your developers can maintain it after I hand it back. A number with no explanation attached is not a report, it is marketing.

Thirty minutes, at no cost either way, looking at your live site together: the field data in CrUX, the interactions that are failing, the client boundaries in your bundle, and the two or three changes most likely to move the number. You get an honest read on whether the problem is small or structural, and a rough shape for the work. No obligation and no deck. If the answer is that your site is fine and the agency was wrong, I will tell you that.

// OPEN TO WORK

Hiring, or building something that needs an engineer?