Say you are running Lighthouse against a staging URL, and the report comes back with a Performance score of 42. You scan the opportunities list, see “Eliminate render-blocking resources” and “Reduce unused JavaScript,” start deferring a few bundles, and rerun. The score moves to 58. You push to production, and real users report nothing feels different. That gap between the synthetic score and the field experience is where most Lighthouse workflows fall apart — not because Lighthouse is wrong, but because it is being read incorrectly.
This post walks through one continuous case: a content-heavy marketing site that scores poorly, gets “optimized,” and still fails field metrics, until the actual root causes are isolated with the trace viewer. Along the way we will cover the specific misreadings that cause teams to fix the wrong thing.
Step 1: Reproduce the score honestly before trusting it
The first mistake is treating a single Lighthouse run as a measurement. It is a lab simulation on one machine, on one network profile, at one moment. Run it five times on the same URL and the Performance score commonly varies by 10 to 20 points, driven mostly by:
- CPU throttling variance. The default “Simulated throttling” model extrapolates from an unthrottled run. On a loaded workstation, that extrapolation is noisy.
- Varying origin latency. If third-party tags load from different edge nodes on each run, TBT swings accordingly.
- Cache state. A cold run and a warm run measure different things. Lighthouse’s “Clear storage” option only resets what the audit controls — the browser HTTP cache and shared CDN caches are separate concerns.
Before drawing any conclusion, take a baseline properly:
# Five runs, mobile preset, JSON output, fresh profile per run.
for i in 1 2 3 4 5; do
lighthouse https://staging.example.com/ \
--preset=desktop \
--only-categories=performance,accessibility,best-practices,seo \
--output=json \
--output-path=./lh-run-$i.json \
--chrome-flags="--headless=new --no-sandbox"
done
Then compare the median, not the best or worst result. If run-to-run variance on the same URL exceeds roughly 15 points, the environment is unstable and no optimization decision made from it will hold up in production. Fix the measurement conditions first — a quiet CI runner, a fixed throttle multiplier, and consistent third-party availability — before touching application code.
A second issue at this stage is using Lighthouse against a locally served production build without the same compression and caching that the CDN applies. gzip and brotli change the picture for text assets dramatically; testing without them inflates the reported transfer size and produces misleading “reduce unused JavaScript” numbers.
Step 2: Stop optimizing the total score; optimize the metrics inside it
Lighthouse’s Performance category is a weighted blend of several metric scores — roughly FCP, Speed Index, LCP, TBT, and CLS in current versions, with historically a weighting toward TBT and LCP. Chasing the composite number encourages cosmetic wins. The website owner does not feel the composite; they feel the paint and the layout shift.
The right workflow is to open the Metrics section and record the raw values, then map each one to the audit that produces it:
| Metric | What it really measures | Which audits drive it |
|---|---|---|
| FCP | Time to first text or image paint | Render-blocking resources, server response time, font loading |
| LCP | Time to largest visible element paint | LCP image discovery, fetchpriority, preload, TTFB |
| TBT | Main-thread blocking during load | Long tasks, third-party scripts, hydration cost |
| CLS | Unexpected layout shift score | Image/video dimension attributes, injected banners, late fonts |
In our marketing-site case, the composite score was 42, but the breakdown showed an LCP of 6.8s on mobile and a CLS of 0.31. TBT was moderate. The most impactful issue was not JavaScript at all — it was that the hero image was being discovered late in the parse and had no explicit dimensions.
Step 3: Read the trace, not just the report
The single most common mistake is closing the report without opening the View Trace button. The report lists symptoms; the trace shows the causal chain.
For the hero-image LCP problem, the trace usually reveals one of these patterns:
- The LCP element is not parsed until late. If the hero is wrapped in a client-rendered component or injected after a data fetch, the browser cannot start downloading the image until JS has run. The fix is to render the LCP element in the initial HTML payload.
- The image is discovered, but late. If it lives inside CSS as a
background-image, or in a<picture>with amediaattribute that matches only after layout, the preload scanner misses it. - The image is discovered and preloaded, but the transfer is slow. This is where
fetchpriority, correctsizes, and a modern format (AVIF or WebP) matter.
Concrete fix for pattern 2 and 3, applied to the hero element:
<!-- Hero image: preload with matching attributes, decouple from JS -->
<link
rel="preload"
as="image"
href="/img/hero-1200.avif"
imagesrcset="/img/hero-800.avif 800w, /img/hero-1200.avif 1200w, /img/hero-1600.avif 1600w"
imagesizes="100vw"
type="image/avif"
fetchpriority="high"
/>
<img
src="/img/hero-1200.avif"
srcset="/img/hero-800.avif 800w, /img/hero-1200.avif 1200w, /img/hero-1600.avif 1600w"
sizes="100vw"
width="1200"
height="675"
alt="Product hero"
decoding="async"
/>
Two details matter here. First, the imagesrcset and imagesizes on the preload must match the <img> exactly, or the browser will fetch two different files and the preload is wasted. Second, the explicit width and height attributes are what eliminate the CLS contribution from this element — they give the browser an aspect ratio to reserve space for before the bytes arrive.
The decoding="async" attribute is safe here; do not add loading="lazy" to an LCP image. Lazy-loading the hero is one of the most frequent self-inflicted LCP regressions, because it defers the download until after layout, adding a full network round trip.
Step 4: Attribute TBT to the right script, not the loudest one
Once LCP and CLS are addressed, the next largest cost in most reports is TBT. The instinct is to defer or async every script that appears in the “Reduce unused JavaScript” list. That list is a coarse signal: it aggregates byte counts across all bundles, including ones that run immediately after load and are not on the critical path.
Use the trace’s Bottom-Up and Call Tree panels instead. Sort by self-time, filter to main-thread tasks during the load window, and identify the specific bundle and function responsible for long tasks (anything over 50ms). In the case study, one third-party chat widget accounted for roughly 380ms of main-thread blocking, while the much larger application bundle contributed almost nothing before FCP because it was already defer-ed.
For a third-party tag you cannot remove, the realistic lever is delaying its load until after the user has interacted or until a requestIdleCallback window opens. A pattern that works without breaking the tag’s API:
// Defer non-critical third-party tags until after first interaction or idle.
function loadChatWidget() {
if (window.__chatLoaded) return;
window.__chatLoaded = true;
const s = document.createElement('script');
s.src = 'https://cdn.example.com/chat-widget.js';
s.async = true;
document.head.appendChild(s);
}
const trigger = () => {
loadChatWidget();
['scroll', 'pointerdown', 'keydown'].forEach((evt) =>
window.removeEventListener(evt, trigger, { passive: true })
);
};
['scroll', 'pointerdown', 'keydown'].forEach((evt) =>
window.addEventListener(evt, trigger, { passive: true, once: false })
);
// Fallback so the widget still loads for passive visitors.
window.addEventListener('load', () => {
if ('requestIdleCallback' in window) {
requestIdleCallback(loadChatWidget, { timeout: 4000 });
} else {
setTimeout(loadChatWidget, 4000);
}
});
Trade-offs to be honest about. Delaying a script changes when its functionality becomes available. If the tag is a consent manager, an analytics beacon that must fire before navigation, or anything legally required on page load, this pattern is wrong. Similarly, delaying a chat widget may be fine for a blog and unacceptable for a support portal where the widget is the primary call to action. Do not run this pattern reflexively; match it to the actual function of the script.
A second failure mode: if you delay a script that other code depends on, you create race conditions. Expose a load event (window.dispatchEvent(new Event('chat:ready'))) rather than assuming timing.
Step 5: Verify against field data, not just another lab run
Here is the last mistake and the one that keeps teams in a loop. After fixing LCP discovery and deferring the chat widget, the lab score in our walkthrough improved substantially. That does not mean real users improved, because lab conditions differ from the distribution of real devices, networks, and geographies.
The verification step is to compare lab results against field data from the Chrome UX Report or your RUM provider, before and after the change, over a comparable window. A few realities to expect:
- Lab and field often disagree on LCP by a wide margin, because lab uses a fixed throttling model while field data aggregates across 4G, 5G, and degraded connections.
- CLS frequently looks better in lab than in field when third-party banners, cookie walls, or late-injected content only appear for certain user segments.
- TBT has no direct field equivalent. INP (Interaction to Next Paint) is the field metric that captures interactivity, and it measures a different thing. Do not assume a TBT improvement translates to an INP improvement.
If field LCP does not move after a lab improvement, the most common causes are: the CDN is not serving the preloaded asset to all regions, an A/B testing tool is swapping the hero after load, or the actual LCP element is different than what Lighthouse identified in the lab (for example, a webfont block of text on some devices and the hero image on others).
A workable checklist
- Take a five-run median with a fixed environment before optimizing anything. Ignore single-run scores.
- Open the trace. Attribute LCP, CLS, and TBT to specific elements and functions, not to categories.
- Match preload attributes to the actual
<img>or the preload is wasted. - Never lazy-load the LCP image; always set explicit dimensions on it.
- Attribute TBT with the trace’s self-time sort, not the “unused JavaScript” list.
- Delay third-party tags only when their function allows it, and expose a ready event so dependents do not race.
- Verify against field data over a comparable window; do not declare victory on a second lab run.
Lighthouse is a diagnostic tool, not a scoreboard. Treat its report as a list of hypotheses, confirm each one in the trace, and validate the fix where it matters — in the field numbers users generate. Do that, and the score improvements follow the experience instead of the other way around.
🔗 Recommended Reading
- Core Web Vitals Optimization for Finance and Banking Websites: A Troubleshooting Checklist
- Common Core Web Vitals Mistakes Developers Make (And How to Fix Them)
- Compressing and Serving WebP Images: A Beginner's Step-by-Step Tutorial
- Browser Caching Setup: A Beginner's Step-by-Step Guide
- Troubleshooting Slow Server Response Times: Common Mistakes and Fixes