mirror of
https://github.com/Such-Software/such.software.git
synced 2026-10-17 05:32:05 +02:00
- New post 'My Animated Splash Was Tanking Our Mobile PageSpeed Score' (draft:false): the LCP investigation, the data-URI diagnostic, and the real fix (shrink the use-client boundary / server-render the static parts). Two SVG diagrams. - Such Moon Launch now live on iOS: App Store link wired on the apps and product pages.
129 lines
7.7 KiB
Plaintext
129 lines
7.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 day spent learning the fix had nothing to do with 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, got it wrong the obvious way first, and one change fixed it. The short version:
|
|
the fix was sending most of the page as plain HTML instead of as code the browser has to run. The
|
|
image tricks 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, but I initially misread it. Total Blocking Time (TBT) was green: about 30
|
|
milliseconds, which I took to mean the main thread was idle. It does not mean that. TBT only counts
|
|
the part of a task that runs past 50 milliseconds, so a task that runs for 40 counts as zero. The page
|
|
ran a lot of those, each just under the bar, and they added up.
|
|
|
|
Here is where they came from, and it is the part that is easy to get wrong. The page was already
|
|
server-rendered: the framework sends real HTML, logo included, which is why it looked fine and ranked
|
|
fine. But the whole page was also a client component, so the browser downloaded all of its JavaScript
|
|
and re-ran it to wire up clicks and state. That second pass has a name, hydration, and it was a swarm
|
|
of small tasks holding the main thread while the logo, already sitting in the HTML, waited to be
|
|
drawn.
|
|
|
|
Lighthouse splits LCP into loading the element and then drawing it. My logo loaded instantly, since it
|
|
was inline, so almost all of the five seconds was drawing: the pixels were ready and the browser was
|
|
too busy hydrating to put them on screen. The filmstrip agreed, the logo showed up about halfway
|
|
through the load, not at the start.
|
|
|
|
In the end I did not hunt down the exact line holding the paint back. I did not need to. The image was
|
|
not slow. The page was.
|
|
|
|
## The fix
|
|
|
|
So this was never "turn on server-side rendering." That was already on. The trap is subtler, and easy
|
|
to spring by accident: one `"use client"` at the top of the page, added for a single interactive piece
|
|
(the splash), pulls the entire page under it into the client bundle. Everything below the mark becomes
|
|
code that ships and re-runs in the browser, and it still emits correct HTML the whole time, so nothing
|
|
looks broken until you test on a slow phone.
|
|
|
|
The fix is to shrink that boundary. Most of a homepage is not interactive: a headline, some
|
|
paragraphs, a few cards. That can stay server-only, plain HTML with nothing to ship and nothing to
|
|
wake up. So I split it: the hero, the copy, and the sections are server-rendered now and hydrate to
|
|
nothing, only the genuinely interactive parts (splash, header, bottom nav, animated background) ship
|
|
as one small island of JavaScript.
|
|
|
|
<figure>
|
|
<img src="/images/blog/animated-hero-core-web-vitals/server-vs-client.svg" alt="Before: one client component holding everything, all shipping as JavaScript, paints late. After: a small client island plus server-HTML content, paints early." />
|
|
<figcaption>Before, the entire page was interactive JavaScript. After, only a small island is; the rest is plain
|
|
HTML the browser can show immediately.</figcaption>
|
|
</figure>
|
|
|
|
Mobile went from the 60s to 95. Desktop held at 98. It stopped jumping between runs, because the
|
|
largest paint no longer waits on a pile of JavaScript to settle.
|
|
|
|
## What I would actually tell you
|
|
|
|
- If a page is slow to appear, look at how much of it is interactive JavaScript. In the Next.js App
|
|
Router, one `"use client"` at the top makes the whole page client code; push that mark down to the
|
|
smallest piece that actually needs it.
|
|
- To tell whether you are waiting on a file or on JavaScript, inline the slow image in the HTML and
|
|
measure again. If nothing improves, stop optimizing the file.
|
|
- A "responds quickly" score is not a "draws quickly" score. A green Total Blocking Time can still
|
|
hide seconds of small tasks. Read the breakdown of the slow number, not the one next to it.
|
|
- An image drawn straight into the HTML (inline SVG) will not count as the largest paint. Use an
|
|
`<img>` tag.
|
|
- Animate looping effects with CSS, not JavaScript. The browser moves and fades things on a separate
|
|
track without interrupting your code; a JavaScript animation does work on every frame. (The desktop
|
|
half of this story: different number, same lesson.)
|
|
- None of it needed a content delivery network (CDN). The standard advice for a slow page is "put a
|
|
CDN in front of it." This was about when things draw and what has to run as code. A CDN solves a
|
|
different problem.
|
|
|
|
## 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, find out whether you are waiting on a file or on your own code
|
|
before you optimize the wrong one.
|