Bundle Size Analysis
Metro bundle visualizer and lazy route splitting - find what ships in the Hermes main bundle and defer heavy screens until after cold start.
Search across all documentation pages
Metro bundle visualizer and lazy route splitting - find what ships in the Hermes main bundle and defer heavy screens until after cold start.
Quick-reference recipe card - copy-paste ready.
1. Generate production bundle + source map
npx react-native bundle \
--platform android \
--dev false \
--entry-file node_modules/expo-router/entry.js \
--bundle-output ./.perf/index.android.bundle \
--sourcemap-output ./.perf/index.android.bundle.map \
--assets-dest ./.perf/assets2. Open treemap
npx react-native-bundle-visualizer \
--bundle ./.perf/index.android.bundle \
--sourcemap ./.perf/index.android.bundle.map3. Triage top offenders
| Treemap block | Common fix |
|---|---|
moment / date-fns/locale/* | dayjs or narrow imports |
lodash full | lodash/es/map or native helpers |
@expo/vector-icons entire set | Per-family import or subset font |
charting / maps / editor SDKs | Lazy route + dynamic import() |
Duplicate react / nested copies | Align monorepo metro.config.js |
4. Lazy-load heavy Expo Router screens
// app/(tabs)/analytics.tsx - lazy tab screen
import { Suspense, lazy } from "react";
import { ActivityIndicator, View } from "react-native";
const AnalyticsScreen = lazy(() => import("../../src/screens/AnalyticsScreen"));
export default function AnalyticsRoute() {
return (
<Suspense
fallback={
<View style={{ flex: 1, justifyContent: "center" }}>
<ActivityIndicator />
</View>
}
>
<AnalyticsScreen />
</Suspense>
);
}5. CI size gate (sketch)
#!/usr/bin/env bash
# scripts/check-bundle-size.sh
BUNDLE="./.perf/index.android.bundle"
MAX_BYTES=4500000 # ~4.3 MB raw - adjust to your gzip budget
SIZE=$(wc -c < "$BUNDLE")
if [ "$SIZE" -gt "$MAX_BYTES" ]; then
echo "Bundle $SIZE exceeds $MAX_BYTES"
exit 1
fiWhen to reach for this:
import from a heavy SDK on the root layout path.Audit a bloated root import, split a heavy screen, and verify the treemap block moves out of the main path.
// app/_layout.tsx - keep root lean
import { Stack } from "expo-router";
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "../src/lib/queryClient";
export default function RootLayout() {
return (
<QueryClientProvider client={queryClient}>
<Stack screenOptions={{ headerShown: false }} />
</QueryClientProvider>
);
}// app/settings/reports.tsx - heavy screen NOT on TTI path
import { Suspense, lazy } from "react";
import { ActivityIndicator, StyleSheet, View } from "react-native";
const ReportsScreen = lazy(() =>
import("../../src/screens/ReportsScreen").then((m) => ({ default: m.ReportsScreen })),
);
export default function ReportsRoute() {
return (
<Suspense fallback={<View style={styles.fallback}><ActivityIndicator /></View>}>
<ReportsScreen />
</Suspense>
);
}
const styles = StyleSheet.create({
fallback: { flex: 1, alignItems: "center", justifyContent: "center" },
});// src/screens/ReportsScreen.tsx - heavy deps isolated to lazy chunk
import { StyleSheet, Text, View } from "react-native";
// Example heavy dep - only loads when user opens Reports
import { VictoryPie } from "victory-native";
export function ReportsScreen() {
const data = [
{ x: "A", y: 35 },
{ x: "B", y: 25 },
{ x: "C", y: 40 },
];
return (
<View style={styles.screen}>
<Text style={styles.title}>Reports</Text>
<VictoryPie data={data} width={280} height={280} />
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, padding: 16, alignItems: "center" },
title: { fontSize: 20, fontWeight: "700", marginBottom: 16 },
});Verification checklist
victory-native (or your SDK) area in main bundle.lazy() - re-bundle; heavy block shrinks in main treemap.import the heavy module transitively through barrels.// BAD - pulls entire library
import _ from "lodash";
import * as Icons from "@expo/vector-icons";
// BETTER
import debounce from "lodash/debounce";
import Ionicons from "@expo/vector-icons/Ionicons";// packages/ui/src/index.ts - re-exports everything
export * from "./charts"; // ← drags charts into any `from "@acme/ui"` import
// BETTER - explicit subpaths in package.json exports
// import { Button } from "@acme/ui/button";Shared packages in monorepos duplicate dependencies easily - align watchFolders and resolver in metro.config.js. See Shared Packages & Metro Resolution.
| Pattern | TTI impact |
|---|---|
lazy() screen module | Chunk loads on first navigation - good for settings/reports |
Tab lazy: true (default) | Defers first mount until tab focus - good for cold start |
Link prefetch | Loads chunk early - use only for likely next screen |
Root _layout imports | Always on critical path - keep minimal |
// Prefetch heavy detail route when user hovers card - optional trade-off
import { Link } from "expo-router";
<Link href="/reports" prefetch>
Open reports
</Link>Assets (images, fonts) are separate but affect download - expo-asset bundled media appears in export output.
dev: true includes dev tooling; always use --dev false.lazy() routes.Both map bytes to modules. react-native-bundle-visualizer is tuned for RN Metro output - start there. Use source-map-explorer if you already have web tooling in CI.
CI often gates raw or gzip - pick one and document it. Gzip ≈ 30–40% of raw for typical RN bundles; align with your 4 MB budget definition.
Metro supports import() for async chunks. Native modules still autolink at build time - removing JS import does not remove native code unless you uninstall the package.
No - only imports reachable from production entry count. They still affect install and CI time - see Dead Code & Dependency Analysis.
Bundled .ttf files land in assets. Subset icon fonts and load display fonts after splash when possible - Startup Time Optimization.
TTI includes native startup, bytecode load, and first-screen work - not JS bytes alone. Profile holistically with Performance Basics.
lazy tab mountingStack versions: This page was written for React 19.2.3, React Native 0.86.0, and Expo SDK 57 (
expo~57.0.4).
Reviewed by Chris St. John·Last updated Jul 16, 2026