When Lighthouse Is Green but Search Console's Core Web Vitals Are Red — Field Notes on Naming the Slow Interaction with Real-User Data
Lighthouse scores in the 90s, yet field Core Web Vitals won't budge. Here is how I closed the lab-vs-field gap with real-user monitoring (RUM), named the exact interaction driving a slow INP, and fixed it with Antigravity.
My Lighthouse score was 92. Green. Green every time I ran it.
And yet the Core Web Vitals report in Search Console kept INP stuck on "needs improvement." Touching it on a real device, honestly, it did not feel that slow.
As an indie developer running a small blog site on my own, this mismatch jerked me around for about two weeks. I spent that time raising the lab number, and the field number did not move a single millisecond.
The cause was simple. I had been measuring a screen that real users were not touching. This is a record of closing the lab-vs-field gap with real-user monitoring, naming the slow interaction, and only then fixing it with Antigravity.
Internalize that lab and field are different things
First I re-learned exactly what Lighthouse measures.
Lighthouse is "lab data." It is nothing more than one load, on your machine, under fixed conditions. INP in particular barely fires any interactions in the lab, so it usually comes out near zero. Of course it looks green.
Search Console, on the other hand, shows "field data": the real-user p75 from the Chrome User Experience Report (CrUX). It is the distribution of what happens when actual people press buttons, type into forms, and filter lists.
Aspect
Lab (Lighthouse)
Field (CrUX / Search Console)
What it measures
One synthetic load
28 days of real-user distribution
How it treats INP
Few interactions; tends to understate
Reflects the p75 of real interactions
Update speed
Immediate
Days to weeks of lag
Used for SEO evaluation
No
Yes
Field data is what affects ranking. So if lab is green but field is red, the one to trust is the field. Until this sank in, I kept shooting at the wrong target.
Measure real users' INP yourself
CrUX is useful, but it is coarse and lagging. It will not tell you "which page, which interaction is slow." So I instrumented real-user monitoring (RUM) on my own site.
Google's web-vitals library has an attribution build, and that was decisive. It does not just return the INP value — it tells you the exact element and event that caused the delay.
// Collect real users' INP with its sourceimport { onINP } from 'web-vitals/attribution';onINP((metric) => { const attr = metric.attribution; const payload = { value: Math.round(metric.value), // the actual INP in ms rating: metric.rating, // good / needs-improvement / poor target: attr.interactionTarget, // selector of the DOM element that caused the delay type: attr.interactionType, // pointer / keyboard // breakdown: where the time went — input delay, processing, or presentation inputDelay: Math.round(attr.inputDelay), processingDuration: Math.round(attr.processingDuration), presentationDelay: Math.round(attr.presentationDelay), path: location.pathname, }; // send lightly via beacon (survives page unload) navigator.sendBeacon('/api/rum', JSON.stringify(payload));});
The interactionTarget and the three breakdown fields I send here were the treasure map. INP is the sum of input delay, processing duration, and presentation delay. Which one is fat completely changes the fix you should reach for.
I accumulated a week and aggregated by path and target. It turned out that INP p75 hit 410ms not on the homepage I had been measuring in Lighthouse, but on the tag filter of the article list. That is where most real users were interacting. I had been polishing an entrance no one walked through.
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Why lab (Lighthouse) and field (CrUX/RUM) diverge, and which one to trust for ranking
✦Using the web-vitals attribution build to pinpoint the exact DOM element and event behind a slow INP
✦Handing that one named interaction to Antigravity to fix, then protecting the field p75 with a regression test
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Read the breakdown to confirm it's "heavy processing"
Once I could name it, the next step was the breakdown of that 410ms. The RUM aggregation looked like this.
Breakdown
p75 value
Meaning
inputDelay
18ms
The main thread was free
processingDuration
360ms
The event handler itself is heavy
presentationDelay
32ms
Rendering was fine
Processing was 360ms. So the cause was neither loading nor rendering, but synchronous work inside the event handler. The moment a tag was clicked, thousands of articles were being filtered on the main thread every time.
At this point the instruction to Antigravity becomes concrete. Not "make the site faster," but "the article-list tag filter handler has a p75 processingDuration of 360ms; please split this synchronous filter to lower INP." When you can name the cause, the agent's suggestions get sharply better.
Fix only the one named spot with Antigravity
I handed Antigravity's chat the specific information from RUM as-is. What came back was a fix that decouples the filtering from input responsiveness.
// Before: synchronous filter of all items on every click (inflates processingDuration)function TagFilter({ articles }: { articles: Article[] }) { const [tag, setTag] = useState(''); const filtered = articles.filter((a) => a.tags.includes(tag)); return ( <> <TagButtons onSelect={setTag} /> <ArticleGrid items={filtered} /> </> );}// After: useTransition marks filtering non-urgent and returns the input response firstimport { useState, useTransition, useMemo } from 'react';function TagFilter({ articles }: { articles: Article[] }) { const [tag, setTag] = useState(''); const [isPending, startTransition] = useTransition(); // keep the heavy compute, but take the selection out of urgent rendering const filtered = useMemo( () => articles.filter((a) => a.tags.includes(tag)), [articles, tag], ); const handleSelect = (next: string) => { // don't block the click's paint; apply the filter afterward startTransition(() => setTag(next)); }; return ( <> <TagButtons onSelect={handleSelect} pending={isPending} /> <ArticleGrid items={filtered} /> </> );}
The point of useTransition is to return the visual response to the click (the pressed state) immediately, and push the reflection of the heavy filter result into a non-urgent lane. I did not make the computation itself faster; INP drops because the "next paint" after the user's interaction is delivered first. Finding handlers that hurt INP shares its logic with Troubleshooting slow behavior in Antigravity.
In the RUM measurement, this screen's INP p75 dropped from 410ms to 90ms, about a 78% improvement. The lab score barely changed. Of course — the lab was never measuring this interaction to begin with.
Protect the field improvement with a regression test
Finally, I put in a mechanism to protect the number I had just lowered.
Field data takes weeks to update, so you cannot use it as a CI pass/fail signal. Instead, I built a synthetic test in the lab that actually fires that interaction. It reproduces the interaction real users touch and regression-monitors a metric close to INP.
# .github/workflows/inp-regression.yml — reproduce the real interaction and regression-monitor INPname: INP Regressionon: pull_request: branches: [main]jobs: interaction: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 22 } - run: npm ci && npm run build && npm run start & # Playwright actually clicks the "tag filter" identified by RUM, # measures the event's processingDuration, and gates on a threshold - run: node scripts/measure-interaction.mjs --path=/articles --action=tag-filter --budget-ms=200
What matters is matching the interaction you measure in CI to "the interaction that was red in RUM." No matter how well you guard a screen no one touches, the field will not move. A concrete example of wiring Lighthouse CI into a regression pipeline is in Stopping Lighthouse CI performance regressions in Antigravity.
Wrap-up — find "whose, which interaction" before you fix
After a two-week detour, the lesson is a single one. If lab is green and field is red, the thing deciding what to fix is the field.
Lighthouse is excellent as an entrance checkup. But only RUM could name a real user's INP. Once you capture the source element and the breakdown of the event with web-vitals attribution, your instruction to Antigravity changes from "make it faster" to "lower this breakdown of this interaction." Agents respond far better to a specific symptom than a vague wish.
The next time my Core Web Vitals go red, I will stop poking at a real device and open the RUM aggregation first. The slow interaction usually lives on a screen I have not been polishing. I hope this helps anyone stalled at the same divergence.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.