react-native-release-health: catching a bad OTA update before your users do
· 5 min read · Amrith Vengalath
OTA updates ship JavaScript straight to production. When one of them is broken, nothing in the default stack notices: the update applies, the app crashes on launch, the user relaunches, and it crashes again. Your crash reporter sees a spike but cannot tell you which update caused it or whether the fix is to roll back. Your OTA dashboard shows the download count going up and nothing else. The delivery pipelines are excellent; the feedback loop about whether a rollout actually works on devices is missing.
I built react-native-release-health to close that loop on the device, per update id, working with your OTA vendor rather than against it.
What actually closes the loop
The idea is a probation window, not a health check endpoint. Every launch is tagged with the active update id and native build. A freshly applied update goes on probation, and your app calls markHealthy() once its first screen is genuinely interactive, not merely rendered. No markHealthy() within the timeout makes the update suspect. A crash or an abnormal exit followed by a relaunch counts against it, and hitting the crash-loop threshold makes it failed. A failed update fires rollback_recommended, and rollback_executed too, when the adapter supports client rollback and you opted in with autoRollback.
ReleaseHealth.init({
adapter: expoUpdatesAdapter(),
sinks: [httpSink({ url: 'https://telemetry.example.com/release-health' })],
healthyTimeoutMs: 15000, // markHealthy() deadline after a fresh update
crashLoopThreshold: 2, // failed launches before rollback is recommended
});
// Call this once your first screen is genuinely interactive.
ReleaseHealth.markHealthy();Placement of that one call is load-bearing. Call it unconditionally at mount and a broken-but-rendering update gets accepted anyway. Never call it on some code path and a perfectly healthy update sits at suspect until the next relaunch.
How do you detect a crash without a crash SDK?
You do not need one, you just need to know the app did not exit cleanly. The native module records a clean-exit flag on graceful background or terminate, so a launch that follows neither is treated as an abnormal exit. Fatal JS errors are additionally caught in-session through the global error handler. The engine counts consecutive failed probation launches and judges at launch start, so the crash-loop verdict lands even when the crash itself killed the process before anything could be sent.
This is a heuristic, and I want to be upfront about what it is and is not. It detects "the app did not exit cleanly", not "the app crashed at this stack trace". Force-quits and OS memory kills read as abnormal exits too, so during probation one unlucky force-quit counts against the update exactly like a crash would. There are no stack traces, no symbolication, nothing to attach to an issue tracker on its own. Pair it with a crash reporter for ground truth. That is exactly what the Sentry sink is for: it tags every Sentry issue with the active update id and health status, so a crash captured while a fresh update is still on probation carries that context, and the two systems tell one story instead of two.
The event stream
Every sink receives the same typed events, each carrying a session id and a timestamp:
| Event | Fired when |
|---|---|
session_start | Every launch, with update id, native version, build number, platform, cohort |
update_downloaded | The adapter reports a new update ready to apply |
update_apply_success | A probation update was marked healthy |
update_apply_failed | Probation failed (crash loop or timeout chain), with a reason |
crash | A fatal JS error was caught in this session |
healthy | markHealthy() accepted, with time-to-healthy |
rollback_recommended | An update was judged failed, with crash-loop or apply-failed |
rollback_executed | autoRollback ran the adapter's rollback(), with a success flag |
Adapters and sinks, not a monolith
Everything vendor-specific lives behind two small interfaces, so the engine itself never has an opinion about expo-updates versus hot-updater versus your own update server.
An adapter tells the engine the active update id and streams download/error events, with an optional rollback(). expo-updates has no client-side rollback API at all, so that adapter is recommendation-only by design, and rollback happens server-side via eas update:roll-back-to-embedded. hot-updater does expose a client rollback path, so that adapter implements rollback() and supports autoRollback, reverting to the embedded bundle when a crash loop is detected.
A sink just receives the event stream: the HTTP sink batches and posts it to any endpoint you control, and the Sentry sink turns it into tags and breadcrumbs on your existing crash reporter. Both are peer-based and duck-typed, so neither pulls a vendor SDK into an app that does not already use it, and a misbehaving sink cannot take the host app down.
Client rollback saves the device, not the fleet
With autoRollback: true on the hot-updater adapter, the device reverts to the embedded bundle with no server involvement, and the next launch is stable. But as long as the update server keeps serving the bad bundle, the device re-downloads it at the next check and the cycle repeats: an oscillation between the embedded bundle and the poison. Client rollback buys each device a working session. The fleet-level fix is always server-side: disable or roll back the release at the source. I treat rollback_executed as first aid and rollback_recommended as the page that makes a human disable the release.
Where it fits
| Delivers updates | On-device health verdict | Rollback loop | Vendor-neutral | |
|---|---|---|---|---|
| EAS Update / expo-updates | yes | no | server-side, manual | no |
| hot-updater | yes | first-launch native crash guard | server-side console | no |
| Sentry release health | no | crash-free session rates, per release | no | yes |
| react-native-release-health | no, on purpose | probation, crash-loop, per update id | recommends; executes where the vendor allows | yes |
The delivery vendors are good at delivery, and Sentry is good at crash truth. This library is the piece in between: the on-device judgment of whether the rollout you just shipped is actually healthy, expressed per update id, with a rollback recommendation you can automate.
It ships as five packages: the core engine, an adapter each for expo-updates and hot-updater, and a sink each for a plain HTTP endpoint and Sentry. Full docs, the API reference, and the honest limitations list are at vengalath.com/npm/react-native-release-health.