Words that inspire, ideas that spark, stories that stay

How We Fixed INP on a Highly Interactive Next.js Homepage

Anand Patel

Anand Patel

Wednesday, September 16th, 20267 min read

How We Fixed INP on a Highly Interactive Next.js Homepage

Our homepage looked fine on paper. LCP was comfortably in the "good" range, CLS was basically zero, and nothing about the page felt broken when you loaded it.

But the second someone actually used the page, opened a country picker, expanded a popover, scrolled through a long list of destinations things got sluggish. Not broken, just... laggy. That's the gap Interaction to Next Paint (INP) is built to catch, and it's the metric that finally sent us digging.

This post walks through how we actually debugged it: from spotting the problem in real-user data, to reproducing it locally, to the two specific code changes that brought our field INP back under control.

A Quick Refresher on INP

INP measures how responsive your page feels when someone interacts with it a click, a tap, a keypress. Unlike LCP, which is all about loading, INP only cares about what happens after the user does something.

Roughly, an interaction breaks down into:

  1. Input delay — how long the browser waits before it can even start handling the interaction, usually because the main thread is busy with something else

  2. Processing time — the actual work your event handlers (and everything they trigger) do

  3. Presentation delay — the gap between finishing that work and the browser painting the result

Google considers 200ms or less "good." And the key thing to internalize is that INP isn't measuring how fast your onClick fires ,it's measuring the whole trip from tap to pixel.

 

Why an Interactive Homepage is Exactly Where This Bites You

Homepages tend to accumulate a lot of small interactive surface area: nav menus, filters, tabs, popovers, search, carousels, product pickers, third-party scripts. None of these looks expensive in isolation. Together, they add up.

Our specific offender turned out to be the popovers that, once opened, rendered a long, filterable list of countries along with some trip-length and date logic:

const INITIAL_COUNTRY_COUNT = 30;

const LOAD_MORE_COUNT = 30;

 

const MAX_TRIP_DAYS = 179;

const MAX_START_DATE_YEARS = 1;

 

Nothing here looks alarming on its own. The problem wasn't any single line, it was everything firing synchronously the instant the popover opened.

 

Start With Field Data, Not a Hunch

Before touching DevTools, we checked whether this was even a real problem for real users, using field data from the Chrome UX Report (CrUX), a rolling, 28-day aggregate of actual user sessions across real devices and networks.

It's worth separating field data from lab data early on:

  • Field data comes from real users, on their actual devices and connections, doing whatever they naturally do on your site.

  • Lab data comes from you, on your machine, deliberately reproducing one interaction under conditions you control.

The two numbers won't match, and that's fine ,they answer different questions. Field data tells you whether there's a problem and roughly where to look. Lab data lets you actually dig into why.

 

Field trend for LCP, INP, and CLS. LCP climbs out of the "poor" band over several months; INP stays inside "good" but hugs the upper edge.

In our case, the trend was the useful signal, not a single snapshot. Our CrUX history covering rolling 28-day windows from October 2025 through August 2026, showed LCP starting well into "poor" territory and steadily climbing out of it over several months as earlier loading fixes landed. INP, by contrast, had been sitting inside the "good" band for most of that window already, but the line was hugging the upper edge of it rather than sitting comfortably in the middle, with a slight uptick again toward the most recent weeks. CLS told a similar story: mostly flat near zero, with one early spike and a smaller recent rise.

That's an important nuance, and it's worth calling out: INP wasn't failing. It was passing with almost no margin. That reframed the goal, this wasn't about rescuing a broken metric, it was about giving a passing metric some breathing room before real-world variance pushed it over the line.

 

Pulling Field Data Into DevTools

One thing that made this a lot easier: recent Chrome DevTools versions let you pull CrUX field data directly into the Performance panel, so you can see it next to whatever you're measuring locally.

To set it up: Performance panel → Field data → Set up. Once it's connected, DevTools shows your local trace numbers right alongside the real-world p75.

 

The Field data section in the Performance panel, after connecting to CrUX.

Here's the comparison DevTools gave us, pulling local measurements next to the CrUX field data:

INP

Local:         16 ms

Field p75:    196 ms

 

That 196ms is technically "good", it's under the 200ms threshold, but only by 4 milliseconds. It's the kind of number that passes today and fails the next time the mix of devices in the field sample shifts slightly. Meanwhile, our local session on the same interaction came in at 16ms, a 12x gap. That gap told us two things. First, under ideal local conditions the picker felt totally fine , so this wasn't an obviously broken interaction. Second, real users, on real devices, with real background tab noise and slower CPUs, were feeling something we simply weren't reproducing by accident, and the metric had almost no margin left. We had to go looking for it before it tipped over the line.

 

Recording The Actual Interaction

One more useful detail from that same DevTools panel: it labels which kind of interaction is driving the field p75, and in our case it was flagged simply as pointer , consistent with a click or tap rather than a keypress or drag. That narrowed things down before we'd even opened a recording. Combined with the picker being the most visually "heavy" interactive element on the page, it was the obvious first place to record.

Next: Performance panel → Start recording → open the country picker → Stop recording.

Rather than eyeballing the whole trace, we started with the Interaction track and zoomed into the click that opened the popover. From there it's a matter of following the chain:

Click

  ↓

Event handler

  ↓

JS work

  ↓

React render

  ↓

Layout / paint

  ↓

Next frame

 

The trace showed a long, unbroken block of main-thread work starting the instant the popover opened, before the first frame of the popover ever painted. The browser was doing all the expensive setup work synchronously, on the same tick as the click, instead of getting something on screen first and filling in the rest after.

That's the pattern worth remembering: it's rarely one slow function. It's usually the shape of when work happens relative to the paint.

 

What Was Actually Slow

Two things stood out once we traced the long tasks back to source:

1. The full country list was rendered immediately.

Every time the popover opened, we were rendering the entire dataset in one pass, instead of showing a reasonably sized first batch and loading more as needed.

2. The popover's content was computed synchronously on open.

Trip-length bounds, date-range limits, and the filtered list were all calculated on the same tick as the click that opened the popover, which meant the paint had to wait for all of it.

Neither of these is exotic. They're the kind of thing that's easy to write without thinking twice, and easy to miss because it doesn't look expensive in a code review ,it only shows up as expensive once you watch it run.

 

The Fix: Don't Make The Click Wait For Everything

The fix ended up being pretty unglamorous, which is usually a good sign.

Cap the initial render, then load more.

Instead of rendering every country the moment the list appears, we render a fixed first batch and grow it from there:

const INITIAL_COUNTRY_COUNT = 30;

const LOAD_MORE_COUNT = 30;

 

The popover now has something real to paint almost immediately, and the rest of the list is appended in manageable chunks instead of one large synchronous pass.

Push the popover's content work off the interaction's critical path.

Rather than computing everything the instant the popover opens, we let the browser paint the popover shell first and defer the heavier content work to the next available idle-ish moment:

const deferPopoverContentRender = (callback: () => void) => {

  requestAnimationFrame(() => {

    setTimeout(callback, 0);

  });

};

 

The requestAnimationFrame call lets the browser get through its current rendering work and paint a frame first. The setTimeout(callback, 0) nested inside then pushes the actual content computation to a fresh macrotask, after that paint has had a chance to happen, rather than competing with it on the same tick. The visible result, the popover opening, shows up on screen before the more expensive list and range logic even starts running.

None of this required rewriting the picker or introducing a new state management pattern. It just meant being deliberate about what has to happen before the next paint, and what can happen right after it.

 

Checking That It Actually Worked, Locally First

Before trusting a "feels faster" impression, we re-ran the exact same recording: same interaction, same steps, Performance panel open, before-and-after traces side by side.

What we were looking for:

  • A shorter block of work between the click and the next paint

  • No single long task dominating the trace

  • The popover shell appearing before the full list finishes rendering

The local trace confirmed the shape had changed, the click now produced a fast paint, with the country list and range calculations trailing in afterward instead of blocking that first frame.

 

Then Checking It Against Real Users

Local traces are a sanity check, not the finish line. The real test is what happens once it ships and CrUX has enough real sessions to tell us something.

After deploying both changes, PageSpeed Insights and the DevTools field-data panel agreed on where things landed:

Metric

Local

Field 75th percentile

LCP

1.27 s

1.48 s

CLS

0.00

0.03

INP

16 ms

196 ms

 

Local session vs. field p75, pulled from the Performance panel's Field data view.

The number that mattered most to us was INP. Field p75 sits at 196ms still technically under the 200ms line, same as before we touched anything, but the local trace now shows a much wider safety margin: 16ms locally against 196ms in the field is a big gap, and it's the direction we wanted. The point of moving the picker's expensive work off the critical path wasn't to flip a red number green; it was to stop a passing metric from being one bad device or one busy tab away from failing. CLS is worth watching too, 0.03 is well within the "good" range, but the worst layout-shift cluster in our last session was 2 shifts, a reminder that the popover's own entrance can still nudge content if we're not careful with how the deferred content mounts.

We're treating this as a starting point, not a finish line,the trend view is a rolling 28-day window, so it takes a few weeks of real traffic before a code change is fully reflected in it. We're watching the next few CrUX updates rather than reacting to any single day's number.

 

The Workflow, Condensed

  1. Check field data before assuming anything

  2. Confirm INP is actually a problem, and watch the trend, not one number

  3. Narrow down which interaction is likely responsible

  4. Pull field data into DevTools for context

  5. Reproduce the interaction locally and record it in the Performance panel

  6. Start with the Interaction track, then follow the chain to long tasks

  7. Trace the expensive work back to actual source code

  8. Ask when the work happens relative to the paint, not just what it costs

  9. Move non-essential work off the critical path (defer, batch, or paginate it)

  10. Re-record the same interaction and compare traces

  11. Ship it

  12. Watch field data to confirm the fix held up for real users

 

Conclusion: What Stuck With Us

The lesson wasn't "this one function was slow." It was that a click can quietly trigger a whole chain of rendering and computation, and INP punishes anything sitting on that chain before the next paint  no matter how reasonable each individual piece looks in isolation.

In our case, the fix wasn't clever. It was rendering a smaller first batch of a list and deferring a couple of calculations by one animation frame and one tick. The metric didn't need to be rescued from failure, it needed room to breathe. Going from a 4-millisecond cushion above the "good" threshold to a local trace that's an order of magnitude faster than the field p75 is the kind of change that doesn't show up as a dramatic before/after screenshot, but it's exactly the kind of change that keeps a passing metric passing once real-world devices and networks get involved.

Booking form image

Struggling to choose the right Headless CMS & Headless Commerce tech stack?
 We’ll help you pick the best solution for your business! Exclusive Offer: 20 Hours of Free Development & Consultation


Book a Meeting

Frequently Asked Questions

Google considers 200ms or under "good" for Interaction to Next Paint. The field p75 here was 196ms — technically passing, but only 4ms of margin. A slight shift in the mix of real-world devices could have pushed it into "needs improvement," so the fix was about creating headroom, not rescuing a failing metric.

 

CrUX gives you a rolling 28-day aggregate of real user sessions, which tells you whether a problem exists and roughly where to look before you touch DevTools. Recent Chrome DevTools versions let you connect CrUX field data directly in the Performance panel, so your local trace numbers sit right next to the real-world p75 for comparison.

 

Local testing happens under ideal conditions — no background tab noise, no low-end CPU, no network variance. Field data reflects real devices and real usage patterns, which is exactly the gap INP is designed to expose. A 12x difference like this signals the interaction isn't broken, but it also isn't resilient to real-world conditions yet.

 

Move non-essential work off the interaction's critical path instead of optimizing the work itself. In this case, that meant capping the initial list render to a fixed batch and deferring heavier calculations with requestAnimationFrame plus a setTimeout(callback, 0), so the browser paints a response first and computes the rest afterward.

 

Related articles

You may also like

5 Reasons to Upgrade Your CMS Now

5 Reasons to Upgrade Your CMS Now

Read more
Author

Vipul Uthaiah

Saturday, March 29th, 2025

10 min read
Top 5 Best Alternatives to Sanity

Top 5 Best Alternatives to Sanity

Read more
Author

Vipul Uthaiah

Sunday, June 22nd, 2025

8 min read
7 Best AI SEO Agencies Shaping Visibility, Revenue, and Demand in Australia 2026

7 Best AI SEO Agencies Shaping Visibility, Revenue, and Demand in Australia 2026

Read more
Author

Vipul Uthaiah

Thursday, December 25th, 2025

Top 5 Next.js Development Agencies in Amsterdam 2026 for Modern Web Apps

Top 5 Next.js Development Agencies in Amsterdam 2026 for Modern Web Apps

Read more
Author

Vipul Uthaiah

Thursday, January 29th, 2026

Top 5 Prismic Development Agencies for 2026: Scalable, SEO-First & Composable CMS Experts

Top 5 Prismic Development Agencies for 2026: Scalable, SEO-First & Composable CMS Experts

Read more
Author

Vipul Uthaiah

Saturday, January 31st, 2026

Top 5 Shopify & Shopify Plus Agencies in Calgary Building Fast, Scalable Stores in 2026

Top 5 Shopify & Shopify Plus Agencies in Calgary Building Fast, Scalable Stores in 2026

Read more
Author

Vipul Uthaiah

Wednesday, February 4th, 2026