How favicon generation breaks and what fixing it taught us #
Think of a favicon as the lobby placard in an office tower. Guests never come for the placard. They come for the meeting inside. Yet if the placard shows the wrong name, the whole building feels unreliable. In AI builders the lobby is rebuilt on every deploy. Prompts create new pages, themes shift, and the static folder gets rewritten by agents that move fast. The placard must stay correct through all of that motion. When it does not, tabs go blank, install prompts fail, and brand trust slips in a way that looks small in a ticket but large in perception.
This post is for teams that run generation pipelines where assets are created by code, not by hand. It maps how favicon generation usually works, why it fails under concurrency and cache, and how BYOB reconciled the flow. The fixes are specific to commits 2b633ef and d7792b4 and the current logic in back/air/services.py, but the patterns apply to any builder that writes to static and app.html on behalf of users.
Why does favicon generation look simple until production load arrives? #
On a personal site you add a favicon once. Export an image, drop it in static, add a link element, ship. The MDN link element reference describes exactly what that link does and which rel values trigger icon loading. SvelteKit makes the placement obvious. Anything in static is served from the site root, as the SvelteKit project structure docs describe, so favicon.svg maps to static/favicon.svg without routing.
AI builders change the shape of the problem. Projects start from a prompt with no designer export. The system must infer a palette from app.css, pick initials from the project name, render an SVG, rasterize PNG and ICO, and write the link into app.html before the preview finishes. Then it must do that again and again as the user iterates, without ever clobbering a custom icon uploaded at step four. The flow is still generate and link, but now it repeats under concurrency, across restarts, and through caches that were never designed for rapid asset churn.
How do AI builders generate favicons today and where does each path fail? #
Teams pick one of four patterns. Each handles the favicon differently, and each carries a failure mode that shows up only after you add iteration and concurrency. Where builder internals are not publicly documented, we say so plainly.
| Approach | How it handles favicon | Failure mode | Fix |
|---|---|---|---|
| Static committed asset | Repo ships a default favicon.svg in static and never changes it |
Projects look identical and brand never lands | Add generation at project creation that writes SVG PNG ICO from palette |
| Dynamic generation on every build | Build step regenerates SVG PNG ICO from current theme on each deploy | Custom upload is overwritten on next deploy | Reconciliation that checks for custom links and files before writing |
| Multi size manifest driven | Generator writes 192 and 512 icons plus 180 apple touch and links via manifest | Missing size or wrong mime breaks install prompt | Validate sizes in MDN manifest icons and web.dev manifest and render variants together |
| Late injected link | Client script injects link element after hydration | Icon flashes or fails when script is blocked | Write canonical link into app.html at build time so server markup is complete |
The manifest side matters because install checks look at sizes. The MDN manifest icons docs and web.dev learn pwa manifest both treat 192 and 512 as the baseline for install icons, while 180 remains the Apple touch convention. BYOB renders PNG at 512 and ICO with 16, 32, and 48, which covers the set. The web.dev add manifest guide ties the manifest to the install path, so missing sizes show as warnings.
Cache is the second breaker. Browsers keep favicons longer than HTML. A new file on disk may not appear until the cached entry expires. A query such as ?v=20260718 forces a fresh fetch. If you bust the file but leave a stale link, the bust never reaches the client.
Concurrency is the third breaker. Two jobs for the same project can interleave reads and writes, and hundreds of projects at once can saturate rendering. None of this shows in single project testing.
Why does cache and link drift cause the most support tickets? #
Support tickets rarely say cache. They say my icon is stuck or my manifest install fails or my tab shows the old logo after rebrand. All three often trace to link drift.
Link drift happens when stale link elements accumulate in app.html. A template may ship with a link that points at %sveltekit.assets%/favicon.svg. A later pass appends a canonical link without removing the old one. A third pass adds shortcut icon with a different href. Browsers pick one, but which one varies. Users see a random outcome and file a ticket that says the platform is nondeterministic.
BYOB learned this before commit 2b633ef. The original normalizer always wrote /favicon.svg with a cache buster, which assumed every project wanted SVG. That fails when a user uploads a PNG or ICO. The fix in 2b633ef replaced the fixed path with a dynamic canonical href chosen from the preserved asset. If custom SVG exists, the href stays on SVG. If custom ICO is present without SVG, the href moves to ICO. Raster JPG, JPEG, and WEBP are also detected and mapped to mime type via image/svg+xml, image/png, image/x-icon, image/jpeg, and image/webp. The normalizer also parses shortcut icon case insensitive and leaves apple touch icon alone. That distinction is visible in back/air/test_favicon_generation.py.
The speed story in d7792b4 completes the picture. Before that commit the pipeline read files one by one and rendered even when not needed. After d7792b4 the reads for svg, png, ico, jpg, jpeg, webp, app.css, +layout.svelte, and app.html run together with asyncio.gather, rendering moves off the main thread with asyncio.to_thread, and writes are gathered. The global semaphore default of 4 and per project locks serialize work. A redeploy with a custom icon now does two small reads, finds the marker, and returns without rendering.
For builders outside BYOB, the pattern to copy is authority. Put the link in one place and make the reconciliation pass the only writer.
How does a safe reconciliation pass work step by step? #
The pass that survives production has a clear order.
First, read in parallel. Collect app.html, +layout.svelte, app.css, and any existing favicon files in one batch. In BYOB this is the gather over svg, png, ico, jpg, jpeg, webp, css, layout, and app.html seen in generate_project_favicon_assets in back/air/services.py. Parallel reads cut tail latency when storage is chatty.
Second, detect custom intent. The helper _has_custom_favicon_link scans link elements for icon rel and ignores only those hrefs that point at the default set such as %sveltekit.assets%/favicon or /favicon.svg. Any other icon href counts as custom. If either app.html or +layout.svelte contains that marker, the routine returns skipped with user_custom_link_in_html and protected_custom_favicon true. This is the respect rule. Tests in back/air/test_favicon_generation.py assert preserved custom for a project with a custom ico without svg.
Third, choose canonical href. When no custom link is found, pick the href from the file that will be served. Custom SVG outranks custom ICO, which outranks custom PNG, which outranks raster sources. That cascade sets canonical_favicon_path in back/air/services.py. The cache buster is the UTC date as ?v=YYYYMMDD.
Fourth, normalize markup. The normalizer removes existing icon links and appends a single canonical link with correct mime type into head. It returns early when the canonical href is already the only entry.
Fifth, decide what to write. Existing SVG that matches the generated structure is left alone. Existing SVG that matches the default BYOB asset is treated as placeholder and replaced. PNG and ICO are compared the same way. Only missing or placeholder files get fresh content.
Sixth, render and write together. Rendering SVG to PNG uses CairoSVG at 512, and ICO uses Pillow to bundle 16, 32, and 48. Those run on a thread so the event loop stays free. Writes are gathered so svg, png, ico, and app.html commit together.
What sizes and checks should you ship so the platform and the browser agree? #
Ship SVG at source, PNG at 512 for manifest, ICO with 16, 32, and 48 for tab and shortcut, plus optional 180 apple touch. The MDN manifest icons page and Google web app manifest docs list 192 and 512 for install, and the web.dev add manifest guide ties the manifest to the install criteria.
BYOB generates SVG, PNG at 512, and ICO with the three sizes, and preserves any raster JPG, JPEG, or WEBP, converting via Pillow to PNG when needed.
Add a cache plan from day one. Use a date query on the href and update it when file bytes change. Keep mime type correct per href. Validate with a hard reload and a fresh profile.
You can also try the generator we keep public at favicon generator which we verified returns 200 and which follows the same palette to initials flow described here. We checked that link with curl -I -L before inclusion, as we did for every spec source in this post.
What are the trade-offs? #
Automatic favicons remove a chore builders always forget. The pipeline earns its complexity only above a certain scale.
| Where generation wins | Where it costs |
|---|---|
| Every project starts with a valid icon set, sized per MDN (https://developer.mozilla.org/en-US/docs/Web/Manifest/icons) and manifest friendly (https://web.dev/learn/pwa/web-app-manifest), with link handling per the link spec (https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/link) | Reconciliation, locks, semaphores, and custom preservation are real concurrent code guarding a tiny asset |
| Background generation with deferred rendering keeps icons off the critical path | Cache and link drift cause support tickets that look trivial and investigate slowly |
| Custom uploads survive later iterations instead of silent overwrites | A second writer to app.html anywhere in the codebase breaks the invariant quietly |
Pick the alternative, one hand placed SVG link in app.html, for a single site or a handful of pages. Build the pipeline when projects number in the hundreds and parallel writes race.
Who this is for (and who should skip it) #
This guide is for builders and platform teams that generate assets on behalf of users and redeploy often. If your pipeline writes to static and app.html through automation, the reconciliation rules, locking, and cache busting here will save you tickets and brand risk. It is also for product teams that care about install checks. The size notes map directly to what users see when they try to install or bookmark.
Skip the deep internals if you ship a single site and manage the favicon by hand. Keep one SVG and one ICO in static with a single link in app.html and you will never hit these races. The preservation logic is still worth reading as a pattern for any file you might later automate, but you do not need a semaphore for one origin.
- Best for developers automating favicons across frequent deploys.
- Best for startups cutting brand risk from cache and link drift.
- Best for small teams shipping install ready icons without manual checks.
What we learned building this #
We learned that preservation is a product decision before it is a code decision. Before 2b633ef the pipeline assumed SVG. After, it asks what the user kept. The code lives in back/air/services.py in _build_canonical_favicon_link, _favicon_mime_type, _has_custom_favicon_link, and _normalize_app_html_favicon_link, plus the cascade that picks canonical_favicon_path. The test file back/air/test_favicon_generation.py makes the promise concrete. It asserts that a template with several icon links is collapsed to one entry, that an already canonical document stays stable, that apple touch icon survives, and that a custom ico keeps app.html on ico with protected_custom_favicon true.
We also learned that speed came from doing less. Commit d7792b4 is titled Speed up favicon reconciliation and it reads like a performance patch. In practice it is a do not render until needed patch. Reads run in parallel, rendering is deferred behind _get_generated_binary_b64 and only runs when needed, and writes are gathered. The semaphore default of 4 in _get_favicon_global_semaphore caps parallel work while per project locks in _get_favicon_project_lock serialize work per origin. The front end in src/lib/components/chat/StepActions.svelte maps generate_favicon to the image status lane.
One more lesson worth stating plainly. The hardest bug was not a crash. It was a quiet overwrite. A user uploaded a hand crafted icon, the next iteration replaced it, and no log marked it as an error. We now treat any write that ignores a custom marker as a defect.
How do you avoid favicon breakage in your own builder? #
Run this list after you wire generation and before you claim it works.
- Verify
app.htmlis the single writer. Search the codebase for any other file that injects a favicon link and remove duplicates. Confirm+layout.sveltedoes not carry its own icon link. - Confirm canonical href selection. With a custom SVG present, the link should stay on SVG. With custom ICO alone, it should point at ICO. With raster JPG or WEBP present, it should point at that file with the correct mime type.
- Check link normalization. Feed the normalizer an html sample with duplicate icon entries plus apple touch icon. Assert only icon links are collapsed, apple touch remains, and a document that already contains the canonical href is unchanged.
- Validate sizes. Fetch
favicon.pngand assert 512. Openfavicon.icoand confirm 16, 32, and 48 are present. Confirm manifest includes 192 and 512 where install is expected, per MDN manifest icons and web.dev manifest. - Check mime types. Request each href and assert content type matches the extension. SVG should be
image/svg+xml, PNGimage/png, ICOimage/x-icon. Mismatch is a silent failure on some browsers. - Bust the cache. After writing new bytes, confirm the href query such as
?v=YYYYMMDDchanged. Load the page in a fresh profile and in a hard reload of the old profile. - Guard concurrency. Run two generation calls for the same project at the same time in test and assert only one write wins and the link stays canonical.
- Preserve custom. Upload a known good custom SVG, PNG, and ICO in separate runs and assert the pipeline marks
preserved_customand leaves the bytes untouched. Try an invalid SVG and confirm the status reportsinvalid_or_unrenderablewithout crashing. - Keep it async off the critical path. Trigger generation in the background and confirm page load and deploy complete before the icon lands. The UI should show a transient generating assets state, as wired in
StepActions.svelte, without blocking navigation.
Run the list on every change to theme, static handling, or markup injection. Favicons are not where you want to discover that a new feature writes to app.html with a second link.
Minor releases in this space rarely get headlines, yet this one changes how every new BYOB project first looks and how every later deploy preserves a human choice. That is the bargain an AI builder has to keep. Generate fast, respect what the human already did, and stay out of the way afterward.