I have six app UI clones (Airbnb, Threads, Luma, Suno, Tiimo, Structured) built with React Native + Expo and published as static web demos. On a good connection they open instantly. On a throttled 40 KB/s connection, some of them showed nothing for 40+ seconds, and one showed a "Font loading failed" error instead of the app.
Three changes fixed it. None of them is specific to my apps, so they should work for any Expo web export.
Demos: https://migrant620.github.io/ · Source (MIT): https://github.com/migrant620
How I measured
Chrome DevTools can throttle the network, and so can a headless browser through the DevTools protocol:
await client.send('Network.emulateNetworkConditions', {
offline: false,
latency: 150, // ms
downloadThroughput: 40 * 1024, // bytes/s
uploadThroughput: 40 * 1024,
});
I recorded two numbers per app: when the first text appears, and when all web fonts have finished loading. Cache disabled, two runs each.
1. Don't block the first render on fonts
The default Expo pattern is:
const [loaded] = useFonts({ Roboto: require('./Roboto.ttf') });
if (!loaded) return null; // blank page until every font arrives
On a slow connection, "every font" can mean a megabyte or more, so the user stares at a white screen. If a font request fails or times out, some setups show an error instead of the app.
What I do instead is register the fonts with font-display: swap and render immediately:
const css = Object.entries(FACES)
.map(([family, url]) =>
`@font-face{font-family:${family};src:url("${url}") format("truetype");font-display:swap;}`)
.join('\n');
const style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
Every fontFamily in the styles also gets a system fallback, so text is readable right away and swaps to the real font when it lands. A short grace timer (a second or two) still avoids a flash of fallback text on fast connections; after that the app renders no matter what.
I also load the fonts the first screen needs before the rest:
const load = (families: string[]) =>
Promise.all(families.map((f) => document.fonts.load(`16px ${f}`)));
load(FIRST_SCREEN_FACES).then(() => load(OTHER_FACES));
2. Ship only the glyphs you use
Icon fonts were the worst offenders. A full MaterialCommunityIcons TTF is over 1 MB, and each app used a few dozen icons. Text fonts were also full Unicode builds while the UI is English only.
fontTools cuts a font down to the characters you list:
pyftsubset Roboto-Regular.ttf \
--unicodes="U+0020-007E,U+00A0-00FF,U+2013-2026" \
--layout-features='*' \
--output-file=Roboto-Regular.subset.ttf
For icon fonts, pass the code points of the icons you actually render. Results:
| App | Fonts before | Fonts after |
|---|---|---|
| Tiimo | 1.92 MB | 416 KB |
| Structured | 1.89 MB | 410 KB |
| Airbnb | 655 KB | 233 KB |
Glyph shapes and widths are unchanged, so layout doesn't move.
3. Lazy-load everything that isn't the first screen
Expo's web export supports React.lazy and splits each dynamic import() into its own chunk:
const loadSettings = () => import('./screens/SettingsScreen');
const SettingsScreen = React.lazy(() =>
loadSettings().then((m) => ({ default: m.SettingsScreen })));
// ...
<React.Suspense fallback={null}>
{tab === 'settings' ? <SettingsScreen /> : null}
</React.Suspense>
Two details made this smooth:
-
Prefetch after first paint. Once the first screen and its fonts are in, I call the
load*()functions in the background, so by the time someone taps Settings the chunk is usually there. -
Check the sub-path. My demos live under
/structured/,/suno/and so on. Build with the right base URL and confirm the chunk files return 200 on the deployed site, not just locally.
Results
Same 40 KB/s, 150 ms connection:
| App | First text | All fonts loaded |
|---|---|---|
| Structured | 16.1 s → 13.6 s | 61.2 s → 19.4 s |
| Suno | 39.5 s → 24.4 s | no longer shows an error |
| Tiimo | 16.0 s → 15.0 s | 62.0 s → 25.3 s |
Most of the remaining first-text time is React DOM plus React Native Web itself (roughly 300 KB together). That's the next thing to look at.
If you have an Expo web app, try opening it with DevTools set to "Slow 3G" and cache disabled. If you see a blank page, the font gate is the first place to look.
Previous post in this series: Building Tiimo's focus timer ring with one SVG arc.











