All writing

Beyond First Paint: What Lighthouse Is Actually Measuring

I thought Lighthouse measured how fast a page shows up. It measures something stranger. Here's what the score is made of, and how a portfolio with a 3D bridge on its homepage ended up scoring higher than the plain site it replaced.

21 min read

I rebuilt my portfolio and put a 3D Golden Gate Bridge on the homepage. Real geometry, moving cars, fog, a camera that flies down the deck as you scroll.

Then I ran Lighthouse on a throttled phone, fully expecting it to punish me.

Most pages came back faster than the old site. The old site had no 3D at all.

That didn't make sense to me. And honestly, I couldn't have explained why, because until a few months ago I didn't really know what Lighthouse was measuring. I knew it lived somewhere in Chrome DevTools. I knew people screenshot their green circles. I had a vague idea it was about "time to first paint" and a few other numbers nobody reads.

So I dug in. It turns out first paint is a small slice of the story. The numbers that decide whether a page feels fast are the ones most of us scroll right past.

This post is everything I wish someone had told me before I started: what the metrics actually are, how five of them get squeezed into one score, why that score changes every time you hit refresh, and what it took to make a 3D homepage behave on a mid-range phone.

Your users never see a stopwatch

Here's the first thing that clicked for me. Nobody visiting your site thinks "the First Contentful Paint was 1.2 seconds." They feel three things:

  1. Is it loading? Did the thing I came for show up?
  2. Is it listening? When I tap, does anything happen?
  3. Is it holding still? Or does the button jump away right as I go to press it?

Google turned those three feelings into three measurements and called them the Core Web Vitals:

  • LCP, Largest Contentful Paint answers "is it loading?" It's the moment the biggest thing on screen finishes painting. Good is under 2.5 seconds.
  • INP, Interaction to Next Paint answers "is it listening?" It's how long the page takes to visibly respond after you tap, click or type. Good is under 200 milliseconds.
  • CLS, Cumulative Layout Shift answers "is it holding still?" It's a score for how much stuff moves around unexpectedly. Good is under 0.1.

That's it. Three questions, three numbers. Everything else in a Lighthouse report is either a supporting metric or a hint about how to fix one of these.

Is it loading?

LCP · Largest Contentful Paint

Good≤ 2.5 sNeeds work≤ 4 sPoor> 4 s

Is it listening?

INP · Interaction to Next Paint

Good≤ 200 msNeeds work≤ 500 msPoor> 500 ms

Is it holding still?

CLS · Cumulative Layout Shift

Good≤ 0.1Needs work≤ 0.25Poor> 0.25
Google's thresholds, judged at the 75th percentile of real visits.

The famous one: paint

This is the part everyone half-knows, and it's where I started too.

FCP, First Contentful Paint, is when the browser draws anything from your page: a heading, a logo, a line of text. It's the "something is happening" moment.

The catch is that FCP can be instant on a page that still feels slow. Picture a page that paints its navigation bar in 0.8 seconds and then shows a big empty box for three more seconds while the hero image crawls in. FCP looks great. You, staring at the empty box, do not feel great.

That's why LCP exists. The browser keeps track of the largest image or text block inside the viewport, and every time a bigger one paints, it updates its answer. Once you interact with the page or scroll, it stops looking. Whatever was biggest at that point is your LCP element, and the time it painted is your LCP.

A few things that surprised me:

  • Text counts. If your biggest element is a headline, the headline is your LCP. It doesn't have to be an image.
  • CSS background images count if they're loaded with url(), unless they cover the whole viewport. Chrome treats a full-screen background as backdrop, not content, and skips it.
  • LCP has phases. Lighthouse splits it into four: how long the server took to respond (TTFB), how long before the browser started downloading the LCP resource, how long the download took, and how long after that until it actually painted. When LCP is slow, one of those four is usually the villain, and the fix for each is completely different.

Keep that last point in your back pocket. It comes back to bite me at the end of this post.

The ones nobody watches

This is the section I wish I'd read first.

A page can look ready and ignore you

Your browser does almost everything on one thread, the main thread. Parsing HTML, running JavaScript, calculating layout, painting, and responding to your taps. All of it, one lane.

When JavaScript runs for a long time without a break, everything else waits in line. Your tap included. The page looks finished. The button is right there. You press it and nothing happens, because the main thread is busy chewing through a 300 ms chunk of script and your tap is stuck behind it.

The browser calls any task over 50 ms a long task. Why 50? To feel instant, a response needs to land in about 100 ms, and a 50 ms task leaves room for the browser to handle your input in the other half.

TBT, Total Blocking Time, is how Lighthouse measures this. It takes every long task during load and adds up only the part over 50 ms:

  • a 250 ms task blocks for 200 ms
  • a 90 ms task blocks for 40 ms
  • a 40 ms task blocks for 0 ms, because it's under the line

TBT = 240 ms. Lighthouse calls under 200 ms good.

INP measures the same pain from the user's side. When someone actually taps, INP times three things: how long the tap waited for the main thread to be free, how long your event handlers ran, and how long until the next frame painted. Across a visit, the browser keeps roughly the worst one. INP replaced an older metric, First Input Delay, in March 2024, because FID only measured the waiting part of the first tap and let everything else off the hook.

Here's the twist: Lighthouse can't measure INP. It loads your page and doesn't tap anything, so there's no interaction to time. TBT is its stand-in. If your TBT is high, your INP will probably be bad once real people show up.

The button that runs away

You've lived this. You go to tap "Cancel," an ad loads above it, the whole page shifts down, and you tap "Buy" instead.

That's a layout shift. CLS gives it a number, and the math is more intuitive than it looks. Every time a visible element moves without you causing it, the browser multiplies two fractions:

  • How much of the screen was affected. Take the area the element covered before and after the move, combined, as a fraction of the viewport.
  • How far it moved. The distance as a fraction of the viewport's height.

An element taking up half the screen that gets pushed down by a quarter of the screen touches 75% of the viewport and moves 25% of it. That's 0.75 × 0.25 = 0.19. One shift, and you're already past the 0.1 "good" line.

Shifts get grouped into bursts, and your CLS is the worst burst. Shifts that happen right after you click or type don't count, because you caused them and you expect them.

The usual culprits are boring: images without a width and height, web fonts that swap in at a different size, and content that loads late and shoves everything below it down the page.

How five metrics become one number

So where does the big number in the circle come from?

Lighthouse's performance score is a weighted average of five metrics:

  • Total Blocking Time: 30%
  • Largest Contentful Paint: 25%
  • Cumulative Layout Shift: 25%
  • First Contentful Paint: 10%
  • Speed Index (how quickly the visible area fills in): 10%

Read that list again. The two metrics most people think of as "performance," first paint and how fast things appear, are worth 20% together. TBT alone outweighs First Contentful Paint three to one. CLS, the one about things jumping around, is worth as much as LCP.

That was my "oh" moment. You can have a page that paints in half a second and still score in the 60s because the main thread is jammed and the layout jumps.

Two more details that explain a lot of confusing results:

Each metric is scored on a curve, not a straight line. Lighthouse maps each raw value onto a 0 to 100 score using a curve built from real-world data. On mobile, an LCP of 2.5 seconds scores about 90 and 4 seconds scores about 50. The curve is steepest in the middle, so going from 4 seconds to 3 buys you far more points than going from 2 seconds to 1. Past a certain point, you're polishing for almost nothing.

The mobile test is a simulated phone. By default, Lighthouse pretends to be a mid-range Android phone on a slow 4G connection: four times slower CPU than your laptop, around 150 ms of network latency, and roughly 1.6 Mbps of bandwidth. That's why your site can feel instant on your MacBook and score 70 on mobile. Your laptop is just not your users' phone.

Notice the word simulated, though. Lighthouse doesn't actually slow your page down. It loads it at full speed, records what happened, and then estimates what would have happened on that slow phone. Usually the estimate is close. Hold that thought.

Lab vs field: why your score changes on every refresh

Run Lighthouse three times in a row and you'll get three different scores. This drove me a little crazy at first.

It happens because Lighthouse is a lab test: one simulated load, on one machine, under made-up conditions. Whatever else your computer is doing, background tabs, a sync client, a fan spinning up, leaks into the result. That's why serious setups run it several times and take the median.

The other kind of data is field data, measured from real visitors on real devices. Google collects this from Chrome users in the Chrome UX Report, and it's what Search actually uses. Two things make it different:

  • It's the 75th percentile. To pass a Core Web Vital, 75% of real page loads need to be in the good range. Your fastest visitors don't carry you. Your slower quarter decides.
  • It includes INP. Real people tap things. Lab tests don't.

The way I think about it now: lab data is for debugging, field data is the truth. The lab tells you why something is slow, with a full trace you can dig through. The field tells you whether it's slow for the people who actually show up.

My site collects both. Lighthouse runs in CI on every preview deploy. And every real visit reports its LCP, INP, CLS, FCP and TTFB through Next.js's useReportWebVitals, rounded, tagged with the page it measured, and sent to my analytics as a few bytes.

Here's what the first week of real visits says, at the 75th percentile, across every page on the site:

  • LCP: 110 ms to 1.5 seconds. Every page is in the good range. The slowest is the homepage.
  • INP: 8 to 108 ms. Every page is under the 200 ms line, even the homepage with a 3D scene running.
  • CLS: 0. On every page.

It's a small sample, about 120 page loads, so I treat it as a first look, not a verdict. But notice something: the lab said my homepage's LCP was 3.3 seconds. Real visitors saw 1.5. Keep that gap in mind.

Case study: putting a 3D bridge on a phone

Okay. Back to the mystery from the top.

Here's where the old site started, measured the same way (mobile, median of three runs):

  • Homepage: 0.94
  • Content pages (About, Projects, Writing, Photography): 0.93 to 0.96

And here's the new site, bridge and all:

  • Homepage, with a live 3D scene: 0.92
  • Content pages: 0.95 to 0.99
  • Total Blocking Time on every route: 13 to 31 ms
  • Layout shift on every route: 0.000
  • Accessibility, best practices and SEO: 100 everywhere

The homepage gave up two points to carry an entire 3D world. Everything else got faster. None of it came from one big trick. It came from asking the same question about every feature: which metric does this hurt, and how do I keep it from hurting it?

1. The bridge never touches the main thread

A 3D scene is a lot of JavaScript running every single frame. On the main thread, that's a long task factory. TBT would explode, and every tap would wait behind a frame render.

So the bridge doesn't live on the main thread. It renders inside a Web Worker, a separate background thread, drawing to an OffscreenCanvas. The page hands the canvas over to the worker once, and from then on the main thread just sends small messages like "the user scrolled to here." The heavy lifting happens in a lane your taps never have to wait in.

If a browser can't do that, the page falls back to rendering on the main thread rather than breaking.

Metric it protects: TBT in the lab, INP in the field.

2. Not every phone gets a bridge

Some devices shouldn't be asked to render 3D at all. Before anything loads, the site sorts the visitor into one of three tiers:

  • Poster: reduced motion turned on, Save-Data on, 2 GB of memory or less, no WebGL2, or a GPU on a known-weak list. These visitors get beautiful still images and never download a single byte of 3D code.
  • Mid: 30 cars, smaller shadows, no bloom.
  • High: a fine pointer and at least 8 cores or 8 GB of memory. 90 cars, bigger shadows, bloom.

There's one more safety net. If the frame rate drops below about 50 fps for a second and a half, the scene quietly lowers its resolution and doesn't raise it back.

Metric it protects: everything. The fastest JavaScript is the JavaScript a weak phone never downloads.

3. A picture first, the 3D world later

Here's the trick that does the most work. When you land on the homepage, you're not looking at 3D. You're looking at a photo of the 3D scene.

I baked 24 still images ahead of time (6 stops along the bridge, day and night, wide and tall screens), saved as AVIF at 53 to 111 KB each. A tiny script that runs before the page paints tells the browser to fetch the right one first, at high priority. That still is what you see in the first second.

The actual 3D engine waits until the browser is idle (or 2 seconds, whichever comes first), boots up in the worker, draws its first frame, and then crossfades over the still in 600 ms. If you're watching closely, you'll see the cars start moving. If you're not, you never notice the swap.

Metric it protects: TBT. The heavy work happens after the page is already "loaded" in every way Lighthouse measures. I also believed it protected LCP. It doesn't, and finding out why taught me more than anything else in this project. That's the last section.

4. Stop drawing when nobody's looking

A canvas that renders while it's off-screen or in a background tab is burning battery for nobody. Both canvases on the site, the bridge on Home and the blueprint background on Projects, pause when the tab is hidden or they scroll out of view.

Two smaller findings from my prototypes made a big difference too:

  • No frosted glass over moving canvases. Blurred, see-through panels look great, but backdrop-filter over a live canvas cost about 10% of the frame rate. The panels are solid now.
  • Antialiasing made resizing stall. With the renderer's antialiasing on, resizing the window took 80 to 390 ms. Turning it off brought that down to about 5 ms.

Metric it protects: INP, and the general "this feels smooth" that no metric fully captures.

5. The 52 KB I couldn't see

This one's my favorite, because I would never have found it by looking at the code.

My first budget for content pages was 120 KB of JavaScript. It seemed reasonable. It was also impossible: the framework alone (Next.js 16 plus React 19) weighs about 136 KB before I write a single line. So I measured instead of guessing, and set the budget at 170 KB.

Then the content pages started creeping toward it. The culprit was the homepage. My nav links told Next.js to prefetch every page they pointed to as soon as the link was visible, and that included Home. Home imported its scroll animation library, GSAP, the normal way. So every page on the site was quietly downloading about 52 KB of animation code for a page you might never visit.

Two fixes:

  • Home now loads GSAP lazily, only when the homepage actually needs it.
  • Nav links prefetch on intent instead of visibility: when you hover, focus, or start to touch a link. One page's code never counts against another page's budget again.

Metric it protects: script weight, which drives TBT on slow phones.

6. Fonts, the biggest single download

Web fonts are sneaky. They're often the largest file on a text page, and they can cause layout shift when they swap in.

I self-host two families, subset to only the characters the site uses. That took them from 114 KB to 70 KB, which by itself was worth about 0.02 on the mobile score. They swap in from a fallback with adjusted metrics, so the text doesn't jump when the real font arrives.

Metric it protects: LCP and CLS.

7. Analytics that weighs 2 KB

The popular analytics libraries are around 50 KB. Mine is a tiny first-party script, 2,037 bytes compressed, that loads only after the browser is idle and only on the production site. It's also what sends the real-user Web Vitals I mentioned earlier.

Metric it protects: script weight and TBT, on every page.

8. Zero layout shift, on purpose

CLS is 0.000 on every route, and it's mostly boring discipline:

  • Every image ships with its width and height and a tiny blurred placeholder, so the space is reserved before the image arrives.
  • Live data, like my coding stats, is rendered on the server. If the data isn't available, the section just isn't there. Nothing loads in late and shoves the page down.

Metric it protects: CLS.

Keeping it fast

Here's the uncomfortable truth about performance: it rots. Every new feature, dependency and image nudges the numbers. Nobody notices one nudge. Twenty nudges later, your 98 is a 74 and nobody knows which change did it.

So the site doesn't rely on me remembering to check. On every preview deploy, CI runs Lighthouse on every page, three times each, and fails the build if a content page drops below:

  • 0.95 performance
  • 150 ms Total Blocking Time
  • 170 KB of JavaScript
  • 0.05 layout shift

The homepage gets its own looser budget (0.85 and 300 ms), because it carries the bridge.

On top of that, a bot posts a comment on every pull request comparing the new numbers to the main branch. It ignores noise (a 0.01 wobble isn't news) and when something does regress, it suggests the likeliest cause from a list of things that have bitten me before, like "three.js leaked into the homepage bundle" or "GSAP is loading eagerly again."

And yes, while writing this post I found a bug in my own watchdog. The baseline it compares against had been recording Vercel's login page instead of my site: a performance score of 0.42 and a 9.5 second LCP for a page I didn't build.

The cause was one missing secret. My preview deployments are password-protected, and CI needs a bypass token to get past that screen. It never had one. So every Lighthouse run politely followed the redirect to Vercel's login page and audited that, all eight routes collapsing into one page called /login. The preview budget checks that had been failing? They were failing Vercel's page, not mine.

My guard dog was guarding the wrong door. Now CI checks that it can actually reach my site before it measures anything, and the baseline step refuses to save any run that ended up somewhere else. Measure your measurements.

The headline that was never late

I said LCP would come back to bite me. Here it is.

All that work on the poster images had one goal: make the homepage still the LCP element and get it painted fast. The code comments said so. My mental model said so.

Lighthouse disagreed. On a simulated phone, the homepage's LCP was 3.3 seconds, and the LCP element wasn't the poster at all. It was my name, the big headline.

The phases looked damning:

  • Server response: 453 ms
  • Render delay: 2,853 ms

The headline is plain text in the HTML. It needs nothing to download. And Lighthouse was telling me it sat there for almost three seconds before painting. So I went looking for whatever was holding it back.

First, why wasn't the poster the LCP? Remember the background image rule: Chrome skips images that cover the whole viewport. My poster is a full-screen backdrop, so it was never a candidate. My code comments had been wrong since the day I wrote them.

Next, I hunted for the delay. I tried the obvious fix, lowering the poster's download priority so it wouldn't compete with the page. The simulated LCP didn't move: 3,240 to 3,258 ms, same as before. So I stopped guessing and recorded real traces.

The headline paints in the very first frame. Every time.

Then I loaded the page under the same slow-4G conditions, for real this time instead of simulated. Three runs: 1,571 to 1,637 ms. LCP and First Contentful Paint landed at the same moment, because the headline is the first thing on screen. The scripts, the fonts and the poster all finished after it painted.

So where did 3.3 seconds come from? Remember: Lighthouse doesn't slow your page down. It loads it at full speed and estimates. On my laptop the whole page arrived in about 30 ms, so by the time the headline painted, every script, both fonts and the bridge photo had already landed. The simulator saw that and assumed the headline needed all of it: 330 KB in total. Then it replayed those 330 KB over slow 4G. The last script chunk finished at about 3.2 seconds, and because text has nothing of its own to download, all of that time got labeled "render delay."

The headline was never late. The estimate was.

I could have "fixed" it. Blocking the poster would shave about 380 ms off the simulated number. Real visitors would just see the bridge later, so I left it alone. Making the page worse for people to make it look better to a simulator is the trap this whole post is about.

And the real visitors agree. Their 75th-percentile LCP on the homepage is 1.5 seconds, and on phones alone it's 1.3. That's right next to my throttled test, and nowhere near the simulator's 3.3.

If you only take one thing from this

Stop looking at the circle. Look at what's inside it.

Here's the checklist I wish I'd had:

  1. Find your LCP element. Lighthouse names it in the report. Make sure it's the thing you think it is.
  2. Check its phases, then check them for real. A big "render delay" on a text element might be the simulator, not your page. Record a real trace with throttling before you start "fixing" anything.
  3. Hunt long tasks. Open the Performance panel, look for the red-cornered tasks, and move heavy work off the main thread or later in time.
  4. Give every image a width and height. It's the cheapest CLS fix there is.
  5. Measure JavaScript per route. Then check what each page downloads that it doesn't need. Prefetching is a common leak.
  6. Collect field data. Lighthouse is one simulated phone. Your visitors are thousands of real ones.
  7. Put budgets in CI. If a number matters, make the build fail when it slips.

I started this project not knowing what a long task was. I'm finishing it with a 3D bridge on a phone, a CI pipeline that defends my scores, and a healthy suspicion of every number, including the ones I like.

The score is a model of your page. Your users are the page. When the two disagree, believe your users.