Why an Expo app needs an analytics layer before adding Amplitude
18 min readWritten by: Jonathan Reis on
Adding Amplitude to an Expo app should not mean spreading a second SDK through product code. A runtime-aware analytics layer keeps Firebase and GA4 intact while creating room for independent rollout, failure isolation, and product analysis that does not inherit vendor boundaries.

An Expo app can compile for Android, iOS, and the browser, but its analytics SDKs do not become cross-platform by accident. In one production app, Firebase Analytics already handled native events. Opening the same app in a browser needed a different transport: Google Analytics 4 through gtag.js. The later requirement was to add Amplitude without replacing either destination.
At first, that sounds like a provider integration. It is really an architectural decision about where product facts stop and analytics vendors begin. Without that boundary, every new question about retention, a new dashboard tool, a privacy review, or a provider outage leaks into screens, game logic, and navigation. The apparent shortcut of calling each SDK directly is cheap only until the second provider arrives.
The goal was not to replace Firebase. It was to keep one product event contract and fan it out to every provider enabled for the current runtime. Android and iOS continue to use Firebase, with Amplitude available as a second native destination. Expo Web uses a GA4 Web stream inside the same GA4 property as the app, with Amplitude available beside it. That keeps gameplay analysis together while preserving the platform boundary.
The easy wrong move is to reuse the Measurement ID from the marketing landing page. It works technically, but it puts gameplay events beside acquisition traffic, campaign clicks, and landing-page scrolls. The data arrives, but the reports no longer answer clean product questions.
The real advantage is freedom to change analytics without changing the product
An event such as game_started is a product fact. It should mean the same thing whether it is sent to Firebase, GA4, Amplitude, a warehouse, or no provider at all. The screen that starts the game should not know which vendor needs the event, which SDK works on the current runtime, or what happens when a request fails. It only knows that a game started.
That distinction becomes concrete after launch. A team may want path analysis and retention cohorts in a second tool, a browser transport while native keeps Firebase, or a way to disable one destination after a privacy or quota decision. A provider can also become slow or unavailable during a checkout, ad flow, or game start. A new surface may need events without learning three SDK APIs.
With vendor calls in product code, each request becomes a cross-cutting rewrite: event names, parameter mapping, initialization lifecycle, platform guards, error behavior, and test doubles spread across the app. With an analytics layer, those decisions remain in one place. Product code changes once, where the fact is emitted; delivery changes independently.
This is not an argument for collecting the same data everywhere by default. It is an argument for making that choice reversible. A provider can be added, paused, or removed from configuration while the product contract stays stable. The application owns event semantics; integrations remain replaceable infrastructure.
What a multi-provider layer changes in operations
The visible result is fan-out. The operational result is a smaller blast radius. When a provider is an adapter behind one dispatcher, a failed request can be logged and isolated. It does not turn a product action into a failed action. A player should still start a game if an analytics endpoint is unavailable. A visitor should still reach the store if a landing CTA cannot flush an event. Analytics is observational; it must not become a dependency of gameplay, navigation, purchase, or acquisition.
Independent configuration also turns rollout from a code deployment into a controlled operational decision. A build can contain an adapter but keep it disabled. The team can validate generated production configuration, enable one destination, observe its ingestion and errors, then decide whether it stays. The fallback is not an emergency rewrite that removes calls from the app. It is a provider flag that preserves the contract and leaves other destinations running.
The same boundary improves testing. Product tests assert that game_started was emitted with expected properties without starting network SDKs. Adapter tests assert that Firebase, GA4, or Amplitude payloads are correctly derived from that fact. Integration smokes validate real credentials and ingestion. These tests answer different questions and should not be collapsed into a single brittle end-to-end check.
It also improves the handoff between engineering and product. Engineering can state which facts exist and when they fire. Product can decide which questions matter, which provider is useful for an analysis, and which properties are allowed. Neither side has to infer behavior from SDK calls scattered through screens.
Start with one event contract
The app already sent events through a shared analytics package. Screens, game starts, finishes, settings, and a few non-personal user properties all used the same public methods:
trackEvent(event);
logScreenView(screen);
setUserProperty(property);
That contract should not branch in game code based on Platform.OS. The caller describes a product fact such as game_started; the analytics package chooses the delivery mechanism.
Before this change, the package had one provider field:
analytics:
enabled: true
provider: firebase
That configuration was ambiguous once Web became a real destination. The word firebase was accurate on native, but the browser implementation could use GA4’s JavaScript tag or Amplitude’s Browser SDK. Adding a field called webMeasurementId would make the next provider migration harder: an Amplitude key is not a GA4 Measurement ID.
The final config separates the runtime, provider, and provider-specific key:
analytics:
enabled: true
native:
providers:
- provider: firebase
enabled: true
- provider: amplitude
enabled: false
apiKey: AMPLITUDE_PUBLIC_API_KEY
web:
providers:
- provider: ga4
enabled: true
measurementId: G-XXXXXXXXXX
pageViews: false
- provider: amplitude
enabled: false
apiKey: AMPLITUDE_PUBLIC_API_KEY
Each provider owns its key and enabled flag. Amplitude can therefore be enabled or disabled without removing Firebase or GA4:
web:
providers:
- provider: amplitude
enabled: true
apiKey: your-public-api-key
The same shape supports a second destination without baking GA4 terminology into the shared contract.
Keep native and Web bundles on their own runtimes
React Native resolves platform filenames before normal filenames. We used that instead of importing react-native and branching at every event call. The dispatcher then sends each call to all enabled providers and isolates failures.
The Web resolver reads config.web.providers. Its native counterpart reads config.native.providers.
// providers.native.ts
const providers: Record<string, AnalyticsProvider> = {
none: noopAnalyticsProvider,
mock: noopAnalyticsProvider,
firebase: createFirebaseProvider(),
amplitude: createAmplitudeNativeProvider(),
};
function trackEvent(config: AnalyticsConfig, event: AnalyticsEvent) {
return dispatchAnalytics(config, config.native.providers, providers, (provider) =>
provider.trackEvent(event, config),
);
}
The native file imports firebase.native.ts, where @react-native-firebase/analytics is used. The Web bundle never resolves that file. Its normal resolver imports the browser providers instead:
const providers: Record<string, AnalyticsProvider> = {
none: noopAnalyticsProvider,
mock: noopAnalyticsProvider,
ga4: createGa4Provider(),
amplitude: createAmplitudeBrowserProvider(),
};
function trackEvent(config: AnalyticsConfig, event: AnalyticsEvent) {
return dispatchAnalytics(config, config.web.providers, providers, (provider) =>
provider.trackEvent(event, config),
);
}
This matters beyond code style. Importing the native SDK from a Web module can fail during bundling, pull unnecessary dependencies into the browser output, or leave a provider that silently does nothing. Separate platform entry points make the runtime dependency visible in the file system. The dispatcher also means an unavailable Amplitude endpoint does not stop Firebase or GA4.
We added focused tests for each resolver, plus fan-out, provider failures, disabled destinations, and the lazy activation gate. That proves the split is not only a TypeScript shape.
Choose providers for different jobs, not competing truth
Running Firebase/GA4 and Amplitude together is not a vote on which dashboard is “correct.” They answer overlapping questions through different data models, reports, session definitions, processing delays, and exploration tools. Treating one provider as an automatic replica of another creates a false release criterion: equal totals that may never exist, even when both integrations are healthy.
Firebase and GA4 retain continuity for existing native reports, acquisition context, and platform-specific tooling. A GA4 Web stream lets Expo Web participate in the same product property without pretending that browser events are native Firebase events. Amplitude provides a separate product-analysis surface for paths, cohorts, retention, and funnels. The useful result is not three sources of truth. It is one event contract with multiple lenses.
That distinction affects how teams discuss discrepancies. A count difference can be a real implementation bug, but it can also be expected behavior: a provider may batch later, apply a different session boundary, omit an event because consent or configuration differs, or show delayed processing. Start with the contract and realtime evidence for a known smoke flow. Only then compare aggregates, with provider definitions visible.
The same discipline keeps landing analytics useful. Acquisition traffic answers where a visitor came from and whether a store CTA was used. Product analytics answers what happened after someone entered the app. Joining those questions requires deliberate attribution design, not reuse of a client identifier because it is convenient.
Create a Web stream in the app property
Firebase already connected the native app to an existing GA4 property. A browser tag cannot send to the Android or iOS identifiers. It needs a Web Measurement ID in the form G-....
The right setup is a Web stream inside the existing app property, with Amplitude configured as a separate product destination:
GA4 property: your app
├── Android stream -> Firebase SDK
├── iOS stream -> Firebase SDK
└── Web stream -> gtag.js from Expo Web
Amplitude project -> Browser SDK from Expo Web and native SDKs
This does not create a second product property or require rewriting existing reports. The streams identify the source platform; the property remains the place where native and browser gameplay can be analyzed together.
The landing page stays separate. Its GA4 property measures acquisition: page views, referrers, campaigns, and store CTA clicks. The app stream measures gameplay. Reusing the landing Measurement ID would create a technically valid but analytically confusing dataset.
The URL requested while creating a Web stream is useful metadata, not an allowlist. A later deployment on another domain can still send data with the same Measurement ID. Keep the ID in production configuration when your repository treats public client IDs as versioned configuration.
Initialize Web analytics without blocking first paint
Each adapter first checks whether its provider is enabled and has a valid key. The Browser SDKs are not initialized by the first React effect. Events, manual screen views, user properties, and initialization calls enter a shared gate and wait for the first pointer/keyboard/scroll interaction, a hidden document, or a six-second deadline. isConfiguredWebProvider also keeps empty keys and placeholders out of the rendered site configuration.
After activation, the GA4 adapter creates the normal dataLayer queue and configures GA4:
globalThis.gtag("js", new Date());
globalThis.gtag("config", measurementId, { send_page_view: false });
send_page_view: false is intentional. Expo Web is a single-page application, and the app already sends screen_view from React Navigation. Letting GA4 generate an automatic page view while the navigator sends a manual screen view creates two competing navigation signals.
The tag script is delayed until one of three moments:
- the first pointer, keyboard, or scroll interaction;
- six seconds after the document load event;
- the page becomes hidden.
The shared gate has the same purpose for Amplitude: preserve early calls without making the first React effect initialize an external SDK. A CTA can call flush during pagehide; the flush explicitly activates the gate, drains queued operations, and then asks the providers to flush. The CTA itself does not call preventDefault or await analytics before navigation.
Keep non-production telemetry out of the real property
The three configuration files share the same structure, but not the same values:
# config.development.yml and config.test.yml
analytics:
enabled: true
native:
providers:
- provider: mock
enabled: true
web:
providers:
- provider: mock
enabled: true
This is more reliable than relying on a missing object field. A developer can inspect any environment and see the full contract. Development and tests keep calling the same analytics API, but the mock provider prevents local sessions, browser tests, and test fixtures from reaching GA4.
Production keeps each provider explicitly enabled or disabled. In this implementation, Amplitude was turned on only after its public ingestion key, privacy disclosure, real collection smoke, and store data-safety review were complete. The typed config generator checks the provider array, so a stale analytics.provider field fails typechecking instead of silently changing runtime behavior.
Validate the production artifact, not only the source
One trap appeared during validation: exporting EXPO_PUBLIC_APP_ENV=production for config generation does not automatically apply it to the next shell command.
This is wrong when expo export runs after &&:
EXPO_PUBLIC_APP_ENV=production pnpm --filter <your-app> config:generate && \
pnpm --filter <your-app> exec expo export --platform web
The second command can run app.config.js with the default development environment and bundle the mock provider. Apply the environment to both commands:
EXPO_PUBLIC_APP_ENV=production pnpm --filter <your-app> config:generate && \
EXPO_PUBLIC_APP_ENV=production pnpm --filter <your-app> exec expo export --platform web
We then searched the exported JavaScript bundle for the Web stream ID and verified that it did not contain the landing-page ID. That check caught the environment mistake before deployment.
The next operational validation is external: open the production Web app, navigate to a screen, start a game, and inspect Realtime or DebugView in the app GA4 property. The bundle can prove that the right tag and ID are present; it cannot prove that a deployed browser session reached Google’s ingestion pipeline.
For native validation, the earlier guide on Firebase DebugView in a React Native release build covers the Firebase side of the same property. Amplitude needs its own real-credential smoke. The same event must be visible in both destinations before the provider is enabled for production users.
Turn on a second destination only after a real smoke
The code path is not the rollout. Before enabling Amplitude in production, we verified the same product flow on Android, iOS, Expo Web, and the public landing. Each surface sent a small, observable sequence such as bootstrap, screen navigation, a product action, and, where available, attribution. The point was to catch the boundary failures that unit tests do not see: a wrong public key, a provider excluded from the generated configuration, a browser bundle importing the wrong implementation, or a deployment still serving an older artifact.
The acceptance rule was deliberately narrower than matching dashboards. Analytics providers process events at different speeds, apply different session rules, and expose data through different reports. Equal totals are not a valid release gate. A useful smoke asks four questions instead:
- Did the app remain usable while each provider initialized or failed?
- Did the expected event and non-sensitive properties leave the current runtime?
- Did each provider accept the event in its own realtime surface or ingestion response?
- Can the team identify the build and runtime that produced the evidence?
For the Web and landing paths, successful network responses established that the browser SDKs accepted the event batch. Realtime views then confirmed arrival. For native, a release-like build is more useful than a debug build because it exercises generated configuration, native SDK wiring, and the same startup path users receive. A store-distributed install still deserves its own short smoke: it removes the last difference between a locally signed artifact and the package delivered by the store.
Do not turn an ingestion check into a product conclusion. Early release traffic can prove that a version is distributed and attribution fields are populated. It cannot support a retention, acquisition, advertising, or purchase decision until there is enough behavior to analyze.
Keep privacy, attribution, and product analytics separate
Adding another destination changes the data flow, so it also changes release work. The relevant privacy notice and store disclosure must describe the active analytics providers and the categories of data they receive. That review is part of the rollout, not a document to catch up later.
It does not follow that analytics should reuse an ads-consent prompt. In this architecture, ad consent remains owned by the ads stack. Product analytics does not display a second consent gate merely because Amplitude is active. The correct rule depends on the product’s legal basis and jurisdictions, but the runtime ownership should remain explicit instead of treating every SDK as interchangeable.
Install Referrer is another boundary worth keeping narrow. An Android app can record a sanitized attribution result such as an attributed source, medium, campaign, and content. It should not forward the raw referrer string as product telemetry, and it does not replace a mobile measurement partner. The event answers a modest question: which store-provided campaign values reached this installation? It does not prove that every reinstall is a new acquisition or that a landing click caused a later install.
The architecture keeps future options cheap, not free
An analytics layer does not remove the work of defining events. It makes the work visible. A vague event name remains vague in every provider. A property that is not safe to collect remains unsafe after it passes through an adapter. A funnel cannot be trusted because it appears in a polished dashboard. The layer protects the boundary; it does not invent product semantics or governance.
It also does not mean every new provider deserves production access. Each destination adds SDK weight, data flow, privacy obligations, operational cost, and another place where an event can be misread. The correct default is a small provider list, a bounded event contract, and an explicit reason for each integration.
What the structure does buy is optionality under those constraints. A future warehouse export can consume the same domain events. A new platform can get a dedicated adapter instead of copying native SDK code. A provider migration can run in parallel long enough to validate the replacement. A temporary outage can reduce observability without changing what the user can do. Those are not promises of free analytics. They are ways to avoid turning an analytics decision into a product rewrite.
What the rollout proved and what it did not
The published release established that the product could keep Firebase/GA4 and Amplitude active in parallel without changing its domain event names, blocking gameplay, or merging landing traffic with product traffic. Public-store propagation and first-open events confirmed that users had received the new app version. The public sites also delivered their GA4 and Amplitude events without console errors.
One limitation remains useful to state plainly. A provider’s aggregate dashboard is not evidence that the exact same store-installed session reached every destination. That last correlation requires a device available for a short store-install smoke. Leaving it as a tracked follow-up is more honest than inferring it from separate aggregate reports.
Checklist
- Keep a single typed product-event contract.
- Resolve providers with
.native.tsand Web files, not checks scattered through app code. - Configure each provider independently so a second destination can be enabled without replacing the first.
- Create a GA4 Web stream inside the app property.
- Do not reuse a landing-page Measurement ID for gameplay.
- Keep the Browser SDK behind a shared activation gate; user properties at boot must not initialize it.
- Use
send_page_view: falsewhen React Navigation owns manualscreen_viewevents. - Keep development and test on
mockwith an empty Web key. - Keep automated tests on
mock; label intentional manual development telemetry as QA traffic instead of letting it look like production use. - Export with the production environment applied to every build command.
- Inspect the final bundle and then validate a real browser session in GA4.
- Validate Amplitude and Firebase/GA4 independently in realtime; do not require equal provider totals.
- Review privacy disclosures and store data-safety information before enabling a new production destination.
Firebase, GA4, and Amplitude are not competing event contracts. They are runtime-specific transports for the same product events. Keeping the provider list, activation gate, module resolution, and release evidence explicit lets an app add destinations without changing Android or iOS behavior or making the first Web render pay the SDK cost. More importantly, it keeps analytics as infrastructure that can evolve while the product keeps its own language.
Related postHow to validate Firebase DebugView events in a React Native release build9 min readWritten by: Jonathan Reis on