Notes /
The slowest thing on my homepage was my name
The page is small, the images are lazy and the fonts are preloaded. A fifth of visits still waited too long for the main content, because I had told the browser to wait.
01
What the numbers said
This site has no framework and no build step: a few HTML pages, one stylesheet and a small script. I expected the performance numbers to be boring. Then Cloudflare Web Analytics, which collects timings from real visits, showed this for the first week:
| Good under 2.5 s | 79% |
|---|---|
| Poor over 4 s | 21% |
| Median | 584 ms |
| 75th percentile | 2,412 ms |
Largest Contentful Paint (LCP) is the moment the biggest piece of text or image in the first screen gets painted. A median of 584 ms is fine. A 75th percentile just under the 2.5 s line, with a fifth of visits past 4 s, is not.
The sample was small, about sixty visits, so a couple of slow phones move it a lot. But the pattern was worth a look.
02
Finding the element
Lighthouse, on its simulated slow phone, put LCP at 2.3 s and named the element:
<span style="--i:0">
That's the “H” in my name. It also splits LCP into phases, and the split was lopsided:
| Time to first byte | 641 ms |
|---|---|
| Resource load delay | 0 ms |
| Resource load time | 0 ms |
| Render delay everything after the page arrived | 1,638 ms |
Nearly three quarters of the time came after the page had already arrived. The browser had everything it needed and still didn't paint the letter.
03
Why the letter waited
My name on the homepage is set like a type specimen. Each letter sits below its line, clipped by overflow: hidden, and rises into place one after another:
.name-line { overflow: hidden; }
.name-line > span {
transform: translateY(115%);
animation: rise .9s forwards;
animation-delay: calc(350ms + var(--i) * 55ms);
}
A letter pushed fully below a clipping box isn't painted at all, so as far as LCP is concerned it doesn't exist until it starts to rise. The first letter waited 350 ms after the first render before it moved, and the last finished almost two seconds in. Below the name, the status line and the “Shipped” list started at opacity: 0 and only faded in after 1.15 s.
The usual suspects were innocent. In the network waterfall, the HTML, stylesheet, script and all three fonts finished downloading within about 0.3 s. The screenshots below the fold are lazy-loaded and never competed. The delay was a number I had typed into the stylesheet myself.
04
Keep the animation, make it cheaper
Removing the animation would have fixed the number, but the animation is most of the page's character. So I made two smaller changes.
Start sooner, finish sooner
animation: rise .8s forwards;
animation-delay: calc(60ms + var(--i) * 40ms);
The first letter now starts moving almost immediately, and the text below fades in at 0.5 s instead of 1.15 s.
Play it once per visit
Coming back to the homepage from a case study replayed the whole entrance every time, which nobody needs twice. A class set from sessionStorage turns it off after the first view:
try {
if (sessionStorage.getItem('intro')) d.classList.add('seen');
else sessionStorage.setItem('intro', '1');
} catch (e) {}
.seen .name-line > span { animation: none; transform: none; }
This has to run before the first paint. If it ran in the deferred script, the letters would already be hidden and rising by the time the class arrived. So it went into the small inline script in the <head> that already applies the saved colour theme.
05
The catch: a hashed script
The site's Content Security Policy doesn't allow inline scripts in general. It allows exactly one, identified by the SHA-256 hash of its text:
script-src 'self' 'sha256-…'
Change one character of that script, a space included, and the browser blocks it. The theme toggle and the new intro switch would both quietly stop working. So the change meant three things: edit the script identically on all seven pages, compute the new hash, and swap it into the headers file in the same deploy.
The same rule caught me again an hour later, when I put the site into git. On Windows, git converts line endings to CRLF on checkout by default. That changes the bytes of the inline script, which changes its hash, and that breaks the page. One line in .gitattributes pins the files to LF:
* text=auto eol=lf
06
What changed
In the lab, LCP went from 2.3 s to between 2.0 and 2.26 s across two runs. That's honest but not dramatic. The rest is first paint itself, about 1.6 s on Lighthouse's simulated slow connection: the server round trip plus the stylesheet that has to arrive before anything is drawn. Return visits in the same session skip the animation entirely. The real-visit numbers need another week of data before they mean anything.
What I'm keeping from this is simple. LCP measures when something is painted, not when it is downloaded, so an entrance animation is a render delay you chose. That's sometimes worth paying for, but it should be a decision, and the delay should be as short as the design allows.