All Articles

Picture the person this app is for. She's three miles into a trail run, phone out of an armband, one bar of signal if she's lucky, and she wants to see today's workout right now. Not after a spinner, not after a blank white screen while something loads. Right now, so she can keep moving. That's the moment Pheidi has to be fast. Not fast on my laptop on office wifi, fast there, in the woods, on a mid-range phone with a tired battery and almost no signal. For months I was shipping an app that wasn't, and I didn't notice, because it was always fast for me.

I believe a good product is a fast product. Speed isn't a feature you bolt on at the end, it's respect for the person on the other side of the screen. And it's the small stuff, the half-seconds nobody ever puts on a roadmap, that decides whether an app feels like a coach who's ready the instant you need one or a thing that makes you wait. Nobody opens an app hoping it takes its time. So this is a build log about going hunting for those half-seconds, starting with the one that was hiding in plain sight at the very top of my app.

I build Pheidi alone. It's an adaptive running training app, and like most apps it leans on a few well-known front-end libraries: Bootstrap for layout, Font Awesome for icons, and Plus Jakarta Sans for the brand font. The standard way to load those, the way every tutorial shows you, is from a CDN. So that's what I did, without thinking about it, because not thinking about it is the whole appeal.

Here's what the top of my app was doing:

  • Bootstrap CSS from jsDelivr
  • Font Awesome CSS from cdnjs
  • The font from Google Fonts, which is really two hops: the CSS from one Google host, then the font files from a second

That's three providers and, counting the Google Fonts split, four external hostnames. The received wisdom says this is good. CDNs are fast, they're geographically close to your users, they're free, and if enough sites share the same CDN copy the file might already be cached in the visitor's browser. All true, once. The shared-cache argument died years ago when browsers partitioned their caches by site for privacy, so that supposed free lunch is gone. The rest sounded fine to me, so I left it alone.

What I was actually paying for

The thing nobody puts in the tutorial is what a cross-origin stylesheet costs before it downloads.

CSS is render-blocking. The browser will not paint your page until it has the stylesheets, because painting first and restyling after would be a flickering mess. Fair enough. But a stylesheet on someone else's domain isn't just a download. Before the browser can ask for a single byte, it has to open a connection to that host: a DNS lookup, a TCP handshake, and a TLS handshake. Three round trips, roughly, to a server you've never talked to in this session. And it's on the critical path, because the paint is waiting on it.

I had three of those. On my laptop on office wifi you'll never feel it. But my runner in the woods feels every one. Each handshake to a stranger's server is another moment she's staring at a blank screen with one bar of signal, wondering if the app is broken, when all she wanted was to know whether today is intervals or an easy four miles. That's the cost. Not an abstract millisecond on a chart, a real person kept waiting at the exact moment she reached for the thing I built. Faster is always better here, and "fast enough on wifi" was failing the only test that counts.

There was a second symptom I'd written off as "just how it loads." For a beat on a cold start, the app rendered unstyled: no layout, no icons, wrong font, then everything snapped into place. That's a flash of unstyled content, and it happens precisely because the styling is parked behind those external connections. I'd stopped seeing it the way you stop hearing a fridge hum.

The fix is a delete

The fix is unglamorous. You download the files and serve them from your own domain. Bootstrap, Font Awesome and its web fonts, and the Plus Jakarta Sans woff2 subsets all moved into my own app, served from the same origin as everything else. The CDN <link> and <script> tags got replaced with local paths. The Google Fonts link came out entirely.

Two details worth stealing. First, I put the static assets behind Brotli compression, because the Linux host I run on doesn't compress them automatically the way a CDN would. That matters: the Bootstrap, Font Awesome, and JS that make up the critical text dropped from about 382 KB raw to 54 KB over the wire. Second, I added a small test that fails the build if any http:// or https:// asset URL ever reappears in the host page, so a stray CDN tag can't sneak back in during some future copy-paste.

That's the whole change. The interesting part isn't the fix. It's the measurement, because "it feels snappier" is not a number and I didn't want to write a post built on a vibe.

The measurement

I ran Lighthouse against the app locally under its simulated Slow 4G profile, which models the mobile round-trip latency that makes those handshakes hurt. To get a real before-and-after I measured the current self-hosted version, then temporarily swapped the three local stylesheets back to their CDN URLs, measured again, and reverted. Median of three runs each. The numbers barely moved between runs, so I'm fairly confident in them.

MetricCDN (before)Self-hosted (after)Change
First Contentful Paint2375 ms1657 ms30% faster
Speed Index2375 ms1657 ms30% faster
Origins on the critical path62minus 4
External render-blocking stylesheets30minus 3

First Contentful Paint, the moment the browser first puts text on the screen and what I mean every time I say "first paint" here, went from 2.37 seconds to 1.66 seconds. A third of it, gone, for a change that added zero features and deleted some lines.

Lighthouse will even name the culprits for you. In the CDN run it flagged the three external stylesheets as render-blocking, and attributed roughly 1.3 seconds to the Bootstrap request, 1.2 seconds to Font Awesome, and 0.85 seconds to Google Fonts. After the change those three are same-origin, sharing the connection the browser already has open, so there's no fresh handshake to wait on. Six distinct origins on the page became two.

One honest note on method: I gave the CDN baseline the benefit of the doubt by adding the preconnect hints that a careful developer would use to warm those connections early. So the real-world gap, for the common case where people don't add those hints, is probably wider than what I measured. The 30% is a floor.

The number that didn't move

Here's the part the tutorial version of this post would skip.

Largest Contentful Paint, the time until the biggest element on screen is painted, did not improve. It sat around 3.3 seconds before and after. If I'd only shown you the First Contentful Paint win, I'd have been selling you a clean story that isn't true.

The reason is that my main content isn't waiting on CSS at all. The app's interactive shell has to boot before the largest element settles, and that boot, downloading the framework's JavaScript and opening its connection back to the server, is the real gate on LCP. Self-hosting a stylesheet does nothing for it, because the stylesheet was never the bottleneck there. Different problem, different fix, and I'm chasing it separately. Forcing the CSS change to take credit for it would be exactly the kind of thing that makes performance posts untrustworthy.

So the result is specific, which is the only kind worth reporting: self-hosting the CSS bought me a faster first paint and a calmer cold start, and it did not touch the largest paint. Both of those are true, and only one of them is the headline.

A good product is a fast product

The general lesson isn't "CDNs are bad." They're a reasonable default and for plenty of sites the difference is noise. The lesson is the thing I keep coming back to: the small stuff matters, and faster is always better. Nobody opens an app hoping it takes its time. Every half-second you hand back is a half-second of someone's run you're not stealing.

If you want the one tactical rule underneath that, it's this:

Anything on your critical render path should live on your own origin.

Render-blocking means the user is staring at nothing until it arrives, so don't make that depend on a handshake to a server you don't control, for a saving (the shared cache) that browsers took away. Own the critical path. Rent the rest, the commodity plumbing like email delivery and hosting where someone else's scale genuinely beats mine.

But the rule is downstream of the belief. The reason I went looking at all is that I think sweating this kind of detail is what sets a product apart. Anyone can ship the features. What makes an app feel like it respects you is the hundred small decisions about whether it's fast at the exact moment you need it, and that is the part I can win at as one person against bigger teams who've stopped noticing the half-seconds. So I notice them. That's the whole edge.

And measure before you believe yourself. I'd have told you my app loaded fine, because it loaded fine for me, on my laptop, on my wifi. The runner standing at a trailhead with one bar was getting a slower app than I thought, and the only reason I know how much slower is that I stopped trusting the vibe and ran the test.

If you've got CDN tags at the top of your own app, that's a fifteen-minute experiment and a Lighthouse run. Worst case, you confirm it's already fine. Best case, you find a third of someone's first impression sitting in a stranger's handshake.