~/connor.meador
← back to build log
7 min read

The CSS mask that cost my home page 400ms (and four wrong guesses first)

One route scored 91 while two others on the same site scored 98 and 97. Four plausible causes measured and disproved, the real one found by elimination — and the follow-up measurement that failed its own control.

performanceLCPCSSdebuggingCore Web Vitalsmeasurement

My own site scored 91 on mobile. Two other routes on it — same stack, same fonts, same deploy — scored 98 and 97. That asymmetry is the entire story, because it rules out almost everything before you start. Whatever was wrong could not be the framework, the hosting, the font loading strategy, or the build config. All three pages share every one of those.

I have written before about an LCP problem where the metric was measuring the wrong element. This was the opposite. The metric was measuring exactly the right element. That element was just genuinely slow to paint, on one page, for a reason that was invisible in every summary view I had.

What follows is mostly the four things it wasn't, because that is where the actual work went.

Guess one: the page is too big

The obvious one, and the easiest to check. If the home page ships more bytes than the fast pages, the rest of the investigation is just a payload diet.

It ships fewer. The work-index route transfers more than the home page does and paints roughly half a second sooner. Whatever was costing time, the browser was not waiting on the network to hand it over.

This is worth doing first anyway. Not because it is usually the answer, but because when it is the answer it saves you the next three hours.

Guess two: it's the interactive components

The home page has two client components in the first screen — a small terminal emulator and a typewriter effect. Neither exists on the fast routes. This was my best guess, and I was fairly confident about it, which in hindsight is exactly why it needed a real test rather than a plausible story.

So I turned JavaScript off entirely and measured again.

First paint moved by 23 milliseconds.

Twenty-three. Hydration was not the problem, had never been the problem, and if I had gone with my confident guess I would have spent an evening code-splitting a component that was costing almost nothing. The lesson there is not about React. It is that "this is the only difference between the pages" is a hypothesis, not a finding, and the two feel identical from the inside.

Guess three: fonts are blocking the paint

Font-gated paint is a real and common cause, and I had seen it bite before on a client build. So I checked the actual declarations rather than my memory of them: all thirty-three webfont @font-face rules use font-display: swap, sitting behind Next's metric-matched local fallbacks. Nothing was waiting on a font to show text.

Guess four: something is render-blocking

There is no <link rel="stylesheet"> on the page at all. The CSS is inlined into the document. There was nothing in the head to block on.

Four guesses, four measurements, zero causes. What that buys you is not nothing — the remaining space is now small enough to search directly.

The actual cause

Building something like this? Let’s talk →

The home page's hero section carries a decorative background: two tiled one-pixel gradients forming a 48-pixel grid, with a radial mask-image fading it out toward the edges.

A mask is not free. A tiled background gradient on its own is cheap — the browser can rasterize a tile once and repeat it. Put a mask over it and that shortcut stops being available, because every pixel in the region now depends on the mask's value at that pixel. My reading is that this is what disqualifies the cheap path and forces the whole region to be rasterized properly. I want to be precise about the confidence here: the cost is measured and reproducible, the mechanism is my explanation of it.

And the region is not small. It is the hero — the first thing painted, spanning the full viewport width.

Two details made this hard to see and, once seen, faintly annoying.

The first is that only the hero uses that class. Not the work index, not the resume. That is precisely why those two routes never paid for it, and precisely why comparing them to the home page was the clue rather than the confusion. A cost that appears on exactly one page tends to live in something exactly one page uses. Obvious in retrospect; I spent three guesses elsewhere.

The second is that on mobile the grid was already dialled down to 0.4 opacity, where it is nearly invisible. The page was paying the full rasterization cost for a decoration a phone user could barely see. That is the worst possible trade and it had been live for months, because nothing about it looks like a performance problem when you read the stylesheet. It looks like a nice touch.

The fix was to render the grid from the md breakpoint up. Desktop keeps it exactly as designed. Phones stop paying for something they were not really getting.

Measured on a local production build at mobile throttling, medians of five runs:

  • First contentful paint on the home page: 1728ms to 1312ms, a 416ms improvement.
  • Main-thread work after the bytes finish arriving: 1082ms to 645ms, a 437ms improvement.
  • The control route: 904ms to 884ms, which is to say unmoved.

The control matters more than the two improvements. Without it I would have a before-and-after on a machine whose load I do not control, which is not a result.

The part where my own instrument failed

This is the half I would have left out a year ago.

Having fixed the page, I wanted to know what was left. So I built a bisect: load the page repeatedly, each time injecting a stylesheet that disables one category of expensive CSS — shadows, filters, background images, transitions, pseudo-elements — and see which removal buys the most time.

The results looked readable. Removing pseudo-elements saved 320ms. Disabling JavaScript saved 317ms. And the biggest effect of all, at 393ms, came from removing mask-image.

Except that run was at a 412-pixel viewport, and by then the mask only existed above 768 pixels. There was no mask on the page. I had measured the cost of removing something that was not there, and it came back as the largest number in the table.

That is a placebo, and it failed. Which means the table was not showing me effects at all — it was showing me noise with labels on it. The spread between repeated runs of the same condition was 400 to 900 milliseconds; every effect I was trying to read was between 50 and 400. The signal was smaller than the variance, and the machine happened to be busy with other work that evening.

So I threw the whole table away. The right move was not more runs — it was to stop, because the instrument could not resolve what I was pointing it at.

I have since made the placebo a standing step rather than an accident. Any condition-versus-baseline measurement gets one condition that changes something known to be inactive. If that condition shows an effect, the run is noise and no conclusion from it is safe, no matter how sensible the other rows look. It costs one extra arm and it is the only thing in the setup that can tell you your numbers are fiction.

What I would tell someone hitting this

  • If one route on a site is slow and its siblings are not, stop looking at the framework. Everything shared is exonerated by the comparison itself. Go straight to what that page uses and the others don't.
  • Test your best guess first and expect to be wrong. Mine cost 23ms out of a 400ms problem, and it was the guess I was most sure of.
  • A decoration you have suppressed on mobile is still being paid for on mobile unless you actually removed it. Low opacity is not a discount.
  • Always measure a control you expect not to move. A number without one is a story about your machine.
  • Put a placebo in any bisect. When removing something that isn't there produces your biggest effect, you have learned the only thing that run can teach you.

One honest limit, the same one that applies to every lab number: these are local measurements under simulated throttling, not field data from real users, and I have not yet re-scored the page on Google's own runner. The structural cost was real and is gone. The score it produces is a separate claim and I will make it when I have measured it.

I'm looking for a remote role — AI automation, integration, or full-stack, full-time or contract. If your team wants someone who debugs like this, I reply within one business day.

// let’s talk

Open to AI automation, applied-AI, or full-stack roles, and available now. If your team is hiring, I reply personally within one business day.

Remote (U.S.) · Rochester, NY hybrid considered · Full-time or contract · Eastern Time

Get in touch →