skip to content
Andrei Calazans

Exploring Inlined Requires: Does Expo Router Give You Screen-Level Lazy Loading?

/ 3 min read

Part of Exploring Inlined Requires to Improve Cold Start. The previous post showed that the real startup killer is eager aggregators — navigation stacks that import every screen and reference them at module-init, so inlineRequires can’t defer them. The manual fix is wrapping screens in getComponent thunks. Here I verify that Expo Router does this for you.

I built a small Expo Router app with an _layout, an index, and four leaf screens — each importing a uniquely-tagged heavy module — and pulled apart the export.

The verdict: yes, lazy by default — even in the default sync import mode, independent of inlineRequires.

Routes live behind getters, not eager requires

Expo Router discovers routes with require.context('./app'), which Metro compiles into a context module whose entries are enumerable getters. Reading the key list doesn’t load anything:

var map = Object.defineProperties({}, {
  "./index.js": { enumerable: true, get() { return require(_dependencyMap[2]); } },
  "./one.js":   { enumerable: true, get() { return require(_dependencyMap[3]); } },
  "./two.js":   { enumerable: true, get() { return require(_dependencyMap[4]); } },
});
metroContext.keys = () => Object.keys(map); // lists routes without loading them

Each screen is registered as a getComponent thunk

When Expo Router builds the React Navigation navigator, each route gets a thunk — not a resolved component:

function routeToScreen(route) {
  return createElement(Screen, {
    name: route.route,
    getComponent: () => getQualifiedRouteComponent(route), // called on first navigation
  });
}

In sync mode (the native default), getQualifiedRouteComponent calls a synchronous require of that screen off the startup path. In lazy mode it returns React.lazy(() => loadRoute()) behind Suspense.

Proof: route discovery loads zero screens

I ran Expo Router’s getRoutes() against an instrumented require.context that recorded every module load:

$ node router_probe.js
Modules loaded during route discovery: [ './_layout.js' ]
Leaf routes behind lazy thunks:        [ 'index', 'one', 'two', 'heavy', '_sitemap', '+not-found' ]
Leaf modules loaded at discovery time: 0 / 6

Only _layout loads at discovery (to read unstable_settings). Every leaf screen — and transitively its heavy dependencies — stays unloaded until you navigate to it.

Caveats

  • Layouts on the active path are eager. They must render. Keep root _layout files light.
  • The initial route loads at startup — it renders immediately; only the other routes are deferred.
  • Still one bundle on native. Sync mode means a synchronous require on navigation, not a downloaded chunk. The deferral comes from getComponent, not bundle splitting.
  • Independent of inlineRequires. This is an architecture win from the routing layer, not the transform.

If you’re wiring getComponent thunks by hand across a large navigation stack, Expo Router’s file-based routes are lazy by construction — and that’s a strong argument for letting the routing layer own this instead of every team re-deriving the pattern.