Originally published at deianisac.com.
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, not idle time. Clean Speed Index 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, two small module scripts, 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 `now has asizes` 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 `` as a CSS background, so it adds no request and no browser script.
.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 closes the visible gap for roughly 350 bytes of HTML, so I kept it. I did not add a dependency or a blur transition.
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. My later tree was HTML, one small stylesheet, and two small module scripts. The stylesheet blocks first paint because stylesheets do that. The scripts did not.
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, transition, 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 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.
Top comments (0)