← cd ..

Getting a React Native app to 99.999% crash-free

November 2, 2026·7 min read#react-native#mobile#performance

Our React Native app sits at 99.999% crash-free on iOS and 99.8% on Android. Those numbers look adjacent. They aren't. 99.8% means roughly one session in five hundred ends badly; 99.999% means one in a hundred thousand. The distance between them is most of the work, and almost none of it is the kind of work that looks impressive in a sprint review.

What follows is what actually moved the number, in the order it mattered.

First, know which number you're quoting

Crash reporting tools give you two metrics and people quote whichever is flattering:

  • Crash-free users — the share of users who didn't experience a crash.
  • Crash-free sessions — the share of sessions that didn't end in one.

Sessions is the honest one, and it's the one worth gating on. Crash-free users flattens repeat failure: a cohort stuck in a crash loop counts once each, however many times it happens to them. Sessions counts every bad session, which is much closer to the experience those people actually had.

Pick one, write it down, and make sure everyone quoting the number in a review means the same thing by it. I've sat in meetings where two people disagreed about whether stability was improving because they were reading different rows.

Triage by users affected, not event count

The default view in every crash tool is sorted by event count, and that ranking lies. A crash loop in a retry handler can generate thousands of events from a few dozen users and bury a crash that silently affects ten thousand people once each.

Sort by users affected. In practice the distribution is brutally top-heavy — a handful of clusters account for the large majority of your loss. That's the good news, because it means the first week of serious triage buys you most of the available improvement.

The trap at this stage is the symptom patch. Someone finds the stack, wraps the offending call in a try/catch, and the cluster disappears from the dashboard. The crash is gone; the bug isn't. You've converted a loud failure into a silent one — now the screen renders empty, the user retries, and you've traded a crash you could see for a support ticket you can't. If you can't explain why the value was null, you haven't fixed it yet.

Catch what error boundaries miss

This is where most React Native apps leak crashes, because people assume an error boundary is a safety net and it is a very specific, very small net.

React error boundaries catch errors thrown during render, lifecycle methods, and constructors of the tree beneath them. They do not catch:

  • errors in event handlers
  • anything asynchronous — setTimeout, promises, async functions
  • errors thrown in the boundary itself
  • native crashes, which never touch JavaScript at all

So a boundary per screen is worth having, but it's the floor:

class ScreenBoundary extends React.Component {
  state = { failed: false };
 
  static getDerivedStateFromError() {
    return { failed: true };
  }
 
  componentDidCatch(error, info) {
    crashlytics().log(info.componentStack);
    crashlytics().recordError(error);
  }
 
  retry = () => {
    this.setState({ failed: false }); // the boundary must clear its own state
    this.props.onRetry?.();
  };
 
  render() {
    return this.state.failed ? <Fallback onRetry={this.retry} /> : this.props.children;
  }
}

For everything the boundary can't see, you need the global hooks. React Native exposes ErrorUtils for uncaught JS exceptions:

const defaultHandler = ErrorUtils.getGlobalHandler();
 
ErrorUtils.setGlobalHandler((error, isFatal) => {
  crashlytics().recordError(error);
  defaultHandler(error, isFatal); // keep the default behaviour
});

Be precise about what that buys you, since this post is otherwise about metric honesty: recordError files a non-fatal, and non-fatals don't count toward crash-free sessions — they land in their own view. Calling defaultHandler is what preserves the fatal report that does move the number. The global handler buys visibility; the boundary above it is what actually prevents a crash.

And unhandled promise rejections — the single most common source of invisible breakage I've found — aren't tracked in release builds at all. React Native enables rejection tracking only in dev, where LogBox surfaces the warnings, so production needs you to turn it on:

const pending = new Map();
 
require("promise/setimmediate/rejection-tracking").enable({
  allRejections: true,
  onUnhandled: (id, error) => {
    // "unhandled" is provisional — a handler can still attach a tick later.
    pending.set(id, setTimeout(() => crashlytics().recordError(error), 2000));
  },
  onHandled: (id) => {
    clearTimeout(pending.get(id));
    pending.delete(id);
  },
});

onHandled isn't optional here. With allRejections: true a rejection is flagged the moment nothing is attached to it, and a handler attached on the next tick retracts that. Without the pairing you'll report rejections that were handled perfectly well — the id exists precisely so you can match the two callbacks.

Native crashes are a separate pipeline entirely, reported by the native SDK. Treat JS errors and native crashes as two different queues with two different owners, because the debugging skills barely overlap.

Without symbolication you are guessing

Hermes ships your JavaScript as precompiled bytecode, so an unsymbolicated trace is a stack of bundle offsets — at d (address at index.android.bundle:1:418022), every frame pointing at line 1 of one minified file. Sometimes a function name survives. The real file and line never do. You will stare at it, infer nothing, and move on to an easier ticket.

Upload the source map as part of every release build, automatically, in CI. Not "when we remember." A release that ships without its source map is a release whose crashes you cannot triage, and you will not notice until something breaks and the trace is unreadable.

This is boring infrastructure and it is the single highest-leverage thing on this list. Everything else assumes you can read a stack.

Android is a different problem, not a harder version of the same one

iOS reached 99.999%. Android sat at 99.8% and the remaining gap is mostly structural:

  • Fragmentation. Thousands of device models, vendor-modified ROMs, and OEM power-management layers that kill background work in ways the platform docs don't describe. Some crashes only ever reproduce on one manufacturer's build.
  • ANRs. If the main thread can't service a user input event for about five seconds, Android files an Application Not Responding. That five-second figure is the input-dispatch timeout specifically — it needs someone actually touching the screen; broadcast receivers and service starts have their own, longer budgets. An ANR isn't technically a crash and often won't appear in your crash-free number — but to the user it's identical to one, and Play Console counts it against you. Track ANRs as a separate metric or you'll congratulate yourself on a stability win users didn't feel.
  • Memory. Low-end devices under pressure get killed by the OS, frequently while backgrounded. These often surface as "background kills" rather than crashes, which means your dashboard looks fine while sessions quietly die. Oversized image bitmaps are the usual culprit.

In my experience chasing Android's last 0.2% is the worst return on engineering hours available to you — the structural reasons above are exactly why the ceiling sits where it does. Know where it is and spend deliberately.

Gate the release, not just the code

Everything above is reactive. The thing that stops the number sliding backwards is making a bad release unable to reach everyone.

Ship through a staged rollout — a small percentage first — and define, in advance, the crash-rate threshold that halts it. Write the number down before you need it, because the conversation at 11pm with a regression live is not the moment to decide what "too many crashes" means.

Then make release health a visible, owned metric. Not a dashboard someone opens after an incident: a number reviewed on a cadence, with a name attached, the same way you treat uptime.

The number is a byproduct

None of this is clever. It's symbolication that always works, triage sorted by the right column, boundaries plus global handlers so nothing fails silently, honesty about which platform's ceiling you're fighting, and a rollout gate that can stop a bad build.

99.999% isn't a target you aim at directly. It's what's left over when you stop letting failures go unseen.