One morning I was rereading the Android reviews for my wallpaper app, and the same three words kept appearing. Freezes. Unresponsive. Opens to white. Not one of them said the app had crashed.
The crash reports from that same window told a different story. Exceptions were recorded. The app was crashing. What users never saw was the familiar system dialog telling them the app had stopped.
For a while I assumed the gap was a reporting problem on my end. It turned out to be a screen problem instead.
Why "crashed" and "froze" describe the same minute
There is a report in the Expo repository that matches this exactly: a crash in JS or native code leaves Android on a blank screen (expo/expo #41543). With the New Architecture and expo-updates in the same project, the Activity survives a crash that the React instance does not.
That surviving Activity is holding a React instance that has already been torn down. There is nothing left to draw, so the screen stays blank. And since the process itself is still alive, the operating system has no reason to show a crash dialog.
What the user sees is not an app that died. It is an app that is open and doing nothing. By the time it becomes words, it has turned into "freezes."
Reducing crashes and telling users a crash happened are two separate pieces of work, and they need separate code. For as long as I treated them as one job, I watched the numbers in my dashboards and left nothing at all on the screen.
Catch the JS side before it falls into a blank screen
The JS layer is where you can act first. The goal here is not diagnosis. The goal is to leave the user exactly one thing they can still do.
The boundary below catches exceptions thrown during render, offers a reload button, and keeps a local copy of the last error. reloadAsync() from expo-updates reloads the JS bundle along with the discarded React instance, which is what gets you out of the blank screen.
// components/CrashBoundary.tsx
import React from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as Updates from 'expo-updates';
type Props = { children: React.ReactNode };
type State = { failed: boolean };
const LAST_ERROR_KEY = 'diag:last_js_error';
export class CrashBoundary extends React.Component<Props, State> {
state: State = { failed: false };
static getDerivedStateFromError(): State {
return { failed: true };
}
async componentDidCatch(error: Error, info: React.ErrorInfo) {
// Keep a copy on the device so the next launch can read it back
await AsyncStorage.setItem(
LAST_ERROR_KEY,
JSON.stringify({
at: new Date().toISOString(),
message: error.message,
stack: (error.stack ?? '').slice(0, 2000),
componentStack: (info.componentStack ?? '').slice(0, 2000),
}),
);
}
handleReload = async () => {
try {
await Updates.reloadAsync();
} catch {
// reloadAsync is not always available in a development build
this.setState({ failed: false });
}
};
render() {
if (!this.state.failed) return this.props.children;
return (
<View style={styles.wrap}>
<Text style={styles.title}>This screen failed to load</Text>
<Text style={styles.body}>
Tap below to load it again. If that does not help, close and reopen the app.
</Text>
<Pressable style={styles.button} onPress={this.handleReload}>
<Text style={styles.buttonLabel}>Reload</Text>
</Pressable>
</View>
);
}
}
const styles = StyleSheet.create({
wrap: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 },
title: { fontSize: 17, fontWeight: '600', marginBottom: 8 },
body: { fontSize: 14, lineHeight: 21, textAlign: 'center', marginBottom: 20 },
button: { paddingVertical: 12, paddingHorizontal: 24, borderRadius: 8, borderWidth: 1 },
buttonLabel: { fontSize: 15 },
});Mount it outside your navigator. If the boundary sits inside navigation, a crash that happens mid-transition slips past it.
Writing the copy to AsyncStorage is deliberate. Reports are least likely to reach you from exactly the places where connectivity is poor, which is often where the crash happened. A local copy is still there on the next launch.
A native crash is something you can only notice afterwards
A JS boundary only catches JS exceptions. When the native layer goes down, render is never called at all.
So I record whether the previous session ended cleanly. Set a marker at launch, clear it when the app moves to the background. If the marker is still there next time, the previous run ended somewhere in the middle.
// lib/sessionMarker.ts
import { AppState, type AppStateStatus } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
const OPEN_KEY = 'diag:session_open';
/** Call once at launch; returns true when the previous session ended badly */
export async function beginSession(): Promise<boolean> {
const leftover = await AsyncStorage.getItem(OPEN_KEY);
const endedBadly = leftover !== null;
await AsyncStorage.setItem(OPEN_KEY, new Date().toISOString());
const onChange = async (next: AppStateStatus) => {
if (next === 'background' || next === 'inactive') {
// A clean exit, so remove the marker
await AsyncStorage.removeItem(OPEN_KEY);
} else if (next === 'active') {
await AsyncStorage.setItem(OPEN_KEY, new Date().toISOString());
}
};
AppState.addEventListener('change', onChange);
return endedBadly;
}The caller reads it once, inside startup.
// inside app/_layout.tsx initialization
const endedBadly = await beginSession();
if (endedBadly) {
// The previous run stopped partway through, so recover here
await AsyncStorage.removeItem('cache:last_render_state');
}This marker tells you nothing about the cause. It tells you one thing only: the last run did not end normally. Even so, it lets you drop a stale cache automatically for someone who is reopening the app into a blank screen over and over.
It cannot tell a force quit apart from a crash, and I have stopped trying. I may be missing a cleverer approach, but what has worked for me is to treat both the same way and fail toward safety.
Line up the words in reviews against the logs you hold
People do not report symptoms in technical language. Deciding in advance which layer each phrase points to shortens the distance between reading a review and starting to look.
| What the review says | What the screen does | Layer to suspect | First move |
|---|---|---|---|
| White when I open it | Empty from launch | JS exception during startup | Read the stored boundary copy |
| Freezes partway | Goes empty after a tap | JS or native crash | Check the bad-exit marker |
| Closes by itself | Returns to the home screen | Process termination or memory | Pull the device log |
| Odd since the update | Behavior differs by version | Whatever you are shipping now | Stop the rollout and revert |
The same blank screen can come from somewhere else entirely. I wrote up the theme-switch variant separately in Fixing the White Screen on Theme Switch Without recreate() — When a Process Restart Is the Right Call. Identical symptom, different layer to suspect.
Stop starting the repair with the root cause
The order was where I had it wrong. I was waiting to understand the cause before touching the screen. Meanwhile, users kept looking at nothing.
These days I work in three steps.
- Put the way out in place first. A boundary and a reload button ship today, with no diagnosis at all.
- Add the bad-exit marker. It is not a metric; it exists to change what the next launch does.
- Only then chase the cause. Well-groomed crash reports start paying off at this point, not before.
The first two require no understanding of the underlying bug, and they are the pieces that reach users soonest.
Mount CrashBoundary at your root and give people the reload button, even if nothing else changes this week. A surprising share of the reviews that said "freezes" quietly turn into "reloaded and it came back."
Thank you for reading this far. If you have been stuck on the same mismatch between your reports and your reviews, I hope this ordering helps.