mirror of
https://github.com/Such-Software/such.software.git
synced 2026-10-17 05:32:05 +02:00
- bento-grid: the Occupy Wallets tile is height-constrained (object-contain) to ~240px wide, but sizes='33vw' made Next serve a 750w file into a 241px slot (~62 KiB waste). Cap the sizes hint to the real display size. - blog: add the 'a month later' chapter to the animated-hero post — the Turnstile 450KB below-the-fold weight, deferred, mobile 75 -> 93 / desktop 99.
142 lines
8.7 KiB
Plaintext
142 lines
8.7 KiB
Plaintext
---
|
|
title: "My Animated Splash Was Tanking Our Mobile PageSpeed Score. It Wasn't the Image."
|
|
date: "2026-06-06"
|
|
excerpt: "An animated hero, a mobile score stuck in the 60s, and a fix that came down to one render-blocking stylesheet, not the image."
|
|
draft: false
|
|
---
|
|
|
|
# My Animated Splash Was Tanking Our Mobile PageSpeed Score. It Wasn't the Image.
|
|
|
|
Our homepage opens with a splash: the SUCH mark glowing over a Cherenkov-blue field, a water ripple
|
|
when you click through, a cross-fade into the page. I like it. On Google's Lighthouse test it scored
|
|
98 out of 100 on desktop. On mobile it sat in the 60s, sometimes failing outright, and the number
|
|
dragging it down was Largest Contentful Paint (LCP).
|
|
|
|
A "paint" is the browser drawing pixels to the screen. First paint is when anything shows up. The
|
|
largest contentful paint is when the biggest piece of real content, usually a headline or an image,
|
|
finishes drawing. Google weighs LCP heavily because it stands in for "the page looks ready."
|
|
|
|
I chased that number and got it wrong twice before the breakdown showed me the obvious thing. The short
|
|
version: the logo was ready instantly, but the browser would not paint it until a separate stylesheet
|
|
request came back. Inline the CSS so it ships with the page, and the logo paints right away. The image
|
|
fixes I tried first barely moved it.
|
|
|
|
## The obvious suspect
|
|
|
|
On mobile the tagline is hidden, so the biggest thing on screen is the logo. So you would naively
|
|
optimize the logo. Seems reasonable.
|
|
|
|
First gotcha worth knowing: Chrome will not let a drawing written straight into the page (an inline
|
|
SVG, for scalable vector graphics) count as the largest paint. Plain text counts. An `<img>` tag
|
|
counts, even pointing at an SVG file. An `<svg>...</svg>` written into the HTML does not. Our logo was
|
|
the inline kind, and on mobile it was the only big thing on screen, so the test had nothing valid to
|
|
measure and reported an error. Swapping it for an `<img>` tag made the error go away, which felt like
|
|
progress.
|
|
|
|
The score barely moved. LCP was still four or five seconds, and it jumped between runs. That jumping
|
|
is a clue: a number that swings by whole seconds is not held back by one slow thing you can point at.
|
|
|
|
## The test that ruled out the image
|
|
|
|
If the logo is slow, the usual suspect is the network: it is a separate file the browser has to fetch,
|
|
and on a slow connection that fetch waits behind the CSS and JavaScript. Easy to test. Inline the
|
|
image into the HTML as text (a data URI), so there is no separate file and no request. If the download
|
|
was the problem, the logo should now paint about when the rest of the page first does, near a second.
|
|
|
|
It did not. With the image inlined, LCP was still about five seconds. The download was never the
|
|
bottleneck. Whatever held the logo back happened after the bytes were already in hand.
|
|
|
|
<figure>
|
|
<img src="/images/blog/animated-hero-core-web-vitals/lcp-timeline.svg" alt="Two timelines. Before: first paint at 1.1s, a four-second gap, LCP at 5.2s. After: first paint 1.1s, LCP 1.4s, gap gone." />
|
|
<figcaption>The logo's pixels were ready early, but the browser did not draw them as the largest paint until
|
|
seconds later. That gap is the thing to fix.</figcaption>
|
|
</figure>
|
|
|
|
## Where the time actually went
|
|
|
|
The clue was in front of me, and I read it backwards. Total Blocking Time (TBT, roughly how long the
|
|
page is too busy to respond to a tap) was tiny, about 20 milliseconds. The main thread was free. So
|
|
whatever held the logo back, it was not JavaScript either.
|
|
|
|
Lighthouse splits LCP into two parts: loading the element, then drawing it. The breakdown was blunt.
|
|
Time to first byte: basically zero. Load time: zero, since the logo is inline. Render delay: about two
|
|
and a half seconds. The pixels were in the HTML, the server was fast, the main thread was idle, and
|
|
still nothing drew for over two seconds.
|
|
|
|
There is one thing that makes a browser hold a ready element off the screen like that: it is waiting on
|
|
render-blocking CSS. The page shipped its styles as one separate stylesheet, and a browser will not
|
|
paint anything until that file is fetched and parsed. On a throttled phone that single extra round-trip
|
|
was the whole gap, and its timing wobbled run to run, which is exactly why the score bounced between 70
|
|
and 96 on identical code.
|
|
|
|
## The fix
|
|
|
|
So the fix was to stop making the paint wait on a second request. Next can inline the CSS straight into
|
|
the HTML, so the styles arrive in the same response as the markup. No separate stylesheet, no extra
|
|
round-trip, nothing render-blocking between the HTML and the first paint.
|
|
|
|
<figure>
|
|
<img src="/images/blog/animated-hero-core-web-vitals/css-critical-path.svg" alt="Before: the HTML arrives, then the browser fetches and waits for a separate stylesheet, then the logo paints late. After: the HTML arrives with the CSS inlined, and the logo paints immediately." />
|
|
<figcaption>The logo was ready either way. Before, the paint waited on a separate render-blocking stylesheet
|
|
request; inlining the CSS removes that round-trip.</figcaption>
|
|
</figure>
|
|
|
|
With the CSS inlined, render delay collapsed and the logo painted with the page. Mobile went from the
|
|
60s to a steady 100, and the run-to-run bouncing stopped, because the thing that varied, that one CSS
|
|
request, was no longer on the path.
|
|
|
|
For the record, I also did the things you are supposed to do: moved most of the page to server-rendered
|
|
HTML instead of one big client component, and dropped a JavaScript animation library off the first
|
|
load. That cut the blocking time and is worth doing on its own. But the breakdown is what kept me
|
|
honest. The LCP gate was the stylesheet, not the JavaScript, and only inlining the CSS made the number
|
|
stick.
|
|
|
|
## What I would actually tell you
|
|
|
|
- Read the LCP breakdown, not just the number. If render delay dominates while time-to-first-byte and
|
|
load time are near zero, you are not waiting on the network or the server. You are waiting on
|
|
something that blocks rendering.
|
|
- Render-blocking CSS is that something, more often than you would think. One stylesheet is one extra
|
|
round-trip between the HTML and the first paint, and on a slow connection that can be seconds. Inline
|
|
it, or split it so the above-the-fold styles ship with the page.
|
|
- A data URI rules out the network in one move. If inlining the image does not change LCP, stop
|
|
optimizing the image.
|
|
- An image drawn straight into the HTML (inline SVG) does not count as the largest paint. Use an `<img>`.
|
|
- A green Total Blocking Time does not mean a fast paint. They measure different things, and a free main
|
|
thread can still sit behind a render-blocking request. Read the breakdown of the slow number, not the
|
|
one next to it.
|
|
- Trimming JavaScript (server components, fewer libraries) is still worth doing for responsiveness and
|
|
weight, even when it is not what is gating your paint.
|
|
- None of this needed a content delivery network (CDN). It was about what blocks the first paint, fixed
|
|
in the build.
|
|
|
|
## Why I care about the boring version
|
|
|
|
Performance is part of accessibility, which is a core tenet of this studio. A hero that feels instant
|
|
on my laptop can be unusable on a cheap phone over a weak connection, and that phone is somebody's only
|
|
device. Getting it to appear fast there is part of the same work as getting it to run with a screen
|
|
reader or at 400% zoom.
|
|
|
|
The splash survived. It still shimmers and dissolves into the page, and it skips the intro entirely for
|
|
anyone whose device asks for less motion.
|
|
|
|
When something is slow to appear, read the breakdown before you touch anything. The wait is often not
|
|
where you would guess, and mine was hiding in a single stylesheet request the whole time.
|
|
|
|
## Update, a month later: it wasn't the stylesheet this time either
|
|
|
|
The mobile score slid back into the 70s. I took my own advice and read the breakdown first, and the
|
|
render-blocking culprit from last time was gone. The new drag was download weight: the page was pulling
|
|
about 940 kilobytes on mobile, nearly half of it a single 450-kilobyte bundle from Cloudflare Turnstile,
|
|
the "are you a human" check on our contact form.
|
|
|
|
The catch is where that form lives. On the homepage it sits far below the fold, but the widget loaded
|
|
its scripts the instant the page did, so on a phone over a weak connection that 450 kilobytes competed
|
|
with the content people actually came to see. It never hurt desktop, which is why the score split, high
|
|
on a laptop and mediocre on a phone.
|
|
|
|
The fix was to make the widget lazy: it now loads only when the form scrolls near the screen, and on the
|
|
dedicated contact page, where the form is right there, it still loads at once. Page weight dropped from
|
|
about 940 kilobytes to 490, and mobile went from the mid-70s to 93 (desktop, 99). Same lesson as before,
|
|
in a new disguise: the cost was real, it was just parked somewhere nobody was looking.
|