◉RORK LABJP
●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SONNET 4.5 — Retires on the Claude API on 11/30, 23 days left. If your app calls it, check the migration target●SDK 58 — Moves to React Native 0.88. R8 is now on by default in Android release builds, and iOS prebuild generates a SceneDelegate●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SONNET 4.5 — Retires on the Claude API on 11/30, 23 days left. If your app calls it, check the migration target●SDK 58 — Moves to React Native 0.88. R8 is now on by default in Android release builds, and iOS prebuild generates a SceneDelegate●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
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.

Expo234Android64Crashexpo-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 $10 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-10-27
You Changed the Environment Variable and Rebuilt for Android, but the App Still Points at the Old Server. Look at the Line That Says UP-TO-DATE
Change only an EXPO_PUBLIC_ value and rebuild a release for Android, and the bundle task can be skipped as UP-TO-DATE, leaving the old value inside. I reproduce the same behavior in a minimal Gradle project and cover how to spot it and what to do until it is fixed.
◇ App Dev2026-10-18
It Ran Fine in Debug, but the Release Build Died on Launch with a Resource Not Found. A File I Looked Up by Name Had Been Shrunk Away
A release-only crash at launch, caused by images and sounds looked up by a name string being removed by resource shrinking. A keep.xml written at prebuild, plus a post-build check that confirms it worked.
◇ App Dev2026-10-15
When Only EAS Build Fails with "No matching variant", Check Your Local android/build First
Gradle passes on your machine, but the Android build on EAS stops at "No matching variant of project". Reading the reports on GitHub, I traced how a Windows-generated autolinking cache can reach the Linux builder, and wrote a small script that checks for it before you queue a build.