Describe the bug
During development, a font with font-display: optional can render on first paint and then switch to the fallback font when the page hydrates, and stay on the fallback (a layout shift). The same page built for production keeps the font.
What happens:
- SvelteKit inlines the page's CSS into one
<style data-sveltekit> so that development doesn't flash unstyled content.
- When the client starts, Vite's client injects each imported stylesheet again as a
<style data-vite-dev-id>.
initialize then removes style[data-sveltekit] (client.js).
Removing a stylesheet that declares @font-face makes Chromium re-create the page's font faces, and the new faces load their fonts again. Vite serves dev files with Cache-Control: no-cache, so these loads are conditional requests: in the reproduction the font is requested as 200, 304 or 200, 304, 304, where the production build requests it once. If the page renders while such a request is in flight, the browser gives up on an optional font for the rest of the page's life.
I'll open a PR that inlines the @font-face rules in a style of their own that stays in the page, and removes the rest as before.
Reproduction
https://github.com/xyrolle/sveltekit-dev-font-fallback
The app has one @font-face with font-display: optional in src/app.css, imported by the root layout, with the font preloaded. probe.js loads the page with Playwright and prints, for each load, the font the text ended up rendered with (from DevTools), the status of each request for the font, and any layout shifts.
pnpm install
FONT_DELAY_MS=10 pnpm dev --port 5173 # delays the font responses by 10 ms
node probe.js # Playwright's Chromium
CHANNEL=chrome node probe.js # installed Chrome
Loads that ended in the fallback font (16 each):
|
Playwright's Chromium 153 |
Chrome 154 |
vite dev, FONT_DELAY_MS=0 |
0/16 |
0/16 |
vite dev, FONT_DELAY_MS=10 |
9/16 |
8/16 |
vite build + vite preview, FONT_DELAY_MS=10 |
0/16 |
0/16 |
On localhost this small app wins the race. In a larger app it also happened on localhost, intermittently.
Logs
$ CHANNEL=chrome node probe.js http://localhost:4263/ # FONT_DELAY_MS=10 pnpm dev --port 4263
154.0.8037.98 http://localhost:4263/ latency=0ms
load 1: rendered with Geist; font responses 200, 304; layout shifts []
load 2: rendered with Helvetica; font responses 200, 304; layout shifts [0.00025]
load 3: rendered with Helvetica; font responses 200, 304; layout shifts [0.00025]
load 4: rendered with Helvetica; font responses 200, 304; layout shifts [0.00025]
load 5: rendered with Geist; font responses 200, 304; layout shifts []
...
System Info
System:
OS: macOS 27.2
CPU: (18) arm64 Apple M5 Pro
Memory: 1.89 GB / 64.00 GB
Shell: 5.9 - /bin/zsh
Binaries:
Node: 24.16.0 - /Users/xyrolle/.nvm/versions/node/v24.16.0/bin/node
Yarn: 1.22.22 - /Users/xyrolle/.nvm/versions/node/v24.16.0/bin/yarn
npm: 11.13.0 - /Users/xyrolle/.nvm/versions/node/v24.16.0/bin/npm
pnpm: 11.28.3 - /Users/xyrolle/.nvm/versions/node/v24.16.0/bin/pnpm
Browsers:
Chrome: 154.0.8037.98
Safari: 27.2
npmPackages:
@sveltejs/kit: 3.0.1 => 3.0.1
@sveltejs/vite-plugin-svelte: 7.3.1 => 7.3.1
svelte: 5.57.2 => 5.57.2
vite: 8.3.3 => 8.3.3
Severity
annoyance
Additional Information
The same happens on main (9b56543). Serving dev fonts with a long Cache-Control, as unjs/fontaine#665 does for fontless, avoids the fallback, but when the first font load is slow the re-created face then swaps the font in after first paint, which production doesn't do either.
Describe the bug
During development, a font with
font-display: optionalcan render on first paint and then switch to the fallback font when the page hydrates, and stay on the fallback (a layout shift). The same page built for production keeps the font.What happens:
<style data-sveltekit>so that development doesn't flash unstyled content.<style data-vite-dev-id>.initializethen removesstyle[data-sveltekit](client.js).Removing a stylesheet that declares
@font-facemakes Chromium re-create the page's font faces, and the new faces load their fonts again. Vite serves dev files withCache-Control: no-cache, so these loads are conditional requests: in the reproduction the font is requested as200, 304or200, 304, 304, where the production build requests it once. If the page renders while such a request is in flight, the browser gives up on anoptionalfont for the rest of the page's life.I'll open a PR that inlines the
@font-facerules in a style of their own that stays in the page, and removes the rest as before.Reproduction
https://github.com/xyrolle/sveltekit-dev-font-fallback
The app has one
@font-facewithfont-display: optionalinsrc/app.css, imported by the root layout, with the font preloaded.probe.jsloads the page with Playwright and prints, for each load, the font the text ended up rendered with (from DevTools), the status of each request for the font, and any layout shifts.Loads that ended in the fallback font (16 each):
vite dev,FONT_DELAY_MS=0vite dev,FONT_DELAY_MS=10vite build+vite preview,FONT_DELAY_MS=10On localhost this small app wins the race. In a larger app it also happened on localhost, intermittently.
Logs
System Info
System: OS: macOS 27.2 CPU: (18) arm64 Apple M5 Pro Memory: 1.89 GB / 64.00 GB Shell: 5.9 - /bin/zsh Binaries: Node: 24.16.0 - /Users/xyrolle/.nvm/versions/node/v24.16.0/bin/node Yarn: 1.22.22 - /Users/xyrolle/.nvm/versions/node/v24.16.0/bin/yarn npm: 11.13.0 - /Users/xyrolle/.nvm/versions/node/v24.16.0/bin/npm pnpm: 11.28.3 - /Users/xyrolle/.nvm/versions/node/v24.16.0/bin/pnpm Browsers: Chrome: 154.0.8037.98 Safari: 27.2 npmPackages: @sveltejs/kit: 3.0.1 => 3.0.1 @sveltejs/vite-plugin-svelte: 7.3.1 => 7.3.1 svelte: 5.57.2 => 5.57.2 vite: 8.3.3 => 8.3.3Severity
annoyance
Additional Information
The same happens on
main(9b56543). Serving dev fonts with a longCache-Control, as unjs/fontaine#665 does for fontless, avoids the fallback, but when the first font load is slow the re-created face then swaps the font in after first paint, which production doesn't do either.