9.3 KiB
Set up EAS Observe in an existing project
EAS Observe collects app-startup performance metrics (cold launch, warm launch, bundle load, TTR, TTI) from production Expo apps. This reference summarizes the steps to add expo-observe to an existing project.
Source: https://docs.expo.dev/eas/observe/get-started/ — consult this page for the latest guidance.
SDK 55 vs SDK 56+ at a glance
The library exports differ between SDK versions. Pick the right one for the project's SDK before copying any snippet below.
| Concern | SDK 55 | SDK 56 and later |
|---|---|---|
| Root layout HOC | AppMetricsRoot.wrap(...) |
ObserveRoot.wrap(...) |
markInteractive() API |
Global: AppMetrics.markInteractive() |
Hook: const { markInteractive } = useObserve() |
| Import source | expo-observe |
expo-observe (same package) |
Everything else — package name, build process, dashboard, debug-mode behavior — is the same across versions.
Prerequisites
Before installing, confirm all of the following:
- An Expo account. Sign up at expo.dev/signup if needed.
- Expo SDK 55 or later. Run
npx expo-doctorto check, andnpx expo install --fixto update dependencies. SDK 56+ unlocks the newerObserveRoot/useObserveAPI. - An EAS project. The app must have
extra.eas.projectIdset in its app config. If not, runeas initto create one.
Step 1 — Install the library
From the project root:
npx expo install --fix
npx expo install expo-observe
Step 2 — Wrap the root layout
The HOC automatically measures Time to First Render (TTR). Apply it to the file that exports the app's root component. The HOC name depends on the SDK version.
SDK 55 — use AppMetricsRoot:
// app/_layout.tsx
import { Stack } from 'expo-router';
import { AppMetricsRoot } from 'expo-observe';
function RootLayout() {
return <Stack />;
}
export default AppMetricsRoot.wrap(RootLayout);
SDK 56 and later — use ObserveRoot:
// app/_layout.tsx
import { Stack } from 'expo-router';
import { ObserveRoot } from 'expo-observe';
function RootLayout() {
return <Stack />;
}
export default ObserveRoot.wrap(RootLayout);
Without Expo Router (App.tsx): wrap the default-exported App component the same way — export default AppMetricsRoot.wrap(App); on SDK 55, or export default ObserveRoot.wrap(App); on SDK 56+.
Step 3 — Mark the app as interactive
TTI is not collected automatically. Signal it once the screen is genuinely ready for the user — i.e. after splash-screen-blocking work like update checks, authentication, initial data fetching, or splash animations finishes. Place the call in a useEffect that runs once that work resolves.
SDK 55 — call the global AppMetrics.markInteractive():
// app/_layout.tsx
import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { AppMetrics, AppMetricsRoot } from 'expo-observe';
import { useEffect, useState } from 'react';
SplashScreen.preventAutoHideAsync();
function RootLayout() {
const [isReady, setIsReady] = useState(false);
useEffect(() => {
async function prepare() {
try {
await authenticateUser();
await fetchInitialData();
} catch (e) {
console.warn(e);
} finally {
setIsReady(true);
}
}
prepare();
}, []);
useEffect(() => {
if (isReady) {
SplashScreen.hide();
AppMetrics.markInteractive();
}
}, [isReady]);
if (!isReady) return null;
return <Stack />;
}
export default AppMetricsRoot.wrap(RootLayout);
SDK 56 and later — use the useObserve() hook to get a bound markInteractive:
// app/_layout.tsx
import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { ObserveRoot, useObserve } from 'expo-observe';
import { useEffect, useState } from 'react';
SplashScreen.preventAutoHideAsync();
function RootLayout() {
const [isReady, setIsReady] = useState(false);
const { markInteractive } = useObserve();
useEffect(() => {
async function prepare() {
try {
await authenticateUser();
await fetchInitialData();
} catch (e) {
console.warn(e);
} finally {
setIsReady(true);
}
}
prepare();
}, []);
useEffect(() => {
if (isReady) {
SplashScreen.hide();
markInteractive();
}
}, [isReady, markInteractive]);
if (!isReady) return null;
return <Stack />;
}
export default ObserveRoot.wrap(RootLayout);
Without Expo Router: the structure is the same in App.tsx. Use SplashScreen.hideAsync() instead of SplashScreen.hide() and replace <Stack /> with the app's root tree.
Multiple entry screens
markInteractive() is safe to call repeatedly — only the first call per session is recorded. If the app has more than one entry screen (onboarding, login, deep-link targets), call markInteractive() on each one. Otherwise TTI will be missing for sessions that open via a deep link to a screen without the call.
Step 4 — Build the app
Metrics are collected from real builds, not from expo start:
eas build
By default, metrics collected from debug builds are not dispatched. A build is treated as a debug build when either the native app is a debug build or the JS bundle is a development bundle (
__DEV__istrue). To dispatch anyway while testing the integration, setdispatchInDebug: truewhen callingconfigure()— see Enable metrics in development. This has no effect on release builds.
Step 5 — View the metrics
Open the Observe tab in the EAS dashboard at https://expo.dev/accounts/[account]/projects/[project]/observe to view metrics from the app.
To query metrics from the terminal with the EAS CLI, see ./queries.md. For interpreting the metrics themselves, see ./metrics.md.
Optional — per-route navigation metrics (SDK 56+)
By default expo-observe records app-wide startup metrics only. To additionally get per-route / per-screen navigation metrics (cold_ttr, warm_ttr, and a per-navigation tti, each tagged with the route/screen), enable one of the navigation integrations. These require SDK 56 or later; on earlier SDKs they are silent no-ops. Query the resulting data with eas observe:routes (see ./queries.md).
Pick the integration that matches the app's router:
Expo Router
Docs: https://docs.expo.dev/eas/observe/integrations/expo-router/
-
Enable the integration at module scope, before any screen mounts (it cannot be toggled at runtime — calling
configure()after mount throws):// app/_layout.tsx import { Observe } from 'expo-observe'; Observe.configure({ integrations: { 'expo-router': true }, }); -
Call
useObserve()inside each screen to get amarkInteractivescoped to the current route, and call it from auseEffectonce the screen is interactive:import { useObserve } from 'expo-observe'; import { useEffect } from 'react'; export default function Home() { const { markInteractive } = useObserve(); useEffect(() => { markInteractive(); }, [markInteractive]); return (/* screen content */); }
Events are tagged with the route pattern (e.g. /(tabs)/sessions/[sessionId]) so the dashboard buckets distinct param values together; the resolved url and routeParams are also included. Requires expo-router installed at runtime, or the integration no-ops.
React Navigation
Docs: https://docs.expo.dev/eas/observe/integrations/react-navigation/
Requires @react-navigation/native 7.0.0 or later. Same useObserve() screen usage as above, plus two extra changes:
-
Enable the integration at module scope, before mount:
// App.tsx import { Observe } from 'expo-observe'; Observe.configure({ integrations: { 'react-navigation': true }, }); -
Replace the top-level
<NavigationContainer>with<ObserveNavigationContainer>— a drop-in replacement that accepts the same props and forwards the same ref. If you pass alinkingconfig it is used to resolve a human-readable screen path; otherwise the metric falls back toroute.name.import { ObserveNavigationContainer } from 'expo-observe/integrations/react-navigation'; export default function App() { return <ObserveNavigationContainer>{/* navigators */}</ObserveNavigationContainer>; }
In both integrations, useObserve() is safe to leave in place even when the integration is disabled or the router package is absent — it falls back to the global markInteractive.
Quick checklist
- SDK ≥ 55, EAS project linked.
expo-observeinstalled vianpx expo install.- Root component exported through
AppMetricsRoot.wrap(...)(SDK 55) orObserveRoot.wrap(...)(SDK 56+). markInteractive()called from every entry screen once it is genuinely interactive — globalAppMetrics.markInteractive()on SDK 55, oruseObserve()hook on SDK 56+.- (Optional, SDK 56+) Per-route metrics enabled via
Observe.configure({ integrations: { ... } }), plus<ObserveNavigationContainer>for React Navigation. - New build produced with
eas buildand metrics visible in the Observe dashboard.