You cannot fix what you cannot see
We get called into a lot of apps that are already in production. The first question we ask is never about the code — it is "how did you find out about the last bad release?" The answer is usually one of three things: a one-star review, a support ticket, or a stakeholder's phone. None of those is observability.
Every other tutorial on this site is about shipping something well. This one is about knowing whether it worked. The setup below takes roughly a day to install on an existing React Native app and it pays for itself the first time a release goes sideways.
Layer 1: Crash and error reporting that produces readable stack traces
A JavaScript error thrown inside a Hermes bundle symbolicates to nothing useful unless you have uploaded source maps for that exact build. This is the single most common failure we see: teams have a crash reporter installed, they have hundreds of grouped errors, and every frame reads index.android.bundle:1:284915.
The rules that make traces readable:
- Upload source maps as part of the build, never by hand. In an EAS or CI pipeline the upload step runs after the bundle step, in the same job, using the same commit. A map uploaded from a developer laptop rarely matches the binary users are running.
- Pin the release identifier. Whatever tool you use (Sentry is our default; Crashlytics, Bugsnag and Datadog all work the same way conceptually), the bundle, the map, and the reported events must agree on one release string. We derive it from the app version plus the build number, and — critically — the OTA update ID when one is applied.
- Symbolicate the native side too. iOS dSYMs and Android mapping/native symbol files are separate uploads from your JS source maps. A New Architecture app crashing in C++ inside a Turbo Module gives you nothing without them.
- Strip source maps from the shipped bundle. Upload them to your error tracker; do not ship them inside the app.
Sanity check before you call it done: throw a deliberate error from a hidden debug screen in a release build on a real device, and confirm the trace that lands in the dashboard names your file, function, and line. If it does not, nothing downstream in this post is trustworthy.
Layer 2: Tie crash-free rate to each release, including OTA updates
Crash-free sessions is the one number product and engineering can share. It only works if the release dimension is right.
React Native teams shipping over-the-air updates have a problem the rest of the mobile world does not: one binary version can be running several different JavaScript bundles. If your reporting groups everything under 1.8.0, a bad OTA payload looks exactly like a healthy one.
What we do:
- Report the native version, build number, and update ID as release and tag values on every event.
- Watch crash-free rate per update ID for the first hour of a staged rollout, not per app version.
- Define the rollback trigger in advance and write it down: for example, "crash-free sessions below 99.5% or error count 3x baseline over 30 minutes on the new update ID means roll back." A rollback rule you invent during an incident is a negotiation, not a rule.
- Keep the previous good update available so rollback is a republish, not a store submission. (We covered the release pipeline mechanics in the OTA updates tutorial; this is the monitoring half of it.)
Layer 3: Performance tracing on the flows that make money
Do not trace everything. Sampling everything is expensive, noisy, and gets switched off within a month. Pick the three or four flows the business cares about — launch, sign-in, search, checkout — and instrument those end to end.
Useful spans for a React Native app:
- App start, broken into native init, JS bundle load, first render, and time-to-interactive.
- Screen transitions for the key flows: navigation event to first meaningful paint.
- Network calls with the route name (never the full URL with IDs in it) so slow endpoints group.
- Custom spans around anything you know is expensive: database migration on first launch, image processing, model inference.
Set the sample rate low in production (a few percent is usually plenty for trend detection) and always report p75 and p95, never averages. Averages hide the mid-range Android device that takes eleven seconds to open your app.
Layer 4: A cold-start budget you can defend
Startup time is the performance metric users notice first and teams measure last. Give it a number.
Our default budget on a mid-range Android device, cold start, release build:
| Phase | Budget |
|---|---|
| Native process + RN init | under 400ms |
| JS bundle load and eval | under 500ms |
| First screen rendered | under 1.2s cumulative |
| Time to interactive | under 2s cumulative |
Those numbers are a starting point, not a law — tune them to your device floor and then hold the line in CI. The point is that "the app feels slow to open" becomes a regression against a threshold instead of an opinion.
When a budget is blown, the causes are boringly consistent:
- Work happening at module scope, so it runs during bundle evaluation whether the user needs it or not.
- Every analytics, feature-flag, and SDK initialiser awaited serially before the first render.
- Fonts, large JSON blobs, or migrations loaded eagerly on the splash screen.
- No lazy loading: the whole navigation tree and every screen's dependencies pulled in at launch.
- An OTA update check blocking launch instead of running in the background and applying on next start.
Fixes are usually deferral, not cleverness: initialise SDKs after first paint, lazy-require heavy screens, move migrations off the launch path, and let the update check be non-blocking.
What to review weekly
Once the four layers are in place, the operating routine is short. Every week, someone looks at:
- Crash-free sessions and crash-free users, per platform, versus last week.
- Top five error groups by affected users — not by event count, which is dominated by one looping device.
- p75 and p95 app-start and key-screen timings on your slowest supported tier.
- ANRs on Android and hangs on iOS, which often show up long before the crash rate moves.
- Whether the last OTA rollout met the rollback threshold you wrote down.
Privacy, because this is user data
Observability payloads are personal data. Scrub tokens and auth headers before send, keep PII out of breadcrumbs and span names, avoid capturing full request bodies by default, and make sure whatever you collect matches the privacy manifest and store disclosures you filed. A monitoring SDK that quietly ships user content off-device is a compliance problem wearing a performance costume.
The short version
Readable stack traces, crash-free rate keyed to the exact JS bundle in the field, tracing on the flows that matter, and a written cold-start budget. That is the whole setup, and it is the difference between finding out about a bad release in thirty minutes and finding out in three days.
If you would like a second pair of eyes on an existing app's release health — or you need this installed properly before a big launch — get in touch. Our React Native consultants do this as a fixed-scope engagement all the time.