Back to the archive

The 34 pixels that made PageSpeed think my static site was slow

On deianisac.com, 34 one-pixel canvas flecks dragged Speed Index to four seconds after the page looked finished.

TL;DR

On deianisac.com, Speed Index stayed near four seconds after the page looked finished. Lighthouse scores a video of the load, not Largest Contentful Paint. 34 one-pixel flecks on Deian Isac's arrival canvas kept the first screen changing.

That canvas now waits for a mouse move, tap, or key. A 48px arrival placeholder and deferred display fonts later moved the same pixels. Clean runs landed next to first paint. There is no Lighthouse user-agent check.

My static site looked finished after about 1.1 seconds. PageSpeed sometimes said it took four.

I looked for a huge hero, a JavaScript framework, and layout shift. It was 34 one-pixel flecks on a canvas.

This happened while I was rebuilding deianisac.com with Astro 7.2.10. The site ships static HTML, hand-written CSS, a small module script, and a few AVIF stills. I expected that to be boring to optimize.

It wasn’t.

The page was already done

The DebugBear filmstrip, not the score, showed the page visually complete at 883 ms. First Contentful Paint and Largest Contentful Paint both landed at 846 ms. The full TTFB was 404 ms, with no layout shift.

PageSpeed was less consistent. I saw Speed Index land at 1.1 s, 2.3 s, 4.1 s, then 3.9 s on the same deployed build.

Lighthouse calculates Speed Index from a video of the page loading. It compares the visual progression between frames. The browser can finish the largest element, satisfy LCP, and keep changing enough small pixels to drag out Speed Index.

My network waterfall was mostly innocent. The screenshots were not.

The 34 pixels

The arrival scene has a full-screen canvas over a static coast image. It draws 24 short dashes on the water and 34 tiny flecks in the air, about 30 times a second.

A person sees a little weather. Lighthouse sees a first screen that keeps changing.

I had deferred the canvas until the browser was idle. That made the result unpredictable. If the main thread became idle early, the animation started inside the measurement window. If it became idle later, Speed Index finished before the first fleck appeared.

Idle was the wrong trigger. The canvas now waits for real interaction.

window.addEventListener("pointermove", onPointerMove, { passive: true });
window.addEventListener("pointerdown", start, { passive: true });
window.addEventListener("keydown", start);

There is no Lighthouse user-agent check. A mouse move starts it on desktop. A tap starts it on a phone. A key press covers keyboard use. Visitors who only read the first screen do not spend CPU on decorative weather, and prefers-reduced-motion still skips it entirely.

Once started, the flecks ease in from the left instead of snapping into view.

That change pulled the clean Speed Index runs back beside first paint.

The images were still too big

The canvas explained the four-second result. Phones were downloading 1,672-pixel stills for much smaller slots.

I added 400, 800, 1,200, and native-width variants, with AVIF first and WebP as the next choice. Each <picture> now has a sizes value that matches its layout, and the map stays lazy while the arrival image is eager and preloaded.

I also compressed the 800-pixel arrival AVIF from 30.0 KiB to 19.4 KiB. That removed the image-delivery warning without producing an obvious visual loss. Going lower did.

Changing the hero from synchronous to asynchronous decoding did not help. Across controlled runs, the median Speed Index moved by about 2 ms. That is noise, so I reverted it.

The page got cheaper to load. Bytes were never the whole problem.

A 244-byte placeholder

After the image work, the frames showed one small blemish. The text appeared over a flat blue fallback, then the coast arrived 37 ms later.

I looked at a ThumbHash approach for Astro and at astro-lqip. Fine if you have a folder of images. I had one hero.

The smallest thing that worked was a 48-pixel WebP generated from the same hero. It is 244 bytes. I put its base64 data URL behind the real <picture> as a CSS background, so it adds no request and no browser script.

<section style={`--arrival-placeholder:url(${heroPlaceholder})`}>
  <WorldPicture image="hero-coast" fetchpriority="high" />
</section>
.arrival__picture {
  background: var(--arrival-placeholder) center 52% / cover no-repeat;
}

A small local Lighthouse test produced this median across three simulated mobile runs:

Fallback Speed Index LCP
Solid sky color 922 ms 1,503 ms
Inline 48 px WebP 902 ms 1,502 ms

Twenty milliseconds is small. It closed a local gap for roughly 350 bytes of HTML, so I shipped it.

Production disagreed. Uptrends showed the 48px still as a full-bleed blur. PageSpeed then marked the arrival-sign paragraph as LCP, not the coast image. The placeholder had given Lighthouse a first paint that was not the real still.

Arrival now uses the sky color until the AVIF lands. Inner-page heroes lost the same trick. The map still has a placeholder because it is allowed to arrive late.

The font swap did the same job

After that, the network tree still listed Barlow 700 and Press Start as children of the render-blocking CSS. Those faces paint the sign heading and the button, not the LCP paragraph, which uses Barlow 600 and is already preloaded.

I pulled the extra faces into a second stylesheet that started as media="print" and flipped to all after load. The tree looked cleaner. Speed Index did not.

On the same recipe, PageSpeed went from a 100 with Speed Index at 1.1 s to a 98 with Speed Index at 3.7 s, then a 99 at 2.3 s. LCP on those runs was 1.9 s and 2.0 s. The sign kept swapping after first paint. Same class of bug as the flecks.

I put the faces back on the blocking sheet. A later PageSpeed run was 100 again, with First Contentful Paint at 0.9 s, LCP at 1.2 s, and Speed Index at 0.9 s.

The optimization I threw away

At one point I inlined some first-screen color, broadened font preloads to every page, and preloaded each inner-page hero. The dependency tree looked cleaner. The screenshots got worse, including one run that did not become visually complete until 8.56 s.

I liked that cleaner tree. The pass did not improve repeated tests, so I reverted it. One ugly run is not evidence about request order or render-blocking CSS.

An unscored warning is not a reason to rearrange a working critical path.

I did move one tag. The stylesheet used to sit under JSON-LD, an inline analytics stub, and Astro’s view-transition router. PageSpeed listed that router as a parent of the CSS request. The hashed CSS file now comes first in <head>. That is not the same as inlining the sheet.

I then dropped the router. This is a static site. One transition:name on the logo was not worth 6.6 KB of JavaScript and a forced reflow on the critical path. The remaining module script positions the arrival sign.

The stylesheet still blocks first paint because stylesheets do that. PageSpeed will keep flagging global.css. I’m not inlining it again.

The remaining bad run was real

After the canvas and image changes, I ran production Lighthouse three times.

Run Score Speed Index Blocking time
1 100 1.13 s 25 ms
2 100 1.18 s 19 ms
3 68 3.02 s 1.36 s

A managed Cloudflare challenge script injected by the hosting layer spent 3.42 s on the main thread in that last run.

My static host does not expose a switch for that script. I cannot compress an AVIF until third-party main-thread work disappears. The outlier is still part of the experience for a visitor who receives it, but it needs a hosting-layer fix, not another image tweak.

A 68 is a bad score for a page that already looked finished. I stopped treating the integer score as the result. The frames, the trace, and the repeated runs tell me what changed. The badge does not.

Next time

I would open those frames first.

If the first screen looks done while Speed Index keeps climbing, I would look for a canvas, font swap, placeholder, or anything else that continues to repaint. Then I would pause decorative work until interaction and test again.

I would still do the boring file work. Responsive images, honest sizes, preload only the actual LCP asset, keep below-the-fold art lazy. I would not put a tiny LQIP under a full-viewport LCP image. I would not defer the heading font to tidy a network tree.

I would change one thing at a time and keep the failed runs. They are usually more useful than the screenshot with 100 in the corner.

Speed Index was not wrong. I thought I had asked when the page looked done. It was asking when the pixels stop changing.

Stop when the picture is done.

Questions

What is a good Speed Index score?

Lighthouse marks mobile Speed Index of 3.4 seconds or less as fast, 1.3 seconds on desktop. Clean deianisac.com runs were 1.13 s, 1.18 s, and 0.9 s.

What is a good LCP score?

Lighthouse marks mobile LCP of 2.5 seconds or less as fast. A DebugBear run hit 846 ms while Speed Index was still four seconds. Later PageSpeed runs sat at 1.2 s to 1.4 s.

How do I interpret Lighthouse results when scores disagree?

Read the frames and the repeated runs, not the badge. One build scored 1.1 s, 4.1 s, then 68 from Cloudflare. Deferring Press Start moved Speed Index from 1.1 s to 3.7 s.