skip to content
Andrei Calazans

Exploring Inlined Requires: When Flipping the Flag Does Nothing

/ 3 min read

Part of Exploring Inlined Requires to Improve Cold Start.

We flipped inlineRequires: true on a large production app. The transform was working — requires were moving to use sites. The startup profile barely moved.

Why: eager aggregators

The transform moves a require() to its first use site. If the first use is at module top-level — the module consumes the imported value while it is being evaluated — there is nowhere lazier to move it.

Navigation stacks are the canonical case:

// AppStack.js
import Home from './HomeScreen';
import Settings from './SettingsScreen';
// ... 200 more screens

export const AppStack = createStack([
  { name: 'Home',     component: Home },      // used at module-init
  { name: 'Settings', component: Settings },
]);

The screen bindings are consumed inside createStack([...]), which runs the moment AppStack is loaded. The compiled output is a wall of eager requires the transform cannot touch:

var Home=i(d[0]),Settings=i(d[1]),/* …200 more… */;
_e.AppStack = createStack([{ name:'Home', component:Home }, ...]);

Requiring AppStack synchronously drags in 200+ screen subtrees. In the bundle we analyzed there were well over a thousand of these — navigation stacks, URL-to-destination routing tables, config objects assembled at load time — collectively pulling tens of thousands of eager dependency edges into startup. inlineRequires can’t touch any of them.

What falls through the transform

CategoryWhy it stays eager
react, react-native, jsx runtimesMetro’s nonInlinedRequires block list — intentional
Side-effect imports (import './polyfill')No binding to move; emitted as a bare top-level require()
Module-init usage — nav stacks, routers, HOCs, store/saga registrationFirst use is module evaluation → nowhere lazier
Default/namespace imports on stock RN_interopRequireDefault wrapper not matched by the plugin

The module-init category is what hurts most in practice.

The fix is structural

Make screen references lazy — a thunk that runs on navigation, not at module load:

export const AppStack = createStack([
  { name: 'Home',     getComponent: () => require('./HomeScreen').default },
  { name: 'Settings', getComponent: () => require('./SettingsScreen').default },
]);

React Navigation’s getComponent only invokes the thunk when the screen is first navigated to. That single change — switching from component: Screen to getComponent: () => require('./Screen').default — moved our startup numbers more than the global flag did.

React.lazy(() => import('./Screen')) works too and gives you a Suspense fallback while the screen initializes.

The flag and the refactor are complementary. inlineRequires handles everything it can automatically. The refactor handles the structural cases it can’t.


Next: Expo Router gives you this pattern by defaultgetComponent thunks baked into the routing layer, so you don’t have to wire them by hand.