◉RORK LABJP
●EXPO — EAS Observe now records native crashes (Oct 7). On SDK 57, update to 57.0.21 or later●RORK — GPT-6.1 Sol (Sep 29) and Claude Sonnet 5.5 (Sep 28) are now in the model menu●RN 0.88 — React Native 0.88.x is scheduled for Oct 12, 2 days left●Q&A — People keep asking why AuthSession returns no result in Android standalone builds●WINDOWS — A walkthrough shows how to ship an iPhone app from Windows only, with no Mac●NEW — When only notifications fail in Expo Go: three checks to run first●EXPO — EAS Observe now records native crashes (Oct 7). On SDK 57, update to 57.0.21 or later●RORK — GPT-6.1 Sol (Sep 29) and Claude Sonnet 5.5 (Sep 28) are now in the model menu●RN 0.88 — React Native 0.88.x is scheduled for Oct 12, 2 days left●Q&A — People keep asking why AuthSession returns no result in Android standalone builds●WINDOWS — A walkthrough shows how to ship an iPhone app from Windows only, with no Mac●NEW — When only notifications fail in Expo Go: three checks to run first
Articles/App Dev
◇ App Dev/2026-09-23Intermediate

It Crashed, but the Reviews Say "It Freezes" — When an Android Crash Leaves a Blank Screen Behind

Your crash reports show exceptions, yet users only ever say the app freezes. Here is why expo-updates plus the New Architecture can leave an empty Android screen behind, and the order I now use: a way out first, a bad-exit marker second, root cause last.

Expo213Android53Crashexpo-updatesNew Architecture4

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.

  1. Put the way out in place first. A boundary and a reload button ship today, with no diagnosis at all.
  2. Add the bad-exit marker. It is not a metric; it exists to change what the next launch does.
  3. 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.

Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $15 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

◇ App Dev2026-09-16
The Token I Deleted Came Back After a Restart — Detecting Failed SecureStore Deletes
On Android, getItemAsync can report null right after sign-out while the value is still sitting on disk. Here is how I rewrote a sign-out path that was verifying deletion by reading instead of by the delete result.
◇ App Dev2026-08-18
The three places I had to fix before a Rork project actually targeted API level 36
My app.json said targetSdkVersion 36. The value my build actually read was 35. Here is the script that reports the effective value, and how I split my apps between raising, leaving alone, and requesting an extension.
◇ App Dev2026-09-18
The three lines I check when prebuild stops on the standard SDK 57 Swift AppDelegate error
Turning on ios.enableSceneSupport for iOS 27 can stop prebuild cold. The cause was not my SDK version but the shape of AppDelegate.swift. Here are the three lines to check, and why copying the SDK 58 migration steps by hand will break your build.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links