Skip to content

Trace one Android cold start per app in the perf probe, and profile the native launch - #210

Merged
arun279 merged 9 commits into
feat/expo-nativefrom
perf/android-native-start
Oct 23, 2026
Merged

arun279 merged 9 commits into
feat/expo-nativefrom
perf/android-native-start

Conversation

@arun279

@arun279 arun279 commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner

Why

The Android launch spends 1.3 to 1.8 s before React Native starts its runtime (launch to ready minus the ready mark, on the CI emulator). This PR profiles that span with Android's own tracing. The one cut the profile pointed at gave no gain on the A/B, so it was dropped, and the PR keeps only the tracing.

What changes

scripts/perf-probe.mjs records a Perfetto trace of one verified cold start per app. It is uploaded with the probe's artifacts as cold-start-base.perfetto-trace and cold-start-change.perfetto-trace. The config is in scripts/cold-start.pbtx.

How the trace is taken:

  • perfetto --background-wait returns once its data sources are running, so tracing has begun before the app starts.
  • am start -W returns at the first frame, and SIGTERM then ends the trace.
  • The probe waits for that perfetto process to exit with ps -p, because SELinux denies the shell kill -0 on perfetto's domain. Shell sleep loops are banned by the flake-mask gate, and --detach returns before the data sources start.

The traces can be opened in ui.perfetto.dev or queried with trace_processor and its android.startup module (Perfetto: Android app startup).

Profile

A verified cold start of the base app on the CI emulator, Perfetto run 39911621692. Startup lasted 2545 ms with tracing on. Times are ms from the launch intent.

span ms what runs
0 to 396 396 system work before the process is forked
438 to 606 169 ActivityThreadMain
606 to 1066 460 bindApplication: resources (78), binder setup (114), React Native's native libraries libreactnative and libappmodules (155), content providers (under 10)
1105 to 1333 228 activityStart, including performCreate:MainActivity (128)
1334 to 1569 235 expo-updates' launcher picks the bundle; the main thread waits
1611 to 1776 165 ReactInstance.initialize, including initTurboModules (94, about 60 of it building Expo module definitions)
1786 to 2328 about 540 the JS thread: Expo's JSI install, then bundle evaluation and the first render (it ran 473 ms between 1333 and 2328)
2328 to 2536 208 the first React frame

What the trace shows:

  • expo-updates holds React Native back. Its delay-load handler starts the updates controller only when MainActivity asks to load the app, then holds the load until launchAssetFile returns (UpdatesPackage.kt, EnabledUpdatesController.kt in expo-updates 57). In the trace the main thread finished activityResume at 1334 ms and scheduled the React surface at 1595 ms, right after the launcher logged its Check state.
  • Most of that wait is emulator CPU starvation, not launcher work. The launcher thread ran for only 15 ms, sat runnable for about 248 ms, and held ART's thread-list lock while it waited, so every thread created then (including the React instance's) waited too. In that window the emulator's graphics composer HAL used 350 ms of the 2 CPUs.
  • The emulator inflates the whole span. Over the 2545 ms startup the composer HAL used 1501 ms of CPU, as much as the app (1527 ms). RenderThread spent 291 ms creating its EGL context in software. A phone's GPU and cores do not pay these costs, so this span should be measured on a device before it is optimized further.
  • The Displayed time and am start -W TotalTime end when the splash is hidden, not at the activity's first draw, since Android 12's splash screen API (Expo's lukmccall on expo/expo#36730). Ours is held until the runtime is built, so TotalTime includes JavaScript work.

Tried and dropped: starting expo-updates in Application.onCreate

A config plugin built the updates controller in MainApplication.onCreate of release builds and started it on its own thread. The aim was for the launcher to pick the bundle while the activity is created. Expo's own code starts it there under Android tests, and its older setup did too (Expo: EAS Update in existing native apps).

same emulator, base CI 39907471871 against the change's CI 39914285132 base change
Android relaunch loop, 30 each (run 39917998701): cold start 2491 ms 2503 ms
same loop: launch to ready 3756 ms 3723 ms
Android passes, 5 each (run 39917763422): cold start 1713 ms 1728 ms
same passes: verified cold start 1939 ms 2141 ms
same passes: launch to ready minus the ready mark 1214 ms 1205 ms
  • Why it moved nothing: initializeWithoutStarting kept the main thread asleep or waiting for a CPU for about 290 ms inside bindApplication. So the launcher started only about 35 ms earlier, and the React instance's thread was created at 1151 ms against 1138 ms.
  • Why not start it all on a background thread: that could race the delay-load handler's own initialize, which is not synchronized. So the plugin is gone.
  • iOS: the native change was Android-only. The iOS loop (run 39918815768) came out at 6666 ms base against 5132 ms change for launch to ready. That is the base being the cached app with its JavaScript re-embedded while the change is a fresh native build, the same skew seen on Find and fix the startup failure behind "Couldn't start Cue" #205, so it isn't the change. The iOS passes run (39918572742) stopped on the known empty-sample failure that Wait for a late screen change instead of failing the perf probe run #204 fixes.

Ruled out

  • Baseline Profiles. In the passes run a cold start compiled from the collected profile was no faster than a verified one (2427 ms against 1939 ms base, 2296 ms against 2141 ms change). This matches Expensify's finding that a profile's apparent gain came from comparing against an unverified adb install, which Play users never get. Android's guidance is in Baseline Profiles.
  • Unused native modules, Expo's first recommendation (Expo metrics reference). Every native dependency is imported by the app or required by one that is: expo-constants and expo-linking by expo-router, react-native-worklets by Reanimated.
  • Content providers (Android startup best practices). They take under 10 ms together, including Firebase's.

Found for later, outside the native span

  • WebView on the first request. The first network request loads the system WebView on an OkHttp thread (2388 to 2430 ms), and four other request threads wait about 35 ms each on it. That fits React Native's networking cookie handling, which reaches WebView's CookieManager. Cue's Trakt requests use a bearer token and no cookies.
  • The update check during first content. expo-updates' remote check runs on every launch from 1569 to 2253 ms, alongside the React instance and the bundle.

Neither belongs in this PR without its own measurement.

A Perfetto trace of a cold start on the CI emulator shows expo-updates
holding up React Native. Its delay-load handler starts the updates
controller only when MainActivity asks to load the app, then holds the
load until the launcher has picked a bundle: the main thread finished
activityResume at 1334 ms and scheduled the React surface at 1595 ms,
right after the launcher logged its Check state.

A config plugin now builds the controller in MainApplication.onCreate of
a release build, after Expo's modules, and starts it on its own thread,
so the launcher picks the bundle while the activity is created.
initializeWithoutStarting only builds the controller, so the later
initialize calls from the delay-load handler and onWillCreateReactInstance
see it and return. start() opens the updates database, so it stays off
the main thread. Debug builds are unchanged.
…ode helper, and fail the build if its anchor goes
On the same emulator A/B it moved nothing:
- relaunch loop, 30 launches each (run 39917998701): cold start 2491 ms
  base, 2503 ms change; launch to ready 3756 ms base, 3723 ms change
- passes, 5 each (run 39917763422): cold start 1713 ms base, 1728 ms
  change; verified cold start 1939 ms base, 2141 ms change

The change trace shows why. initializeWithoutStarting kept the main
thread asleep or waiting for a CPU for about 290 ms inside
bindApplication, so the launcher started only about 35 ms earlier than
before, and the thread that builds the React instance was created at
1151 ms against 1138 ms in the base trace. Starting all of it on a
background thread could race the delay-load handler's own initialize.
@arun279 arun279 changed the title Start expo-updates while the Android activity is created, and trace cold starts in the perf probe Trace one Android cold start per app in the perf probe, and profile the native launch Oct 23, 2026
@github-actions

Copy link
Copy Markdown

Pull request footprint

measurement base to head
Product code lines in core/src, native/src, native/app, and native/modules +0
Test lines in test, packages/*/{test,__tests__,e2e}, and .maestro +0
CI and tooling lines in .github and scripts +66
Product comment lines identified by a comment prefix +0

User-delivered artifact sizes

measurement base head delta
Expo iOS JavaScript bundle, raw file 5.45 MB 5.45 MB 0 B
Expo Android JavaScript bundle, raw file 5.48 MB 5.48 MB 0 B
Firebase tester APK, arm64-v8a and all densities 41.17 MB 41.17 MB 0 B
Play download estimate, XXXHDPI arm64-v8a English Android 15 19.83 MB 19.83 MB 0 B

Most complex product function

measurement base head
Cyclomatic complexity, oxlint complexity 10 10
Cognitive complexity, Biome noExcessiveCognitiveComplexity 15 15

@arun279
arun279 merged commit d9bba7f into feat/expo-native Oct 23, 2026
22 checks passed
@arun279
arun279 deleted the perf/android-native-start branch October 23, 2026 08:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant