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:
-
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
-
Processing time — the actual work your event handlers (and everything they trigger) do
-
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
-
Check field data before assuming anything
-
Confirm INP is actually a problem, and watch the trend, not one number
-
Narrow down which interaction is likely responsible
-
Pull field data into DevTools for context
-
Reproduce the interaction locally and record it in the Performance panel
-
Start with the Interaction track, then follow the chain to long tasks
-
Trace the expensive work back to actual source code
-
Ask when the work happens relative to the paint, not just what it costs
-
Move non-essential work off the critical path (defer, batch, or paginate it)
-
Re-record the same interaction and compare traces
-
Ship it
-
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.