Files
such.software/apps/web/content/blog/animated-hero-core-web-vitals.mdx
T
jwinterm 33400c5879 Publish perf blog post + Such Moon Launch iOS links
- 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.
2026-06-06 20:41:31 -04:00

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.