Startup and Bundle Size

What runs before your first screen appears, how Hermes loads code, and how to measure cold start honestly.

SyntaxEXPO_ATLAS=true npx expo export --platform ios

Cold start is the time from tapping the icon to the first usable screen. Four things happen in order: the operating system starts the process, React Native initialises the native host and its modules, Hermes loads the JavaScript bundle, and React renders your first screen. You influence the last two directly.

Hermes and bytecode

Hermes is the default engine. In a release build your JavaScript is compiled ahead of time into bytecode, which is loaded into memory on demand instead of being parsed at startup. So the total byte size of the bundle matters less than how much code evaluates before the first screen. Hermes is not compatible with RAM bundles, which it replaces.

Deferring evaluation

A dynamic import() returns a promise and defers a module's top-level code until first use. Metro still ships one bundle, so this saves evaluation time, not download size. Metro's inline requires rewrite top-level require calls so they run on first use; as of 0.87 the React Native CLI applies this by default and Expo does not, so set inlineRequires: true in metro.config.js if you want it. It only affects require, never import, and it is unsafe for modules with side effects.

Measuring

Use a release build. In development the bundle is fetched from Metro, so you would be timing a download. On Android, adb shell am start -W prints TotalTime. For bundle contents, run EXPO_ATLAS=true npx expo export and open the report.

Example

Example · tsx
import { useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
// `import type` is erased at compile time, so it pulls in no runtime code.
// The longhand `import { type ChartPoint } from ...` means the same thing.
import type { ChartPoint } from "../lib/chart";

export default function ReportScreen() {
  const [points, setPoints] = useState<ChartPoint[] | null>(null);
  const [loading, setLoading] = useState(false);

  // Metro ships a single bundle, so import() does not shrink the download.
  // What it defers is evaluation: the chart module's top-level code runs on
  // the first tap instead of during startup.
  async function openChart() {
    setLoading(true);
    const { buildPoints } = await import("../lib/chart");
    setPoints(buildPoints());
    setLoading(false);
  }

  return (
    <View style={styles.container}>
      <Pressable onPress={openChart} style={styles.button} disabled={loading}>
        <Text style={styles.buttonText}>Show chart</Text>
      </Pressable>
      {loading ? <Text style={styles.status}>Preparing chart</Text> : null}
      {points ? <Text style={styles.status}>{`${points.length} points ready`}</Text> : null}
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, alignItems: "center", justifyContent: "center", gap: 12 },
  button: { backgroundColor: "#1f6feb", paddingHorizontal: 16, paddingVertical: 10, borderRadius: 8 },
  buttonText: { color: "#ffffff" },
  status: { color: "#6b7280" },
});

When to use it

  • Cold start is over three seconds on a mid-range Android phone and you need to know which phase is to blame.
  • The app bundles a chart library and an icon set used on one screen each.
  • Proving that a startup change actually helped, across releases.
  • Finding out what is actually in the production bundle.

More examples

Holding the splash screen only as long as needed

The splash screen stays up until the session is restored, then the stack appears. This is the honest version of a fast startup: moving work behind the splash screen does not make the app start sooner, it only hides the gap. Keep the list of things gated here short, and measure the whole thing from tap to first interaction, not from first render.

Example · tsx
import { useEffect, useState } from "react";
import * as SplashScreen from "expo-splash-screen";
import { Stack } from "expo-router";
import { loadSession } from "../lib/session";

// Called at module scope so the splash screen is held before the first render.
SplashScreen.preventAutoHideAsync();

export default function RootLayout() {
  const [ready, setReady] = useState(false);

  useEffect(() => {
    // Only the work the first screen genuinely needs belongs here.
    // Anything else runs after the user can see the app.
    loadSession()
      .catch(() => undefined)
      .finally(() => setReady(true));
  }, []);

  useEffect(() => {
    if (ready) {
      SplashScreen.hideAsync();
    }
  }, [ready]);

  if (!ready) {
    return null;
  }

  return <Stack />;
}

Enabling inline requires in an Expo project

The React Native CLI inlines requires by default; Expo projects do not, so this is how you opt in. It can cut a noticeable slice off JS startup in an app with many modules. The blockList matters: a module whose top-level code registers a listener or sets a global must keep running eagerly, or the behaviour quietly disappears.

Example · javascript
// metro.config.js
const { getDefaultConfig } = require("expo/metro-config");

const config = getDefaultConfig(__dirname);

config.transformer.getTransformOptions = async () => ({
  transform: {
    // Rewrites top-level require() calls to run on first use.
    inlineRequires: {
      // Modules with side effects must be excluded: inlining changes
      // evaluation order and can skip them entirely.
      blockList: {
        [require.resolve("./src/analytics.ts")]: true,
      },
    },
    // Keep these evaluated eagerly, in order.
    nonInlinedRequires: ["react", "react-native"],
  },
});

module.exports = config;

Measuring cold start on Android

TotalTime is the figure to track between releases. Run it on the slowest device you support, not the newest, because startup is dominated by CPU and storage speed. On iOS the equivalent is Xcode's Instruments App Launch template against a release build on a device.

Example · bash
# Install the release build first; a dev build loads JS from Metro.
# Force-stop so this is a cold start, not a resume.
adb shell am force-stop com.example.app

# -W waits and prints the launch timings.
adb shell am start -W -n com.example.app/.MainActivity
# ThisTime, TotalTime and WaitTime are printed in milliseconds.

# Repeat five times and take the median; one run tells you nothing.

Discussion

  • Be the first to comment on this lesson.