Quick answer
To find slow screens in a mobile app, instrument the navigation layer instead of individual screens. Time every route from the navigation action to first render and to interactive, at the 90th percentile, per release, from real user devices. The slow screens are the ones whose P90 render exceeds your budget. Start with a budget of 1 second on a mid-range device and tighten it from there.
Why nobody can name the slow screen
Your product manager posts in the team Slack channel: "Order history takes forever on my phone. Is the API down?" The API is fine. Someone opens the profiler, taps through the app on a new phone, and every screen looks fine. Nobody can name the screen that is slow, so nobody can fix it.
The gap has a specific cause. Startup dashboards tell you how long the app takes to open. They say nothing about what happens after the first screen. A user who opens the app in 1.2 seconds and then waits 4 seconds for the order history screen will still call the app slow, and your startup chart will still look green.
By the end of this guide you will know what to measure per screen, how to collect it natively and in React Native, and how to sort the results so the first screen you fix is the one that matters most.
Why don't startup metrics show slow screens?
The metrics platforms give you for free are launch metrics. Xcode Organizer and MetricKit measure the time from the icon tap to the first frame. Android vitals reports cold, warm and hot start, and flags a cold start of 5 seconds or more as slow. Both stop counting when the first screen is drawn.
Everything after that first screen is invisible to them. A slow detail view, a settings page that blocks on a network call, a tab that re-renders a 2,000 row list on focus: none of it shows up in a launch histogram. Two habits make the blind spot worse.
Measuring on your own device. A flagship phone plugged into a laptop hides slow storage, thermal throttling, low-power mode and a metered connection. The screen that takes 400 milliseconds in the office takes 2 seconds on a four-year-old Android phone with Battery Saver on.
Reporting averages. If one screen in ten takes 4 seconds and the rest take 300 milliseconds, the average is 670 milliseconds and looks acceptable. The P90 is 4 seconds. Report P90. It is the experience one in ten navigations delivers every day.
What to measure per screen
Three numbers per route, all from production, all at P90.
First render (cold)
Time from the navigation action (a tap on a link, a router.push() call, a deep link) to the moment the destination screen is on screen and focused for the first time. Cold first render is the number users feel as "the tap did nothing".
Android's equivalent for app launch is Time to initial display (TTID). Sentry calls the per-screen version Time to initial display too. The definition is the same: first frame of the screen.
Warm render
The same measurement for a screen that was already rendered before it gained focus, for example after back navigation or a prefetch. Warm render should be near zero. A warm render that is not near zero means the screen does expensive work on every focus, not just on mount.
Time to interactive (TTI)
Time from the navigation action to the moment the screen's content has loaded and its touch handlers respond. A skeleton or a spinner does not count; the clock stops when the user can do the thing they came for.
No platform can detect this on its own, because only your code knows when the data has arrived. Android asks you to call reportFullyDrawn() for the app-level version, Time to full display (TTFD). Sentry's time-to-display API asks for <Sentry.TimeToFullDisplay> or Sentry.reportFullyDisplayed() per screen. EAS Observe asks for markInteractive() inside the screen. The API differs. The one-line-per-screen requirement does not.
If you skip this call, your TTI numbers will be the same as your first render numbers, and the screen that draws a skeleton in 200 milliseconds and then waits 3 seconds for the API will look fast.
How to instrument the navigation layer
The mistake most teams make is to instrument screens one at a time, starting with the one someone complained about. That approach produces data for three screens and none for the other forty. Instrument the navigation layer once, and every route reports.
Native iOS
Every screen in a UIKit app goes through viewDidAppear: and viewDidDisappear:. Firebase Performance Monitoring hooks exactly those two calls to start and stop a trace for every UIViewController in the key window. You can do the same thing yourself with a UIViewController subclass or method swizzling, emit an OSSignposter interval per screen, and read it in Instruments during development. To get the same intervals from production devices you need an SDK that uploads them, because signposts stay on the device.
Native Android
The equivalent hook is Application.ActivityLifecycleCallbacks, which fires for every Activity start and resume. For single-Activity apps built on Fragments or Compose, hook the navigation controller's destination-changed listener instead. Start a timer on navigation, stop it on first draw, and report the pair with the destination name. Android vitals will not show it for you; it reports startup and frame health only.
React Native and Expo
In React Native the navigation library is the single place every screen transition passes through, so it is the place to instrument.
Sentry's React Navigation integration sets enableTimeToInitialDisplay and records TTID for every screen, and you add <Sentry.TimeToFullDisplay> where the screen is ready. Sentry's docs say TTID is available for React Navigation 5 and later, and that the feature does not run in Expo Go.
Datadog RUM tracks each React Navigation screen as a view when you call DdRumReactNavigationTracking.startTrackingViews() from the NavigationContainer onReady callback.
EAS Observe hooks Expo Router or React Navigation 7 with one configure() call and records cold render, warm render and TTI per route pattern. Details are in the Expo section below.
Whichever SDK you pick, two rules hold:
- Tag by route pattern, not by URL.
/orders/[orderId]is one screen./orders/1042and/orders/1043are two URLs. If your tool buckets by URL you will get thousands of one-sample rows and no P90. EAS Observe reports the pattern asrouteNameand keeps the resolved values inrouteParamsfor drill-down. - Filter parameters that carry personal data. Route params end up in a dashboard. If a param is a user ID or a token, strip it at the SDK. EAS Observe does this with
filteredParamson SDK 57 and later.
Which tools measure per-screen performance?
| Tool | Per-screen first render | Per-screen TTI | Slow and frozen frames per screen | Release comparison | Where it wins |
|---|---|---|---|---|---|
| Xcode Organizer + MetricKit | No (launch only) | No | Hangs, app-wide | Yes, per app version | Free, no SDK, launch P50 and P90 per version (Apple) |
| Android vitals | No (launch only) | No | Yes, app-wide | Yes, per app version | Free, no SDK, the numbers Google uses for Play ranking (Android) |
| Firebase Performance Monitoring | No | No | Yes, per UIViewController and Activity | Yes | Automatic per-screen frame health with no code (Firebase) |
| Sentry | Yes (TTID, React Navigation 5+) | Yes (TimeToFullDisplay) | Yes | Yes | Same tool as your crash reports; native and web too (Sentry) |
| Datadog RUM | Views per React Navigation screen | Not documented for React Native | Yes | Yes | Teams already on Datadog for backend traces (Datadog) |
| EAS Observe | Yes (nav_cold_ttr, nav_warm_ttr) | Yes (nav_tti) | Slow, frozen and total delay on startup TTI events | Yes, per app version and per JavaScript update | Expo Router integration; compares across over-the-air updates (Expo docs) |
Two gaps to know about. Firebase gives you frame health per screen but not load time per screen. Sentry and Datadog are the stronger choice if you also need native crash symbolication today; EAS Observe's error reporting is in preview.
How to rank slow screens and find the cause
You now have a table of forty routes with three numbers each. Sort it three times.
1. Sort by P90 cold render, descending. The top of this list is where users wait. Anything over your budget is a candidate. Do not start with the median; a screen with a fine median and a terrible P90 is the one generating support tickets.
2. Re-sort the candidates by session count. A 3 second screen that 2% of users open is less urgent than a 1.4 second screen that every user opens four times a day. Multiply P90 by navigations per day if you want a single number.
3. Check the revenue path. Checkout, paywall, onboarding and the first screen after login carry more weight than their session count suggests. A slow paywall costs money on every navigation. Fix these first even if they sort lower.
Then look at the shape of each slow screen:
- High TTI, low first render. The screen draws fast and then waits. The cause is data: a waterfall of requests, a large JSON parse, a cache miss. Show cached content first, or move the fetch earlier.
- High first render. The screen is heavy before data arrives. The cause is rendering: a large initial tree, synchronous storage reads, an image decode on the main thread. Lazy-load below-the-fold sections and defer heavy children.
- High warm render. The screen does work on every focus. Look at effects that run on focus and lists that rebuild their data on each visit.
Slice each of these by platform, OS version and app version before you touch code. A regression that appears only on one app version is a code change. A regression that appears on every version at once is a backend or network change.
Where Expo fits
EAS Observe is Expo's production performance monitoring service for React Native apps. With the Expo Router or React Navigation integration turned on, it records three metrics per route, cold_ttr, warm_ttr and tti, tagged with the route pattern, and shows them on the Navigation page of the dashboard and in the CLI. In the Observe dashboard, every metric can be filtered by app version, by native build and by EAS Update update ID. The launch post, Introducing Observe, explains why the service links every metric to the build or update that produced it.
Setup is one install plus two additions to your root layout, a wrap and a configure() call, as the getting started guide shows.
Cold and warm render now report for every route with no per-screen code. TTI needs one call in each screen you care about, scoped to that screen by the useObserve() hook.
For apps that use React Navigation directly, set 'react-navigation': true instead and replace <NavigationContainer> with <ObserveNavigationContainer> from expo-observe/integrations/react-navigation, as the React Navigation integration guide shows.
To get the ranked list from the terminal, use the EAS CLI observe commands:
Limitations. Per-route metrics need Expo SDK 56 or later; SDK 55 reports app-wide startup metrics only. The React Navigation integration needs @react-navigation/native 7 or later. The integration must be enabled before the app mounts and cannot be toggled at runtime. EAS Observe does not run in Expo Go; you need a development or production build. Per-route TTI is only recorded on screens where you call markInteractive(). Metric data is retained for a minimum of 60 days. EAS Observe includes 100,000 events per month on the Free plan and 500,000 on paid plans, then usage-based pricing. Error reporting and native crash capture are in preview.
Per-screen budgets at a glance
There is no platform-enforced threshold for per-screen render time. These are the closest published numbers, plus a starting budget.
| Metric | Reference number | Source |
|---|---|---|
| Per-route cold render, P90 | Start at 1 s on a mid-range device; tighten once you have data | This guide (suggested starting budget, not a standard) |
| Per-route TTI, P90 | Under 3 s including cold launch is Expo's app-level recommendation; per-route should be well under that | Expo metrics reference |
| Warm render | Near zero; a warm render close to cold render means work runs on every focus | Expo Router integration |
| Slow frame | 16 ms (Firebase) or 17 ms (Expo) per frame | Firebase, Expo |
| Frozen frame | 700 ms per frame | Firebase, Expo |
| App cold start | 5 s or more is slow (Android vitals); under 1.5 s recommended (Expo) | Android vitals, Expo |
Next step
Turn on per-route metrics for your whole app this week and pull the ranked list on Friday. If your app is React Native on Expo SDK 56 or later, the Expo Router integration is one configure() call. If it is native, start by hooking viewDidAppear: or ActivityLifecycleCallbacks and shipping the timings to whichever SDK you already have.
Verified on 12 September 2026.
