Cursor timing (~4 blinks each side); rewrite perf post to the real fix

- Monitor terminal: ~4 blinks, type 'such', backspace, ~4 blinks, fade.
- Rewrite the blog post so it tells the accurate story end to end (not an update):
  the LCP breakdown showed render delay with TTFB 0 and a free main thread, so it
  was neither the network nor JavaScript. It was render-blocking CSS, fixed by
  inlining the stylesheet. Server components / dropping framer are kept as honest
  supporting steps, not the gate. Swap the architecture figure for a CSS
  critical-path figure; mobile now a steady 100.
This commit is contained in:
jwinterm
2026-06-07 17:32:21 -04:00
parent 89c086398d
commit d2d4ff997f
4 changed files with 88 additions and 101 deletions
@@ -1,7 +1,7 @@
--- ---
title: "My Animated Splash Was Tanking Our Mobile PageSpeed Score. It Wasn't the Image." title: "My Animated Splash Was Tanking Our Mobile PageSpeed Score. It Wasn't the Image."
date: "2026-06-06" 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." 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 draft: false
--- ---
@@ -16,9 +16,10 @@ A "paint" is the browser drawing pixels to the screen. First paint is when anyth
largest contentful paint is when the biggest piece of real content, usually a headline or an image, 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." 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: I chased that number and got it wrong twice before the breakdown showed me the obvious thing. The short
the fix was sending most of the page as plain HTML instead of as code the browser has to run. The version: the logo was ready instantly, but the browser would not paint it until a separate stylesheet
image tricks I tried first barely moved it. 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 ## The obvious suspect
@@ -53,76 +54,71 @@ bottleneck. Whatever held the logo back happened after the bytes were already in
## Where the time actually went ## 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 The clue was in front of me, and I read it backwards. Total Blocking Time (TBT, roughly how long the
milliseconds, which I took to mean the main thread was idle. It does not mean that. TBT only counts page is too busy to respond to a tap) was tiny, about 20 milliseconds. The main thread was free. So
the part of a task that runs past 50 milliseconds, so a task that runs for 40 counts as zero. The page whatever held the logo back, it was not JavaScript either.
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 Lighthouse splits LCP into two parts: loading the element, then drawing it. The breakdown was blunt.
server-rendered: the framework sends real HTML, logo included, which is why it looked fine and ranked Time to first byte: basically zero. Load time: zero, since the logo is inline. Render delay: about two
fine. But the whole page was also a client component, so the browser downloaded all of its JavaScript and a half seconds. The pixels were in the HTML, the server was fast, the main thread was idle, and
and re-ran it to wire up clicks and state. That second pass has a name, hydration, and it was a swarm still nothing drew for over two seconds.
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 There is one thing that makes a browser hold a ready element off the screen like that: it is waiting on
was inline, so almost all of the five seconds was drawing: the pixels were ready and the browser was render-blocking CSS. The page shipped its styles as one separate stylesheet, and a browser will not
too busy hydrating to put them on screen. The filmstrip agreed, the logo showed up about halfway paint anything until that file is fetched and parsed. On a throttled phone that single extra round-trip
through the load, not at the start. 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.
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 ## The fix
So this was never "turn on server-side rendering." That was already on. The trap is subtler, and easy So the fix was to stop making the paint wait on a second request. Next can inline the CSS straight into
to spring by accident: one `"use client"` at the top of the page, added for a single interactive piece the HTML, so the styles arrive in the same response as the markup. No separate stylesheet, no extra
(the splash), pulls the entire page under it into the client bundle. Everything below the mark becomes round-trip, nothing render-blocking between the HTML and the first paint.
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> <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." /> <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>Before, the entire page was interactive JavaScript. After, only a small island is; the rest is plain <figcaption>The logo was ready either way. Before, the paint waited on a separate render-blocking stylesheet
HTML the browser can show immediately.</figcaption> request; inlining the CSS removes that round-trip.</figcaption>
</figure> </figure>
Mobile went from the 60s to 95. Desktop held at 98. It stopped jumping between runs, because the With the CSS inlined, render delay collapsed and the logo painted with the page. Mobile went from the
largest paint no longer waits on a pile of JavaScript to settle. 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 ## 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 - Read the LCP breakdown, not just the number. If render delay dominates while time-to-first-byte and
Router, one `"use client"` at the top makes the whole page client code; push that mark down to the load time are near zero, you are not waiting on the network or the server. You are waiting on
smallest piece that actually needs it. something that blocks rendering.
- To tell whether you are waiting on a file or on JavaScript, inline the slow image in the HTML and - Render-blocking CSS is that something, more often than you would think. One stylesheet is one extra
measure again. If nothing improves, stop optimizing the file. round-trip between the HTML and the first paint, and on a slow connection that can be seconds. Inline
- A "responds quickly" score is not a "draws quickly" score. A green Total Blocking Time can still it, or split it so the above-the-fold styles ship with the page.
hide seconds of small tasks. Read the breakdown of the slow number, not the one next to it. - A data URI rules out the network in one move. If inlining the image does not change LCP, stop
- An image drawn straight into the HTML (inline SVG) will not count as the largest paint. Use an optimizing the image.
`<img>` tag. - An image drawn straight into the HTML (inline SVG) does not count as the largest paint. Use an `<img>`.
- Animate looping effects with CSS, not JavaScript. The browser moves and fades things on a separate - A green Total Blocking Time does not mean a fast paint. They measure different things, and a free main
track without interrupting your code; a JavaScript animation does work on every frame. (The desktop thread can still sit behind a render-blocking request. Read the breakdown of the slow number, not the
half of this story: different number, same lesson.) one next to it.
- None of it needed a content delivery network (CDN). The standard advice for a slow page is "put a - Trimming JavaScript (server components, fewer libraries) is still worth doing for responsiveness and
CDN in front of it." This was about when things draw and what has to run as code. A CDN solves a weight, even when it is not what is gating your paint.
different problem. - 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 ## Why I care about the boring version
Performance is part of accessibility, which is a core tenet of this studio. A hero that feels Performance is part of accessibility, which is a core tenet of this studio. A hero that feels instant
instant on my laptop can be unusable on a cheap phone over a weak connection, and that phone is on my laptop can be unusable on a cheap phone over a weak connection, and that phone is somebody's only
somebody's only device. Getting it to appear fast there is part of the same work as getting it to run with a device. Getting it to appear fast there is part of the same work as getting it to run with a screen
screen reader or at 400% zoom. reader or at 400% zoom.
The splash survived. It still shimmers and dissolves into the page, and it skips the intro entirely The splash survived. It still shimmers and dissolves into the page, and it skips the intro entirely for
for anyone whose device asks for less motion. 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 When something is slow to appear, read the breakdown before you touch anything. The wait is often not
before you optimize the wrong one. where you would guess, and mine was hiding in a single stylesheet request the whole time.
@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 300" role="img"
aria-label="Two request timelines. Before: the HTML arrives, then the browser makes a second request for the stylesheet and waits for it, then the logo paints late. After: the HTML arrives with the CSS inlined, and the logo paints immediately.">
<rect x="2" y="2" width="756" height="296" rx="16" fill="#f8fafc" stroke="#e2e8f0"/>
<text x="100" y="40" font-family="ui-sans-serif, system-ui, sans-serif" font-size="19" font-weight="700" fill="#1e293b">Why the logo painted late: a second request</text>
<!-- Before -->
<text x="100" y="96" font-family="ui-sans-serif, system-ui, sans-serif" font-size="14" font-weight="600" fill="#334155">Before: separate stylesheet (render-blocking)</text>
<rect x="100" y="108" width="150" height="22" rx="3" fill="#e2e8f0"/>
<text x="175" y="123" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="11" fill="#475569">HTML (+ logo)</text>
<rect x="250" y="108" width="330" height="22" rx="3" fill="#fde68a" stroke="#d97706"/>
<text x="415" y="123" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="11" font-weight="600" fill="#b45309">fetch + wait for CSS</text>
<line x1="580" y1="100" x2="580" y2="138" stroke="#dc2626" stroke-width="2"/>
<circle cx="580" cy="119" r="5" fill="#dc2626"/>
<text x="580" y="154" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="11" font-weight="600" fill="#dc2626">logo paints (late)</text>
<!-- After -->
<text x="100" y="208" font-family="ui-sans-serif, system-ui, sans-serif" font-size="14" font-weight="600" fill="#334155">After: CSS inlined in the HTML</text>
<rect x="100" y="220" width="170" height="22" rx="3" fill="#d1fae5" stroke="#059669"/>
<text x="185" y="235" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="11" fill="#047857">HTML (+ logo + CSS)</text>
<line x1="270" y1="212" x2="270" y2="250" stroke="#059669" stroke-width="2"/>
<circle cx="270" cy="231" r="5" fill="#059669"/>
<text x="284" y="235" font-family="ui-sans-serif, system-ui, sans-serif" font-size="11" font-weight="600" fill="#047857">logo paints (early)</text>
<text x="100" y="282" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" fill="#64748b">Same logo, ready instantly either way. The wait was the CSS round-trip, not the image.</text>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -1,33 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 360" role="img"
aria-label="Before: page.tsx is one client component holding the splash, header, hero, sections, and contact form, all of which ship and run as JavaScript, so the page paints late. After: page.tsx is a Server Component; only a small client island (splash, header, nav, background) ships JavaScript, while the hero and sections are server HTML, so the page paints early.">
<rect x="2" y="2" width="756" height="356" rx="16" fill="#f8fafc" stroke="#e2e8f0"/>
<text x="40" y="40" font-family="ui-sans-serif, system-ui, sans-serif" font-size="18" font-weight="700" fill="#1e293b">One client component vs. server HTML plus an island</text>
<!-- Before -->
<text x="60" y="82" font-family="ui-sans-serif, system-ui, sans-serif" font-size="15" font-weight="700" fill="#334155">Before</text>
<rect x="40" y="92" width="300" height="206" rx="10" fill="none" stroke="#94a3b8" stroke-width="1.5"/>
<text x="54" y="114" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" font-weight="600" fill="#475569">page.tsx &#8212; "use client"</text>
<g font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" fill="#b45309" text-anchor="middle">
<rect x="54" y="124" width="272" height="28" rx="5" fill="#fde68a" stroke="#d97706"/><text x="190" y="142">Splash</text>
<rect x="54" y="158" width="272" height="28" rx="5" fill="#fde68a" stroke="#d97706"/><text x="190" y="176">Header + nav</text>
<rect x="54" y="192" width="272" height="28" rx="5" fill="#fde68a" stroke="#d97706"/><text x="190" y="210">Hero + tagline</text>
<rect x="54" y="226" width="272" height="28" rx="5" fill="#fde68a" stroke="#d97706"/><text x="190" y="244">Sections</text>
<rect x="54" y="260" width="272" height="28" rx="5" fill="#fde68a" stroke="#d97706"/><text x="190" y="278">Contact form</text>
</g>
<text x="190" y="322" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" fill="#b45309">all of it ships and runs as JavaScript</text>
<text x="190" y="342" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" font-weight="600" fill="#dc2626">&#8594; the page paints late</text>
<!-- After -->
<text x="420" y="82" font-family="ui-sans-serif, system-ui, sans-serif" font-size="15" font-weight="700" fill="#334155">After</text>
<rect x="400" y="92" width="320" height="206" rx="10" fill="none" stroke="#94a3b8" stroke-width="1.5"/>
<text x="414" y="114" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" font-weight="600" fill="#475569">page.tsx &#8212; Server Component</text>
<rect x="414" y="124" width="292" height="44" rx="5" fill="#fde68a" stroke="#d97706"/>
<text x="560" y="142" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" font-weight="600" fill="#b45309">client island</text>
<text x="560" y="159" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="10" fill="#b45309">splash &#183; header &#183; nav &#183; background</text>
<rect x="414" y="176" width="292" height="40" rx="5" fill="#d1fae5" stroke="#059669"/>
<text x="560" y="200" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" fill="#047857">Hero + tagline &#8212; server HTML</text>
<rect x="414" y="224" width="292" height="40" rx="5" fill="#d1fae5" stroke="#059669"/>
<text x="560" y="248" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" fill="#047857">Sections &#8212; server HTML</text>
<text x="560" y="322" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" fill="#059669">only the island ships JavaScript</text>
<text x="560" y="342" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" font-weight="600" fill="#047857">&#8594; the page paints early</text>
</svg>

Before

Width:  |  Height:  |  Size: 3.9 KiB

+7 -8
View File
@@ -252,18 +252,17 @@
border-right: 1.5px solid currentColor; border-right: 1.5px solid currentColor;
/* type "such", hold, backspace (nebula-type, once); blink a finite number of times /* type "such", hold, backspace (nebula-type, once); blink a finite number of times
(nebula-caret); then fade the whole thing out so it stops being distracting. */ (nebula-caret); then fade the whole thing out so it stops being distracting. */
/* blink ~7 times first (so you catch it even when the splash is skipped on refresh), /* blink ~4 times, type "such", backspace, blink ~4 more, then fade away. */
then type "such", hold, backspace, blink a few more, and fade away. */
animation: animation:
nebula-type 3s steps(4, end) 5.6s forwards, nebula-type 2.2s steps(4, end) 3.2s forwards,
nebula-caret 0.8s steps(1, end) 17, nebula-caret 0.8s steps(1, end) 11,
nebula-term-out 0.6s ease-in 13s forwards; nebula-term-out 0.6s ease-in 8.4s forwards;
} }
@keyframes nebula-type { @keyframes nebula-type {
0% { width: 0; } 0% { width: 0; }
27% { width: 4ch; } 36% { width: 4ch; }
67% { width: 4ch; } 64% { width: 4ch; }
87% { width: 0; } 90% { width: 0; }
100% { width: 0; } 100% { width: 0; }
} }
@keyframes nebula-caret { 0%, 50% { border-color: currentColor; } 50.01%, 100% { border-color: transparent; } } @keyframes nebula-caret { 0%, 50% { border-color: currentColor; } 50.01%, 100% { border-color: transparent; } }