Bryan Hamilton / writing

bryanhamilton.info / writing / article

7.0s to 2.3s: The Core Web Vitals Takedown on a 1,000-Page PHP Site

A 1,000-page PHP site, one render-blocking fonts link, and a measurement habit: how The DJ Calendar went from a 7.0-second homepage LCP to 2.3 - and the cache layers that made every fix take three tries to verify.

  • November 3, 2026
  • 6 min read
  • performance · webdev · php · corewebvitals
  • More writing

I run The DJ Calendar - a PHP/MySQL platform with roughly 1,000 schema.org-rich pages tracking EDM tour dates. My structured data was clean. My Rich Results warnings were zero. And then I looked at my homepage LCP and it said 7.0 seconds.

Seven seconds. For a page that is, structurally, a list of artist cards. This is the teardown that took it to 2.3 seconds - a 67% cut - and the three traps that made a one-day fix take a week to verify.

Measure what the browser actually fetches

The first rule of this whole saga: diagnose against the served page, not your source code. My stack runs LiteSpeed (page cache, ~5 min HTML) behind Cloudflare, with PHP OPcache on a 60-second revalidate. The same fix can look live, dead, and live again inside ten minutes depending on which layer you're looking at.

So every measurement in this story follows the same ritual: cache-busted fetch of the served HTML, then read the <link> and <script> tags that a real browser would see. Not the source file. The served page.

Culprit one: render-blocking fonts

The homepage's render chain looked like this:

<link href="https://fonts.googleapis.com/css2?family=Unbounded&family=Chakra+Petch&display=swap" rel="stylesheet">

One third-party stylesheet, gating first paint, on every cold visit. display=swap doesn't save the LCP element if the stylesheet itself is render-blocking - the browser still waits on the CSSOM before it knows what "swap" even means.

The first pass kept Google Fonts but stopped the bleeding: preloads for the WOFF2 files and the CSS link made non-blocking. That alone moved LCP meaningfully. But a third-party stylesheet dependency on a content site is a tax you pay forever - availability, latency, privacy, all of it. So the second pass removed it:

<link rel="preload" href="/fonts/unbounded-400-900.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/fonts/djc-fonts.css?v=1">

Self-hosted WOFF2, preloaded, with the font-display declared in my own CSS. The two preconnect hints for fonts.googleapis.com and fonts.gstatic.com got deleted outright - dead weight after the migration.

This ran across 129 root PHP files in one sweep. Which brings me to the gotchas.

Three traps on the way to shipped

Trap 1: the variable font parser. Unbounded ships as a variable font with weight range 400 900. A naive "grab the first number" parse produced a broken weight axis. The sweep also had to be written to continue per file - my first version aborted on the first file whose shape it didn't expect. The v3 rule: restore what you touched, log it, keep going, report at the end.

Trap 2: the helpful middleware. Cloudflare's Fonts feature was silently rewriting my Google Fonts links to its own /cf-fonts/ mirror. So the origin looked fixed while the edge served old markup - and I spent a verification cycle chasing a phantom. If a CDN offers to "optimize" something for you, assume it will complicate your diff.

Trap 3: the wrong stylesheet. An earlier fix in this saga taught me this the hard way: /style.css is served via an .htaccess rewrite to a different file on disk. I patched the decoy, the change rendered nothing, and I almost shipped a second patch on top of it. Grep the rewrite rules before you patch any stylesheet, and verify the served bytes.

The numbers

Homepage LCP: 7.0s to 2.3s, measured on the production page, not a toy replica. Still on the table: total blocking time on the homepage sits around 600ms, and the Unbounded WOFF2 weighs about 50KB - the next round of this fight.

The boring summary, for people who like lists:

  • Third-party font stylesheets are render debt. Preload is a patch; self-hosting is the fix.
  • Cache-bust every verification - your page cache, CDN, and OPcache are all lying to you at different intervals.
  • Sweeps must continue-per-file and self-report, or they die on the first weirdo.
  • Verify the served page. Every time. The chain between your editor and the browser has more actors than you think.

The site runs faster, the fonts are mine, and the next person who audits this stack will find the fonts right where the page says they are.

More from the workshop

All writingEvery article, on this domain. Open source11 production-extracted projects.