Skip to content

perf(frontend): preload the two first-paint Fira Code faces - #5992

Merged
RaresKeY merged 1 commit into
odysseus-dev:devfrom
o3LL:perf/preload-first-paint-fonts
Aug 11, 2026
Merged

perf(frontend): preload the two first-paint Fira Code faces#5992
RaresKeY merged 1 commit into
odysseus-dev:devfrom
o3LL:perf/preload-first-paint-fonts

Conversation

@o3LL

@o3LL o3LL commented Aug 11, 2026

Copy link
Copy Markdown
Member

Summary

The two font faces first paint uses, FiraCode-Regular.woff2 and FiraCode-SemiBold.woff2, are declared in static/style.css, so the browser cannot discover them until the stylesheet has been fetched and parsed. On a cold load the font request goes out around 145 ms in, behind the module graph. font-display: swap keeps that from blocking render, so the cost is a longer window of fallback text rather than a stall, but there is no reason the fetch cannot start immediately. This adds two rel="preload" hints in <head> for exactly those two faces. Measured cold with an empty cache, three runs per arm: request start goes from 142-203 ms to 15-19 ms and response end from 174-248 ms to 46-63 ms, with the total request count unchanged and each face still fetched exactly once.

Target branch

  • This PR targets dev, not main.

Linked Issue

Closes #5991

Type of Change

  • Bug fix (non-breaking, fixes a confirmed issue)
  • New feature (non-breaking, adds new behaviour)
  • Breaking change (changes or removes existing behaviour)
  • Refactor / cleanup (behaviour unchanged)
  • Documentation only
  • CI / tooling / configuration

There is no performance box. Refactor is the closest fit: nothing user-visible changes, only when the font request is issued.

Checklist

How to Test

The measurement needs a genuinely cold cache, so use a fresh browser profile for each run rather than a reload. A service-worker unregister plus cache clear is not enough on its own; the HTTP cache will still serve the fonts as 304s and the numbers collapse to noise.

  1. Run the app: uvicorn app:app --host 127.0.0.1 --port 7099.
  2. Open it in a fresh Chrome profile and let it settle, then read the font entries from the Resource Timing API in the console:
    performance.getEntriesByType('resource')
      .filter(e => e.name.includes('/fonts/'))
      .map(e => `${e.name.split('/').pop()} start=${e.startTime.toFixed(0)} end=${e.responseEnd.toFixed(0)} init=${e.initiatorType}`);
  3. On dev you get two entries with init=css starting around 142-203 ms. On this branch you get two entries with init=link starting around 15-19 ms. Two entries either way, and performance.getEntriesByType('resource').length is 226 in both cases.

Confirm crossorigin is doing its job, because without it this change is worse than no change at all:

[...document.fonts].filter(f => f.status === 'loaded').map(f => `${f.family}:${f.weight}`);
// ["Fira Code:400", "Fira Code:600"]

Two font entries in the Resource Timing list means the preload was consumed. If you strip the crossorigin attribute and reload cold you get four instead: the two preloads at about 17 ms, then the same two files again at about 149 ms with init=css. I checked that rather than trusting the folklore.

Confirm nothing else got dragged in. Everything except Fira Code 400 and 600 should still be unloaded:

[...document.fonts].map(f => `${f.family}:${f.weight}:${f.status}`);

Inter, OpenDyslexic and Fira Code 300 stay unloaded through a full cold boot, on a 1280x800 desktop viewport and a 390x844 mobile one, and with localStorage['odysseus-theme'] set to {"font":"sans"} as well as unset. That last one matters: enough of the UI hardcodes 'Fira Code', monospace rather than var(--font-family) that both faces load regardless of the theme's font setting, so the hint is never wasted.

Console should be clean. No "preloaded but not used" warning, no unsupported as value warning. The only messages I get on a cold load are TTS: not available and Highlighting all code blocks on page load, both of which are there on unmodified dev too.

Full suite on this branch: 4902 passed, 2 failed, 4 skipped in 140.30 s. The two failures are tests/test_workspace_confine.py::test_glob_confined_e2e (/tmp resolving to /private/tmp on macOS) and tests/test_integration_api_call_ssrf.py::test_real_socket_falls_back_from_dead_first_to_live_second (real sockets, connect-refused timing). Both are the known environmental pair and reproduce identically on an unmodified checkout. python -m compileall on app.py core routes src services scripts tests exits 0. No JS or Python file is touched by this diff, so there is nothing for node --check to run against.

No test is added. A preload hint has no runtime behaviour to assert, and there is no HTML-linting harness in the repo, so the pytest-shells-out-to-Node idiom the JS tests use does not apply here. Happy to add a smoke test that greps index.html for the two hints if you want one, but it would only restate the diff.

Visual / UI changes

  • Screenshot or short clip of the change in the running app, attached below.
  • Style match: no styling was touched. This adds no CSS, no colour, no font size, no spacing unit and no class. The @font-face rules, --font-family and every existing <link> are unchanged. The two added lines are resource hints, not markup that renders.
  • No new component patterns. No component at all.
  • I am not an LLM agent submitting a bulk PR.

Screenshots / clips

Flagging this honestly rather than ticking the box. Steady-state rendering is identical: a before and after screenshot of the loaded app are the same picture, because the same two faces are used either way and only the moment they arrive changes. The one real visual delta is that the swap from fallback to Fira Code happens about 130 ms earlier on a cold load, and a still image cannot show that.

What I did verify visually, on this branch in the running app, at 1280x800 and at 390x844: the shell renders in Fira Code exactly as before, the sidebar, header, empty-chat state and composer are unchanged, and agent-browser errors was empty throughout. I have both screenshots and will drop them into this thread if you would rather have the picture on the record.

Not verified

  • The queue-starvation half of the motivation is an inference, not a measurement. On loopback the receive phase is 3 to 12 ms in both arms, so what I can demonstrate is the request starting about 130 ms earlier. That an earlier start also shortens the receive phase on a connection where 160+ requests are sharing six connections follows from leaving the queue, but I did not measure it on such a connection.
  • The service worker never controlled the page during my testing. It registers at /static/sw.js (index.html:2558), which scopes it to /static/, and navigator.serviceWorker.controller stayed null across repeated loads of /. So the preload path with the SW intercepting is untested. Unrelated to this change, but it may be worth its own issue.
  • Chrome only, on macOS. I did not exercise Firefox or Safari, though rel="preload" with as="font" is unremarkable in both.
  • No Docker run and no Windows. Python 3.11.15 locally, not the 3.14 in the image, though nothing here is Python.

The app font faces are declared in static/style.css, so the browser only
discovers FiraCode-Regular.woff2 and FiraCode-SemiBold.woff2 once the
stylesheet has parsed. On a cold load they start about 145 ms in, behind
the module graph. font-display: swap keeps that from blocking render, so
the cost is a visible swap rather than a stall, but the fetch can start
immediately instead.

Two preload hints move the request into the head. Measured cold on a
scratch instance with an empty cache, three runs per arm: request start
142-203 ms becomes 15-19 ms, response end 174-248 ms becomes 46-63 ms.
The total request count is unchanged and each face is still fetched
exactly once.

crossorigin is required even though these are same-origin: fonts are
always fetched in CORS mode, and without it the preload is discarded and
the font fetched again. Dropping the attribute produces four font entries
in the Resource Timing list instead of two.

Only Fira Code 400 and 600 are preloaded. They are the only faces first
paint uses. Inter, OpenDyslexic and Fira Code 300 stay unloaded on both
desktop and mobile, with or without a saved font preference.
@github-actions github-actions Bot added the ready for review Description complete — ready for maintainer review label Aug 11, 2026

@RaresKeY RaresKeY left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed the first-paint font preloads on the current head and did not find an author-actionable issue.

Validation

  • The hints target the exact Fira Code 400 and 600 WOFF2 files used by the stylesheet, precede stylesheet discovery, and use the destination, MIME type, URL, and anonymous CORS mode needed for request reuse.
  • The existing CSP, static-file, cache, and service-worker boundaries remain unchanged.
  • The current substantive GitHub checks pass. I did not independently reproduce the browser waterfall, so cross-browser, theme-specific, and constrained-network timing remains the residual validation gap.

@RaresKeY
RaresKeY merged commit c2b9666 into odysseus-dev:dev Aug 11, 2026
19 checks passed
cybervand pushed a commit to cybervand/odysseus that referenced this pull request Aug 12, 2026
…-dev#5992)

The app font faces are declared in static/style.css, so the browser only
discovers FiraCode-Regular.woff2 and FiraCode-SemiBold.woff2 once the
stylesheet has parsed. On a cold load they start about 145 ms in, behind
the module graph. font-display: swap keeps that from blocking render, so
the cost is a visible swap rather than a stall, but the fetch can start
immediately instead.

Two preload hints move the request into the head. Measured cold on a
scratch instance with an empty cache, three runs per arm: request start
142-203 ms becomes 15-19 ms, response end 174-248 ms becomes 46-63 ms.
The total request count is unchanged and each face is still fetched
exactly once.

crossorigin is required even though these are same-origin: fonts are
always fetched in CORS mode, and without it the preload is discarded and
the font fetched again. Dropping the attribute produces four font entries
in the Resource Timing list instead of two.

Only Fira Code 400 and 600 are preloaded. They are the only faces first
paint uses. Inter, OpenDyslexic and Fira Code 300 stay unloaded on both
desktop and mobile, with or without a saved font preference.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready for review Description complete — ready for maintainer review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants