DEV Community

M620
M620

Posted on

My Expo web demos showed a blank page on slow networks. Here's what fixed it

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,
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (1)

Collapse
 
davekurian profile image
Dave Kurian •

Measuring first text and fonts-finished separately is the right pair. One more case worth scripting: block only the font URLs outright (CDP Network.setBlockedURLs) and assert the app still renders with the system fallback. Your 'Font loading failed' screen came from a failed request, not a slow one, so a throttle alone will never reproduce it. It also lets you check that the grace timer fires when you intended.