Skip to content

Preload the two first-paint font faces so they are not discovered after style.css parses #5991

Description

@o3LL

Prerequisites

  • I searched open issues and this has not already been proposed.
  • I searched discussions and this is not already being debated there.
  • This is a concrete, actionable proposal, not a vague "it would be nice if..." request.

Area

UI / Frontend

Problem or Motivation

static/index.html has five rel="modulepreload" hints for the critical JS, but no rel="preload" hint for anything else, including the two font faces first paint actually uses.

Those two faces are FiraCode-Regular.woff2 (400) and FiraCode-SemiBold.woff2 (600). They are declared in static/style.css:113-115, which means the browser cannot know it needs them until the stylesheet has been fetched and parsed. Only then does the font request go out, and by that point it is queuing behind the module graph.

Measured on a cold load with an empty cache, three runs, times in ms from navigation start:

run font request start response end
1 203 242 / 248
2 142 174 / 178
3 147 178 / 175

font-display: swap is already set on all eight @font-face rules, so this does not block rendering. What it costs is a longer window of fallback text before the real face swaps in, and that window scales badly: on a loopback connection the receive phase itself is 3 to 12 ms, but the same fetch on a real network shares Chrome's six connections per origin with 160+ other requests on the critical path, and the two woff2 files end up at the back of that queue.

Two lines of markup remove the discovery delay entirely.

Proposed Solution

Preload exactly the two faces first paint uses, in <head>, immediately before the stylesheet link:

<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-Regular.woff2">
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-SemiBold.woff2">

Three things worth being explicit about, because each of them turns this from a small win into a small regression if it is got wrong:

crossorigin is required even though these are same-origin. Fonts are always fetched in CORS mode. A preload whose mode does not match the real request is not reused, so the font is fetched a second time. I checked this rather than assuming it: with the attribute the Resource Timing list has two font entries, both initiatorType: "link". Dropping only the attribute produces four entries, the two preloads at 17 ms and then the same two files again at 149 ms with initiatorType: "css".

Only these two faces, and no more. Preloading a face first paint does not use is strictly worse than doing nothing, because it competes for bandwidth with something that is needed. Inter (declared inline at index.html:214-216), OpenDyslexic and Fira Code 300 all stay unloaded in document.fonts through a full cold boot. Inter is only used by .modal-content on desktop, .notes-pane-title, the version sidebar and .doclib-card-action-btn, all of which are behind hidden or unmounted UI.

The hint is unconditional markup, so it has to hold for every theme. It does. With odysseus-theme.font set to sans, both Fira Code faces are still fetched and loaded, because enough of the UI hardcodes 'Fira Code', monospace rather than var(--font-family). Same two faces at a 390x844 mobile viewport.

Nothing else changes. The fonts are not in the service worker's PRECACHE list, which is JS and CSS only, so there is no precache list to keep in sync and no CACHE_NAME bump.

Alternatives Considered

Move the @font-face rules from style.css into the inline <style> block in index.html. That would let the browser discover the faces without waiting for the stylesheet, and it is arguably the tidier fix. I ruled it out because it splits the font declarations across two files, and the inline block already holds the Inter faces, so it would end up holding a mix of "the fonts we inlined for speed" and "the fonts that happen to live here". A preload hint says what it means.

rel="modulepreload" for more of the critical-path JS. Related, and mentioned in the same breath in most write-ups, but a separate change with a separate risk profile. The real fix for the request fan-out is at the transport layer, not in the markup.

Do nothing. Defensible: font-display: swap means nothing is blocked, and on a fast local network the delay is around 130 ms. It matters more over a remote or tunnelled connection, where the queue is the whole problem.

Prior Art / Related Issues

I found nothing overlapping. No open or closed issue mentions preloading, and no issue covers font loading performance. The font issues on the tracker are all about sizing and script coverage rather than loading: #977 (font size on low-resolution screens), #4245 (RTL and Persian/Arabic font support), #3467 (UI scale), #4207 and #1317 (closed, text size and iOS input zoom). ROADMAP.md has one performance item and it is about email.

Thirteen open PRs touch static/index.html. Only #5819 and #5821 touch the <head> link block, and both only bump the ?v= query string on the existing modulepreload lines. Neither proposes a font preload, but the lines are adjacent, so whichever lands second needs a trivial rebase.

Are you willing to implement this?

Yes, I can open a PR.

Additional Information

I have the change working locally, measured cold on a scratch instance, three runs per arm with a fresh browser profile each time:

request start response end initiatorType font entries
before 142-203 174-248 css 2
after 15-19 46-63 link 2

Total resource count is 226 in both arms, so the hint moves the request rather than adding one. Console is clean: no "preloaded but not used" warning, no unsupported as value, no errors. CLS and long-task counts are identical before and after.

Two things I could not verify and am flagging rather than glossing over:

  • The queue-starvation part of the motivation is an inference. On loopback the receive phase is single-digit ms in both arms, so what I can actually demonstrate is the request starting about 130 ms earlier. That the earlier start also shortens the receive phase on a congested connection follows from getting out of the queue, but I did not measure it on one.
  • The service worker never controlled the page in my test. It registers at /static/sw.js (index.html:2558), which gives it scope /static/, and navigator.serviceWorker.controller stayed null across repeated loads of /. So I could not exercise the preload path with the SW intercepting. That looks like it may be worth its own issue, and it does not affect this proposal either way.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions