the favicon that would not die

September 7, 2026 meta designdevlog

Small thing. Took way too long. The favicon, the little icon in the browser tab, is the tiniest asset on this whole site and it fought me harder than the reverse proxy did.

what it's supposed to be

The tab icon here is a tiny BMK monogram with a mountain glyph tucked into it, matching the lockup in the header. I didn't hand-draw it as a raster image, I built it as an HTML tile (static/_favicon-tile.html) using the real vendored Literata font and the real site colors, then rendered it to PNG with Playwright so the glyph actually uses the same typeface as the rest of the site instead of some fallback system font that an SVG <text> element would have silently substituted in. Two sizes get generated, 32px for the tab and 512px for everything that wants a bigger icon (home screen shortcuts, that kind of thing), plus an apple-touch-icon at 180px.

<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png?v=2" />
<link rel="icon" type="image/png" sizes="512x512" href="/favicon-512.png?v=2" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png?v=2" />

Notice the ?v=2 on the end of each one. That wasn't there originally. That's the scar tissue from this whole story.

round one: it just didn't update

I redrew the favicon partway through the redesign, regenerated the PNGs, deployed, refreshed the page. Old icon. Still the old mountain shape in the tab. I assumed I'd messed up the build, checked the file on disk, and the new bytes were right there, correct pixels, correct file, correct path. Browser just wasn't asking for it again.

Turns out favicons are the one asset type where browsers cache with something close to religious devotion, and mostly ignore the normal cache-control headers you'd use to bust a cache on anything else. Chrome in particular has historically cached a site's favicon for a long time independent of what the server says, sometimes needing the history and cache cleared entirely, sometimes needing an actual URL change, not just a fresh byte at the same URL. A plain reload wasn't going to touch it. Neither was reloading with cache disabled in devtools, which is the thing I tried second and which normally fixes exactly this class of problem.

round two: blaming the wrong layer

Before I figured out it was the browser, I spent a while suspecting the container instead, because this stack runs behind Docker and I've been burned by stale build layers before (an actual different bug earlier in this project involved node_modules getting clobbered in a Docker build because I hadn't written a .dockerignore yet). So I went down that path first: rebuilt the frontend image with --no-cache, confirmed the new PNG bytes were sitting in frontend/build/client/favicon-32.png inside the running container, curled the URL directly against the container's own port to sidestep Caddy entirely. Fresh bytes, every time, straight from the source. The server was doing its job correctly the whole time. It was never the container. It was purely sitting in my own browser's favicon cache, several layers away from anything a deploy could touch.

That's the part that actually cost the time: ruling out my own infrastructure before accepting that the bug lived somewhere I had zero control over.

the fix

You can't tell a browser's favicon cache to invalidate. What you can do is stop asking for the same URL. Everything I tried before figuring that out kept the same URL:

<!-- before: same URL every time, browser never re-asks for it -->
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />

A query string on the end of the href makes it a different resource as far as caching is concerned, even though it resolves to the exact same file on disk:

<!-- after -->
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png?v=2" />

?v=2 is the whole fix. Next time I change the favicon art for real, that becomes ?v=3, and so on. It's the same trick as cache-busting a CSS or JS bundle with a content hash, just done by hand here since a favicon is such a rare edit that a whole build-tool pipeline for it felt like overkill. One number, bumped manually, every time the pixels actually change.

what I'd tell past me

Check the easy, dumb, embarrassing explanation before the interesting one. "The browser is just being stubborn about a tiny PNG" is a boring answer and I wanted the bug to be something more technically satisfying, some Docker layer-caching subtlety I could write a smarter fix for. It wasn't. It was a five-character query string I could have added in the first ten minutes if I'd believed the boring explanation instead of chasing the container around for half an hour first.

the thing that made it worse before it got better

Part of why I doubted myself on the browser-cache theory for as long as I did is that I first tested it in an incognito window, thinking that would rule the browser cache out entirely, fresh profile, no history, surely no favicon cache either. Wrong new icon showed up there too the first time, which felt like solid evidence it wasn't a caching problem at all. What I didn't realize until later is that Chrome's favicon cache isn't strictly tied to the browsing profile's regular cache or history the same way page assets are, it's closer to a separate, longer-lived store keyed more on the URL itself, and a private window doesn't reliably start that one empty either. So my control test wasn't actually controlling for the thing I thought it was, and it sent me further down the container-blaming path than the evidence should have.

There's also a smaller, boring reason the build side genuinely deserved a first look: the same favicon PNGs exist copied into a few different build output locations at once, frontend/build/client/, the SvelteKit dev output under .svelte-kit/output/client/, and the source static/ directory they all originate from. Three copies of the same two files across a normal build is exactly the kind of thing that goes stale in one spot and not another if a build step gets skipped, so ruling out "wrong copy got served" wasn't paranoia, it just wasn't where the actual bug lived this time.

I thought about just renaming the files outright too, favicon-32-v2.png instead of bumping a query string, which guarantees a cache miss with zero ambiguity. Decided against it, since that means updating every reference to the old filename across app.html and anywhere else it's linked, versus touching one number in one place. The query string does the same job for a fraction of the edit.

1 comment

Log in to comment.

kent September 8, 2026

How do I create favicon, and make it reflect the theme of the page?

Log in

No account?