Self-hosting variable fonts in an Expo web build
On a codebase shared between iOS, Android and web, fonts are the one asset where the platforms genuinely disagree. Native wants a font file bundled and registered. The web wants the smallest possible file, delivered from your own origin, with the browser told about it as early as possible. Expo papers over that difference by handling fonts for you — and on web, what it hands you is not what you would choose.
What Expo does on web
expo-font registers fonts at runtime. On web that means it builds a
@font-face rule in JavaScript and injects it into the document once the app
is running. The font it points at is whatever you passed to it, which for a
cross-platform project is almost always a TTF, because that is what the native
side needs.
Two consequences follow, and both cost you real milliseconds:
TTF on the web is a waste. The same face as WOFF2 is typically half the size or better, because WOFF2 is Brotli-compressed by design. You are shipping a format that exists for a different runtime.
A runtime-injected @font-face cannot be discovered early. The browser's
preload scanner reads the HTML before scripts execute. A font declared in the
initial CSS can start downloading immediately; one declared by JavaScript after
hydration cannot. On a first visit over a slow connection, that is the
difference between text swapping in quickly and a visible stretch of fallback.
What to do instead
Declare the face yourself, in CSS that ships with the document:
@font-face {
font-family: "Inter";
src: url("/fonts/inter-variable.woff2") format("woff2-variations");
font-weight: 100 900;
font-display: swap;
}
A variable font matters more here than it looks. A cross-platform UI usually wants three or four weights; as static files that is three or four requests and three or four caches. As one variable WOFF2 it is a single file that covers the whole range, and the file is often smaller than two static weights put together.
Then tell the browser about it in the document head, so the download starts with the HTML rather than after it:
<link
rel="preload"
as="font"
type="font/woff2"
href="/fonts/inter-variable.woff2"
crossorigin
/>
The crossorigin attribute is not optional even when the file is same-origin:
fonts are fetched in CORS mode, and without it the preload fetch and the real
fetch are treated as different requests. You get the file twice and help
nobody.
The part that actually takes the time
Having done all that, you now have two declarations for the same family: yours in CSS, and the one Expo injects at runtime. The browser will happily load both. The work is making the native side keep its font registration while the web side skips it — a platform split at the point where fonts are loaded, rather than one shared call that pretends the platforms are the same.
That is the whole trick, and it is a small one. It is worth doing because it sits on the first-paint path for every visitor, and because a shared codebase makes it easy to forget that "one call that works everywhere" sometimes means "one call that is wrong on one platform, quietly".
Was it worth it
For a subscriber portal, yes — the people opening it are often on a phone, on mobile data, checking a balance. That is the worst case for a font download that starts late and blocks nothing but reads as slow.
For an internal tool nobody visits cold, probably not. The technique is cheap but not free, and the payoff is entirely in the first visit.
- Expo
- React Native Web
- Performance
- Fonts