skip to content
Andrei Calazans

Exploring Inlined Requires: How They Really Work

/ 4 min read

Part of Exploring Inlined Requires to Improve Cold Start. I wired one shared app into three bundling setups — vanilla RN, Expo SDK 56, rnx-kit — and diffed the output.

Setup: React Native 0.85.3 · Metro 0.84.4 · Expo SDK 56 · @rnx-kit/cli 2.0.1

What the transform does

inlineRequires is a Babel transform inside metro-transform-plugins. It moves each require() from the top of the module factory to the first place the binding is used.

Before — eager:

var _heavy = require(_dependencyMap[2]); // runs when this module loads
function onPress() { _heavy.heavyCompute(1000); }

After — inlined:

function onPress() { require(_dependencyMap[2]).heavyCompute(1000); } // runs on first call

The module’s top-level code now runs lazily — only when the first code path that needs it executes. That’s the startup win. The module is still in the bundle; nothing is removed.

Why react-native stays hoisted but your heavy module doesn’t

Metro ships a default block list that is never inlined:

// metro/src/lib/transformHelpers.js
const baseIgnoredInlineRequires = [
  "React", "react", "react/jsx-dev-runtime", "react/jsx-runtime",
  "react-compiler-runtime", "react-native",
];

These are used on basically every render. Inlining them would add a require() lookup on hot paths for zero startup benefit. Everything outside this list is fair game.

Expo keeps inlineRequires off by default

This contradicts what most people believe. From @expo/metro-config:

getTransformOptions: async () => ({
  transform: {
    experimentalImportSupport: true,
    inlineRequires: false,   // ← off
  },
}),

The emitted Expo bundle confirms it — _heavy is eagerly required at module load. To get deferred evaluation on Expo, set inlineRequires: true in metro.config.js yourself.

Default imports stay eager on stock RN

Even with inlineRequires: true, default and namespace imports slip through the transform. The plugin only matches bare require() calls. Babel’s interop wrappers have different names, so the plugin ignores them:

// Named import → bare require → inlined ✅
import { x } from 'm';
// compiled: var _m = require('m');  → moves to use site

// Default import → interop wrapper → stays hoisted ❌
import def from 'm';
// compiled: var _m = _interopRequireDefault(require('m'));  → stays eager

Most third-party packages are default imports (import React, import moment, import axios). On stock RN they all stay eager even with the flag on.

The fix: let Metro lower imports instead of Babel. Set disableImportExportTransform: true on @react-native/babel-preset and keep experimentalImportSupport: true. Expo already does this — its bundle factories use _$$_IMPORT_DEFAULT/_$$_IMPORT_ALL, which the plugin does match.

Import shapeStock RN (Babel lowers)Expo / Metro lowers
import { x } from 'm'inlinedinlined
import x from 'm'eagerinlined
import * as x from 'm'eagerinlined

import() in RN doesn’t split the bundle

Adding await import('./lazy') compiles to an async require of an already-bundled module:

// import('./lazy') becomes:
var e = (yield _r(d[9])(d[8], d.paths)).lazyGreeting;
//          ^asyncRequire — module still lives in this bundle

No separate chunk is emitted. import() in React Native means deferred evaluation, not code splitting. Splitting is a Metro/Expo web feature.

Hermes already lazy-compiles function bodies on first call, which reduces the value of inline requires. The remaining win is deferring module top-level evaluation — side effects, object construction — not parse cost.

rnx-kit actually shrinks the bundle

rnx-kit’s --tree-shake hands the module graph to esbuild, which does real dead-code elimination. With one unused export in the fixture:

SetupSizeUnused export
Vanilla inlineRequires: false992 KBpresent
Vanilla inlineRequires: true993 KBpresent
rnx-kit --tree-shake807 KBremoved

Gotcha: esbuild only tree-shakes ESM. @react-native/babel-preset rewrites imports to CommonJS by default, which esbuild can’t analyse. Set disableImportExportTransform: true in your production build or you’ll get the esbuild output format with no actual DCE.

The full picture

SetupDefers evaluation?Shrinks bundle?Output
Vanilla inlineRequires: falsenonoMetro __d JS
Vanilla inlineRequires: truenamed imports onlynoMetro __d JS
Expo defaultnonoHermes .hbc
rnx-kit --tree-shakeyes, ~19%esbuild IIFE

Inline requires is a startup lever, not a size lever. And if you turn it on and the profile barely moves, the problem is probably structural — next post.