It started with a tiny flicker
I shipped a redesign, hit refresh, and caught a tiny flicker in the header.
Body text popped in system font first. Then the web font slid in behind it. The whole thing was over in maybe 90 milliseconds. But once I saw it, I could not unsee it.
That micro flash sent me down a rabbit hole that ended with a very different performance budget than the one I started with.
The crime scene: a 90ms font swap
The setup was pretty standard.
- Self-hosted variable font, WOFF2 only.
font-display: swapso we do not block rendering.- Preload hint for the main text face.
Lighthouse loved it. Metrics were fine. No red flags.
Then I loaded the site on my office monitor. 144 Hz, fairly fast machine, clean cache. The render felt slightly "shifty". Not full layout shift. More like the UI taking a tiny breath right after load.
I cracked open Performance in Chrome. Recorded a trace. Measured between first paint of system font and the swap to the web font. Around 90ms on my machine. 120-140ms on an older laptop. On 3G throttling, it stretched near 200ms.
So now I had a number to obsess over. Dangerous.
Measuring the itch
I wired up a crude metric, because if I am going to chase a ghost, I at least want a graph.
The idea:
- Record the timestamp when we start rendering content.
- Listen for
fontloadingandfontactivefrom a small FontFaceObserver wrapper. - Compute
fontSwapDelay = fontActiveTime - firstContentPaintTime.
Not perfect. Good enough.
On a real device set, I saw:
- Fast desktop: 60-100ms.
- Average laptop: 110-180ms.
- Mid-range Android on 4G: 220-320ms.
I could feel it up to around 200ms on my own devices. Past that, it stopped being "barely noticeable" and started being "the page twitches after I start reading".
So my brain did what developer brains love to do. It turned into a personal OKR.
"Get that swap under 100ms for 95% of visits."
No stakeholder asked for this. No user complained. This was pure, uncut perfectionism, dressed up as performance work.
Optimising the wrong hill
I tried everything that sounded even remotely sensible.
1. Smaller font payload
I subset the font:
- First full Latin + symbols.
- Then Latin-1 only.
- Then a split: core text subset for initial load, extended subset lazy-loaded.
Gained maybe 20-30ms on shaky networks. At the cost of a more complex build step, extra font files, and some lovely edge cases with missing glyphs on rare characters.
Now I had body-core.woff2, body-extended.woff2, and a lingering sense that I was doing too much for too little.
2. Font loading strategy acrobatics
I flipped through strategies like I was speed-running a blog post roundup.
font-display: optionalto skip the web font if it was too slow.- Manual class toggling once fonts loaded.
- Different strategies for first visit vs. repeat visit using localStorage.
Result:
- Fewer noticeable swaps on slow connections.
- More variance in visual appearance between visits.
- No dramatic impact on that 90-150ms band I was obsessed with.
I had traded a tiny flicker for a different set of inconsistencies. Not better. Just different.
3. System font stack experiments
The root issue was not just timing. It was the difference between the fallback font and the web font.
If the metrics match closely, the swap feels less like a hiccup and more like a refinement. So I hunted for a system font stack that roughly matched my chosen typeface.
I played with letter-spacing, line-height, and font-size to get the fallback as close as possible.
Did it help? A bit.
The swap became less noticeable on fast loads. On slower ones, you still saw the "before" and "after" state long enough for your brain to register that something just changed.
4. No web fonts at all
At some point I rage-switched to pure system fonts just to sanity check myself.
Zero swaps. Zero extra font bytes. Clean, fast, boring. I lasted 24 hours before I put the web font back.
This is where taste and performance fight. On a personal site, I care about typography. On a high-volume product surface, I might make a different decision.
The aha moment: a mismatched budget
The turning point was not technical.
It was a call with a friend who runs a SaaS product. We were screen sharing. He liked the redesign. I told him I was still tuning the font loading. Mentioned the 90ms swap.
He blinked. Then asked a very normal question.
"Out of curiosity, how many people are bouncing because of that?"
I did not have an answer. Obviously.
Then he followed up.
"What is the slowest thing a real user actually complains about right now?"
That one I could answer: a not-so-great filter interaction on a content listing that could take ~1.5 seconds on certain queries.
So I was polishing a 90-200ms visual artifact, while a 1500ms interaction delay was annoying paying users.
Attention completely out of alignment with impact.
What a performance budget actually is
People talk about performance budgets like they are about kilobytes or milliseconds.
They are not.
A performance budget is a prioritisation story. It says which slowness you are allowed to care about first.
My real budget on this project should have looked something like this:
- Budget 1: Interactions over 1s that block someone from doing the thing they came here to do. Unacceptable. Fix quickly.
- Budget 2: Major layout shifts or jank that break trust. Only allowed if there is a very good reason.
- Budget 3: Subtle visual quirks under 200ms that only designers and frontend devs notice. Nice to fix. Never the top of the list.
My actual behaviour completely ignored that structure. I treated a small, personal annoyance as a top-line performance issue, just because it was visible to me on every reload.
That is how you blow a performance budget. Not just by shipping bloat. Also by burning attention on the wrong problems.
How I re-framed the font swap
Once I admitted I was optimising the wrong hill, I changed how I thought about the font swap entirely.
1. I put a hard cap on how much time I would spend
I gave myself one more afternoon. That was it. If I could not get a clearly better result in a few focused hours, I would ship the least annoying version and move on.
This sounds trivial, but setting a clock is a real performance tool. It keeps polish from turning into procrastination.
2. I defined "good enough" in concrete numbers
Instead of chasing "perfect", I wrote down targets:
- On good connections: font swap under 120ms for first paint.
- On poor connections: allow up to 300ms, but no layout shift large enough to move a line of body text vertically.
- Use
font-display: swapso content is always visible quickly, even if the font is slow.
That turned the font from a source of guilt into just another constraint.
3. I aligned it with actual user flows
On this site, the home page and a few key content pages matter most.
So the rule became simple:
- Critical templates get the full font treatment with careful fallback tuning.
- Less important sections lean more on system fonts or accept a lazier loading strategy.
Performance budget per template. Not per file extension.
The version I shipped
After that last focused afternoon, I ended up with this setup:
- Single variable font file for body and headings, self-hosted, aggressively cached.
<link rel="preload" as="font" type="font/woff2" crossorigin>for the primary text face on critical routes.font-display: swapfor everything.- Fallback stack tuned to roughly match metrics for body text, slightly less tuned for headings.
- No crazy FontFace API gymnastics on first load. Light JS only to avoid FOUT on route transitions in the SPA shell.
The measured swap:
- Fast desktop: ~70-90ms.
- Average laptop: ~120-150ms.
- Mid Android on 4G: ~220-260ms.
The flicker is technically still there. You can spot it if you record a video and scrub frame by frame. On real usage, I do not notice it anymore. Mostly because I stopped hunting for it.
What that 90ms actually taught me
The technical side of this was not new. Fonts are slow. Metrics matter. Fallbacks help. Everyone knows that story.
The useful lesson was about attention.
1. Your nervous system is not a product analytics tool
Developers build an almost pathological sensitivity to micro-jank on their own work. We stare at the same transitions on 144 Hz monitors all day.
That sensitivity is useful. It catches issues early. It also lies to you about priority.
A 90ms hitch that you see 50 times a day will feel more important than a 1.5 second delay that you hit once a week in a deep settings panel. Your body is wrong. The metrics are right.
2. Performance budgets should be written down, not felt
If you do not write the budget down, your priorities drift toward whatever annoys you this week.
For my projects now, I keep a simple document:
- Target FCP / LCP brackets with acceptable ranges.
- Max acceptable delay for core interactions.
- Rules for web fonts, images, and third-party scripts.
- A short list of "things we do not care about yet".
That last category is key. It gives you explicit permission to ignore your own pet peeves until the important stuff is solid.
3. Avoid unitless "fast" and "slow"
"Feels slow" is how you start a conversation. It is not where you should end.
I now try to translate every annoyance into at least one number:
- "The list appears after I count to two" becomes "TTI for this interaction is ~1.9s".
- "The header jumps a bit" becomes "CLS spike of 0.2 caused by the logo".
- "The font twitches" becomes "swap delay of ~150ms on this device".
Once it has a number, it can compete fairly with other work. Before that, it is just noise.
How I handle this now on new projects
I still care about typography. I still see micro-flickers other people miss. That has not changed.
What changed is the order I tackle things.
- Step 1: Get core flows under a clear budget. First meaningful content under a sensible target. Critical interactions under a second whenever possible.
- Step 2: Fix the violent stuff. Layout shifts that move content under the cursor. Jank on scroll or drag.
- Step 3: Only then, spend time trimming the small visual rough edges like subtle font swaps, micro delays on icon loads, and so on.
The old me would happily burn half a day chasing a 50ms improvement on a detail that barely affects anyone. The new me will still do that sometimes, but only after the big rocks are taken care of, and only within a fixed time box.
Small number, big lever
That 90ms font swap did not matter individually. Most users never noticed it. It did not move any metrics.
But catching myself obsessing over it was useful. It was a mirror for how easily my attention drifts toward the fun problems instead of the important ones.
If you care about performance, you probably share that tendency. You notice the tiny things first because you are trained to see them.
The trick is not to stop noticing. The trick is to notice, measure, then decide consciously where that detail sits in your actual performance budget.
Sometimes it will earn a spot near the top. Often it will not.
Either way, make the call with numbers, not just with your nervous system.
That is the real lesson I took from a flicker that lasted less than a blink.
Subscribe to my newsletter to get the latest updates and news
Member discussion