Mobile — Mobile App Architecture
Error Boundaries and Crash Recovery in React Native
Direct answer
Error boundaries catch exceptions thrown during React rendering and lifecycle methods, letting you swap a crashed screen for a recovery UI — but they do not catch async errors, event-handler errors, or native crashes. In React Native the production setup is layered: a boundary per navigation screen wired to crash reporting, a global JavaScript exception handler as the last JS-side net, and native crash tooling for everything below the bridge, plus recovery UX that resets the failed state instead of trapping users in a crash loop.
The difference between an app that feels stable and one that gets deleted is rarely the crash count — it is what the user experiences at the moment of failure. React Native gives you several distinct failure domains, and each needs its own net. Here is the crash-handling architecture I ship.
Key facts, with sources
- React Native's New Architecture became the default in 0.76, the legacy bridge was retired in 0.82, and Hermes V1 shipped as the default JavaScript engine in 0.84. (TO THE NEW Blog)
- Microsoft retired Visual Studio App Center and CodePush on March 31, 2025, forcing every team that depended on it to migrate their over-the-air update architecture. (microsoft/react-native-code-push GitHub issue)
- In the State of React Native 2024 survey, Redux drew the most negative feedback at around 18% dissatisfaction, while React's built-in state management (31% positive) and Zustand (21% positive) were the best regarded. (InfoQ)
- Over 80% of State of React Native 2024 respondents work in teams of up to five developers, meaning most mobile architectures must be maintainable by very small teams. (SSOJet (State of React Native 2024 highlights))
- Published production examples report Shopify at 86% unified code across its apps and Instagram sharing 85 to 99% of code between iOS and Android. (CatDoes)
Know exactly what error boundaries catch
An error boundary is a class component that catches exceptions thrown during rendering, in lifecycle methods, and in constructors of its child tree. That definition excludes most of what actually fails in a production app: rejected promises, errors inside event handlers, setTimeout callbacks, and anything that happens in native code. A boundary will not save you from a failed API call — that is your data layer's job — nor from a native module segfault.
What boundaries are for is the render-time failure class: the unexpected null that crashes a component tree, the malformed cache entry, the third-party component that throws on odd input. Without a boundary, one throwing component takes down the entire app to a white screen or a hard crash. With one, the blast radius shrinks to a section of UI you control. Scoping your expectations correctly is the first step; teams that think boundaries are total crash protection ship the other layers too late.
Place boundaries at navigation screen level
Granularity is a design decision. One app-level boundary means any render error anywhere blanks the whole app — technically recovered, practically still a crash. Per-component boundaries are noise. The sweet spot in every app I have shipped is per navigation screen: a crashed screen shows a contained fallback while tabs, navigation, and every other screen keep working, and the user's escape hatch — going back — is still alive.
I wrap screens automatically at registration time rather than trusting each developer to remember, so coverage is structural. Inside the boundary, the fallback offers a retry that remounts the screen, and the caught error goes to crash reporting with the screen name attached.
import React from 'react';
import { View, Text, Button } from 'react-native';
type Props = { screenName: string; children: React.ReactNode };
type State = { error: Error | null };
export class ScreenErrorBoundary extends React.Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
reportError(error, { screen: this.props.screenName, stack: info.componentStack });
}
render() {
if (this.state.error) {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<Text>Something went wrong on this screen.</Text>
<Button title="Try again" onPress={() => this.setState({ error: null })} />
</View>
);
}
return this.props.children;
}
}The layers boundaries cannot reach
Below the boundary layer sit three more nets. React Native exposes a global JavaScript exception handler; I wrap the default one to report fatal JS errors and show a branded full-app recovery screen instead of the platform's crash behavior — while still deferring to the default handler in development so the red box keeps working. Unhandled promise rejections need their own tracking hook, because they are silent by default and hide real defects, especially fire-and-forget mutations.
Native crashes — memory pressure, native module bugs, startup failures before JavaScript loads — never touch any JS handler, so a native crash reporter initialized in native code is non-negotiable for production. When I evaluate an app's stability story, the first thing I check is whether the team can even see native crash rates separately from JS errors; conflating them makes both impossible to prioritize.
Recovery that does not loop
The most damaging failure pattern is the crash loop: bad state gets persisted, the app crashes on startup, relaunch reads the same state, and the user is permanently locked out — uninstall is their only fix. Defend against it explicitly. Validate persisted state when hydrating stores and treat parse failures as a signal to discard that slice, not to crash. Version your persisted schemas and migrate or reset on mismatch.
For the worst case, keep a crash counter: if the app records repeated fatal crashes within a short window of launch, next startup enters a safe mode that clears caches and non-essential persisted state before rendering. A user losing preferences is a mild annoyance; a user locked out of a working app is a one-star review and a support ticket. The retry button in every fallback should also do more than remount — resetting the query cache for that screen clears the corrupt data that usually caused the throw in the first place.
Test the failure paths like features
Crash handling is code that only runs on your worst day, which means untested recovery paths are usually broken exactly when needed. I keep a hidden developer screen that throws on demand — render error, async rejection, native crash — so any build can demonstrate each net catching its failure class. Unit tests assert that boundaries render fallbacks and that reporting is called with the right context; a smoke test covers the safe-mode startup path.
Equally important is triaging what the nets catch. Boundary hits should page nobody but must feed a dashboard segmented by screen, because a spike on one screen after a release is your fastest regression signal — often faster than store reviews or support tickets. In practice, teams that treat caught errors as telemetry rather than noise fix the underlying defects; teams that only celebrate the crash-free rate let boundaries quietly mask a growing pile of broken screens.
When to hire senior help
Architecture is the cheapest place to buy senior expertise, because decisions about state management, navigation, offline strategy, and update infrastructure made in week one determine costs for years. A short engagement with a senior mobile architect before or during MVP planning routinely prevents the rewrite-at-scale scenario that hits teams around their first major growth phase. If your stack includes React Native + Python + AI, a senior engineer who owns the full product beats coordinating multiple juniors.
Bottom line
Dhairya Senjaliya ships Mobile — Mobile App Architecture projects worldwide — book a scoping call to discuss your specific situation.
Common pitfalls to avoid
- ✕Adopting Redux with sagas and heavy boilerplate for a five-screen MVP when built-in React state or Zustand would cover the actual requirements
- ✕Scattering business logic inside UI components instead of isolating a data layer, making later backend changes or native module swaps expensive
- ✕Building deployment architecture on a hosted OTA service with no exit plan, a risk the March 2025 CodePush shutdown made concrete for thousands of teams
- ✕Assuming permanent connectivity and bolting on caching later, instead of designing offline storage and sync conflict resolution before the data layer hardens
Frequently asked questions
Do React error boundaries catch errors in async code or event handlers?
No. Boundaries only catch exceptions thrown during rendering, lifecycle methods, and constructors of their child tree. Errors in event handlers, promises, timers, and native modules bypass them entirely. Handle async failures in your data layer, track unhandled promise rejections separately, and use a global exception handler plus native crash reporting for everything else.
Where should I put error boundaries in a React Native app?
At the navigation screen level, applied automatically when screens are registered. A crashed screen then shows a contained fallback while tabs and back navigation keep working. One app-wide boundary is too coarse — any error blanks everything — and per-component boundaries are noisy. Add an outermost boundary as a final net, but expect the screen layer to do the real work.
How do I stop a React Native app from crash looping on startup?
Crash loops usually come from corrupt persisted state rehydrated on every launch. Validate and version persisted data, discarding slices that fail to parse instead of throwing. Keep a counter of fatal crashes near startup; after repeated quick failures, boot into a safe mode that clears caches and non-essential storage first. Losing preferences beats locking users out permanently.
What state management should a new mobile app use?
For most apps, React's built-in state plus a light library like Zustand is enough; in the State of React Native 2024 survey those two drew the most positive sentiment while Redux drew the most negative at about 18% dissatisfaction. Heavier tooling is justified mainly by large teams, complex shared state, or strict audit requirements.
Do we need offline support from day one?
If users operate in the field, in transit, or in markets with unreliable networks, yes, because retrofitting offline-first sync onto an online-only data layer is one of the most expensive refactors in mobile. If the app is unusable without live data anyway, graceful error and retry handling may be sufficient.
What are over-the-air updates and should our app use them?
OTA updates push JavaScript-level fixes directly to users without waiting for app store review, which is valuable for hotfixes. Microsoft's CodePush was retired on March 31, 2025, so current options are EAS Updates, a self-hosted CodePush server, or third-party services, and updates must stay within store policies that prohibit changing an app's core purpose.
Bottom line: Dhairya Senjaliya ships Mobile — Mobile App Architecture projects worldwide. Book a scoping call at https://dhairyasenjaliya.com/#book-call.