React Native Navigation Benchmarks: Deep Diving Where Time Is Spent
/ 5 min read
Part of the series. The intro has the headline numbers — here I go into the traces to explain them.
1 · Why Expo Router costs 3× cold start and RAM
Expo Router is ~2.6× the cold start and ~1.6× the peak RAM of the others. Three compounding causes.
Reanimated/Worklets spins up a second JS runtime
The Expo Router template ships react-native-reanimated@4 and react-native-worklets. None of the other three apps include these.
| Evidence | Expo Router | Other three |
|---|---|---|
| Reanimated/Worklets native libs in APK | 8 .so | 0 |
Total arm64 .so count | 20 | 11–18 |
| JS CPU blamed to worklets + reanimated | ~106 ms/run | 0 |
The Worklets package boots a separate Hermes instance for the UI thread. The profile shows [HostFunction] runOnUISync (~70 ms/run), registerCustomSerializable, installTurboModule. This is the main RAM driver — proved below.
The bundle is 2× bigger and 2.6× more modules run at startup
A 2.8 MB bytecode file means more to mmap and evaluate. Expo Router touches 106 source files before the first frame vs 36–43 for the others: React Navigation + the router layer (getRoutes, getStateFromPath, context navigators) + Reanimated/Worklets/Screens.
RAM breakdown by type
| Library | Peak RSS | anon heap | file (code) | GPU | HWUI |
|---|---|---|---|---|---|
| rn-navigation | 182 | 62 | 120 | 52 | 3.9 |
| React Navigation | 195 | 70 | 124 | 50 | 2.2 |
| navigation | 218 | 94 | 124 | 50 | 2.2 |
| Expo Router | 326 | 176 | 152 | 66 | 13.7 |
Expo Router’s premium is overwhelmingly anonymous heap — JS heaps + the native Worklets allocations.
The two axes have different causes. RAM = Reanimated/Worklets. Cold start = mostly the 2× bundle + 106 modules at boot; Reanimated accounts for only ~10% (~60 ms).
2 · Why rn-navigation is the leanest
The cleanest single signal: RUN_JS_BUNDLE — how long the JS entry takes to evaluate.
RUN_JS_BUNDLE — top-level bundle evaluation (ms, from Systrace)rn-navigation evaluates its entry in 55 ms: the JS just registers components and calls Navigation.setRoot(). Tabs and stacks are native Kotlin views — there’s no JS navigation tree to construct, so the first frame arrives fast.
React Navigation is JS-driven on top of react-native-screens. At startup it adds:
- A real JS navigation tree.
NavigationContainer→ navigators → screens are React components reconciled by Fabric (completeRoot,createNode,appendChild). - Expo module init. ~115 ms of JS is labeled
@expo/expo-modules-coreby the profiler. But the Expo Tax post shows the fixed Expo overhead is only ~36 ms — the rest is the app’s own native-module traffic wearing an Expo name tag. - Theme color processing.
color-convert/color-stringrun resolving the default theme.
The navigation router is interesting in the other direction: it has a native tab bar but the slowest bundle eval (266 ms) and the biggest CREATE_UI_MANAGER_MODULE_CONSTANTS (68 ms vs 11–22 ms). It eagerly requireNativeComponents ~two dozen view managers and reads Material3 constants at import time — heavy JS work before the first frame.
3 · Hot functions at startup (Hermes profile)
JS-thread self-time, averaged over 3 runs.
The shared cold-start tax across all four libraries:
| Function | What it is |
|---|---|
[HostFunction] getConstants | Synchronous native-module constant loading over JSI — the biggest leaf on Expo apps |
getConstantsForViewManager | Per-view-manager config fetch (Fabric registering view types) |
completeRoot / createNode / appendChild | Fabric committing the first UI tree to the native shadow tree |
renderWithHooks / beginWork | React reconciling the initial render |
[GC Young Gen] / hades | Hermes GC under allocation pressure at boot |
Library-specific signatures:
| Library | JS busy (ms) | Where time goes |
|---|---|---|
| rn-navigation | 169 | react-native-navigation (57) · getConstantsForViewManager · requireNativeComponent |
| React Navigation | 219 | @expo (82) + expo-modules-core (33) · getConstants (74) · @react-navigation render (28) · color-convert |
| navigation router | 327 | getConstants (141!) · view-manager registration · TabBarItem |
| Expo Router | 461 | @expo (163) · react-native-worklets (95) + runOnUISync (70) · expo-modules-core (53) · expo-router (25) |
4 · Controlled experiment
The findings above are correlational — Expo Router differs from the others in many ways at once. To isolate Reanimated/Worklets I added only it to rn-navigation (same screens, same navigation, nothing else) and re-measured. (apps/rnn_reanimated_app in the repo.)
| Metric | RNN | RNN + Reanimated | Δ | Expo Router |
|---|---|---|---|---|
| Cold start (ms) | 316 | 378 | +62 | 917 |
| Peak RAM — Flashlight (MB) | 195 | 320 | +125 | 308 |
| Peak RSS — trace (MB) | 182 | 318 | +136 | 326 |
| of which anon heap (MB) | 62 | 185 | +123 | 176 |
RUN_JS_BUNDLE (ms) | 55 | 128 | +73 | 208 |
RAM: confirmed. Reanimated alone pushed peak RAM from 195 → 320 MB — matching Expo Router’s 308 MB. The jump is entirely anonymous heap (+123 MB), exactly the Worklets/Hermes UI runtime.
Cold start: mostly not Reanimated. The same change added only +62 ms — about 10% of Expo Router’s ~600 ms gap. The bulk is the 2× bundle and 106 modules at boot.
React Native Navigation Benchmarks