Metrics reference
Edit page
A reference of each performance metric tracked by Expo Observe, including concepts and data handling.
For the complete documentation index, see llms.txt. Use this file to discover all available pages.
Reference for the performance metrics Expo Observe collects, the core concepts used to organize events (sessions and users), and how collected data is retained.
Concepts
Session
A session starts when the app process is launched and ends when the app process is terminated. Each session has a unique identifier and contains all metrics collected during that app launch.
User
A user is identified by an anonymous ID that is unique per app installation. This ID:
- Is generated when the app is first installed
- Persists across app updates
- Is reset if the user uninstalls and reinstalls the app
- Is not Personally Identifiable Information (PII)
This allows you to see metrics across multiple sessions for the same user without collecting personal data.
Metrics
All duration metrics are reported in seconds.
Cold launch time
What it measures: Time from process creation to when the system has finished allocating memory, starting a fresh runtime environment, loading the app's code and resources from disk, and initializing its components before rendering the UI. This is the slowest type of launch and typically occurs after a fresh install, app upgrade, device reboot, or when the OS has killed the app to reclaim memory. It is a native-only metric, meaning your JavaScript code does not affect this metric, but the React Native runtime initialization is included.
This metric is collected automatically.
How to improve:
- Remove unused native modules.
- Avoid static initializers (
+loadmethods in Objective-C, static constructors in C++), and native modules or config plugins that add them. - Keep the app's memory and CPU usage low so the OS does not kill the process while in the background. This does not affect this metric itself, but makes subsequent launches warm instead of cold.
- If you use
expo-updateswith a non-zerofallbackToCacheTimeout, the app launch is blocked waiting for an update check. Keep this value at0(default), or setcheckOnLaunchtoNEVERorERROR_RECOVERY_ONLYto avoid delaying cold launches.
Our recommendation: under 1.5s.
Warm launch time
What it measures: A warm launch happens when the OS already has the app process in memory and only needs to bring it back to the foreground and recreate the view hierarchy. Unlike a cold launch, most native resources and services are already in memory, so this type of launch is significantly faster.
Apps can't pre-warm themselves: the OS decides which processes stay in memory based on system pressure and recent use. You can influence the duration of a warm launch, but not whether one happens.
This metric is collected automatically.
How to improve:
- Remove unused native modules.
- Reduce the number of views in the view hierarchy. The OS has to recreate the view tree on warm launch, so a deeply nested or bloated tree takes longer to restore.
Our recommendation: under 0.5s.
Bundle load time
What it measures: Duration of loading the JavaScript bytecode and evaluating it. This starts when the bundle begins loading and ends when the bundle finishes evaluating, before runApplication is called.
This metric is collected automatically.
How to improve:
- Reduce the bundle size:
- Use tree shaking (enabled by default as of Expo SDK 54) and follow the rules that help Metro strip unnecessary code. See Tree shaking and code removal.
- Analyze your JavaScript bundle to remove unused and large dependencies. See Analyzing JavaScript bundles with Expo Atlas.
- Lazy-load large screens and components with
React.lazy(). See Optimize JavaScript loading. - Avoid blocking the JavaScript thread in the top-level scope:
- Don't do heavy computations.
- Defer any synchronous I/O operations (storage reads and writes).
Our recommendation: under 0.3s.
Time to first render (TTR)
What it measures: Time from when the app finishes native launching to when the root React component first renders on the screen. This is the moment actual content is rendered by React, after the splash screen is hidden. The goal for every app should be to show something meaningful as fast as possible, even if it's a skeleton loading screen.
This metric is collected automatically when you wrap your root layout with AppMetricsRoot (see Get started). You can also call AppMetrics.markFirstRender() manually if needed.
How to improve:
- Reduce bundle load time (see above).
- Avoid synchronous I/O operations (storage reads and writes).
- Avoid blocking on network requests.
- Keep the initial render tree small (defer heavy components).
- Use a lightweight screen as the initial route.
- Minimize
useEffectanduseLayoutEffectchains that block rendering.
Our recommendation: under 2s including the cold launch time.
Time to interactive (TTI)
What it measures: Time between the warm/cold launch and when the user can actually tap, scroll, and interact with the app in other ways. It is the most important startup metric because it is what users perceive as "the app is ready".
This metric is not reported automatically. To start measuring it, call AppMetrics.markInteractive() once the screen is ready for user interaction, for example in a useEffect that runs after your initial data has loaded. If your app uses deep links, the main screen may not always be the initial screen, so we recommend calling this function on other screens too. Only the first call per launch is recorded, so it is safe to call it multiple times (for example, when the user navigates between screens). If you use expo-router, the metric's event automatically includes the current route name.
What makes an app "interactive"?
All of these must be true:
- Content is rendered on the screen (not just a splash or skeleton).
- Touch handlers are attached and responsive.
- Navigation is functional.
How to improve measurement accuracy: Call AppMetrics.markInteractive() only after the screen's content is loaded and touch handlers are active, not just on component mount. If your screen fetches data before becoming usable, place the call after the data is ready.
How to improve:
- Reduce time to first render (see above).
- Avoid waterfall data fetches before showing interactive content.
- Optimize initial network requests.
- Avoid rendering large lists (use FlashList or LegendList).
- Reduce heavy work that may block the JavaScript thread and interactions (I/O operations, state hydration, JSON parsing).
- If possible, show cached or local data first.
Our recommendation: under 3s including the cold launch time.
Automatic event params
Each TTI event includes extra params to help triage issues:
expo.frameRate.slowFrames(count): Frames that took 17ms or longer to render. If this is high relative to the startup duration, the main thread was consistently busy during launch. Points to heavy layout work, synchronous bridge calls, or too many components rendering at once.expo.frameRate.frozenFrames(count): Frames that took 700ms or longer to render. These are hard freezes where the app visibly hung. Even one during startup is a serious issue. Usually caused by synchronous I/O, large JSON parsing, or blocking network calls on the main thread.expo.frameRate.totalDelay(seconds): Total accumulated time all frames exceeded their target duration. This is the single best "smoothness" number. Compare it to TTI: if TTI is 2.5s andtotalDelayis 0.1s, startup was slow but smooth (the time was spent on legitimate work). IftotalDelayis 1.5s, the app was janky for most of the startup, and the user was staring at a stuttering screen.expo.device.lowPowerMode(boolean): Whether the OS power-saver mode (Low Power Mode on iOS, Battery Saver on Android) was active when TTI was reported. Power-saver mode throttles CPU, GPU, and background activity, so a TTI regression that disappears once this flag is filtered out is environmental rather than a code change.expo.device.batteryLevel(number, 0–1): Fractional battery charge at TTI. Useful for ruling out thermal/throttling effects on devices that aggressively manage performance at low charge. Omitted when the OS does not report a value.expo.device.batteryCharging(boolean): Whether the device was plugged in or wirelessly charging. Charging tends to raise sustained CPU performance ceilings on iOS and some Android OEMs, so non-charging samples are the more conservative population to compare against.expo.device.thermalState(string): One ofnominal,fair,serious,critical,unknown. Sustainedserious/criticalstates cause the OS to throttle the CPU/GPU and can dramatically slow startup independent of any app change.expo.network.connected(boolean): Whether the device had an internet-capable network at TTI. If TTI degrades only when this istrue, the cause is likely a network-bound startup path; if it degrades whenfalse, the app is doing more work than it should before showing cached content.expo.network.type(string): One ofwifi,cellular,ethernet,none,other,unknown. Use to compare cellular versus Wi-Fi populations — large gaps usually point to network-bound startup work. VPN traffic is reported as the underlying transport (typicallywifiorcellular) since the VPN tunnels over it. The value set is intentionally the same on Android and iOS so dashboards don't need per-platform branching.
How to interpret them:
- High TTI + low total delay: startup is slow but smooth. Optimize what's blocking the launch sequence (bundle size, data fetching, initialization chains).
- High TTI + high total delay + many slow frames: main thread contention. Offload work and simplify the initial render tree.
- High TTI + high delay + frozen frames: something is blocking hard. Look for synchronous I/O, large JSON parsing, or blocking API calls.
Custom event params
You can attach your own params to the TTI event by passing them to markInteractive(). This is useful for slicing TTI by app-specific dimensions such as user cohort, tenant, feature flag variant, or the type of content the screen loaded.
AppMetrics.markInteractive({ params: { tenant: 'acme', cohort: 'beta', cacheHit: true, }, });
You can also override the route name attached to the event, which is otherwise populated from the initial route detected by Expo Router. This useful when the screen's logical name differs from the router path, is a dynamic route, or when not using Expo Router:
AppMetrics.markInteractive({ routeName: '/feed', params: { cacheHit: true }, });
Param values can be strings, numbers, booleans, or other JSON-serializable values.
Data handling
Offline collection
Metrics collected while the device is offline are stored on the device. They are automatically sent to the server when the app moves to the background, provided there is connectivity. You can also call ExpoObserve.dispatchEvents() to flush events manually at any time.
Data retention
Metric data is retained for a minimum of 60 days.
Sampling
By default, all installations dispatch their metrics. To dispatch from a fraction of installations instead, set sampleRate when calling configure(). The decision is deterministic per installation, so an installation is consistently in-sample or out-of-sample across app launches.
Environment
All metrics are grouped by environment. The environment value is derived from process.env.NODE_ENV by default (falling back to 'production' if unset), or can be overridden via configure({ environment }). The environment is a metadata tag attached to each metric and does not affect whether metrics are dispatched.
Debug builds
Metrics collected from debug builds are dropped before dispatching unless dispatchInDebug is set to true via configure() (for information, see Enable metrics in development). A build is treated as a debug build if either the native app is a debug build or the JS bundle is a development bundle (__DEV__ is true). This detection is independent of the environment value.
Disabling dispatching
You can disable all dispatching globally using configure({ dispatchingEnabled: false }). While disabled, any pending metrics are dropped without being dispatched and no further metrics are dispatched until it is set back to true.