RORK LABJP
SDK58BETA — Expo SDK 58 is still in beta. The only thing stated officially is "three to four weeks", and no stable date appears in any primary source, so it is safer not to plan around oneRN0.88RC1 — React Native 0.88 reached rc.1 on September 16, with the stable release expected October 12. The type-level changes arriving in SDK 58 are the other half of that story11/01 — For anyone who filed a Google Play target API level extension, the delivery deadline is November 1, thirty-nine days away. The extension can be requested once, from Policy status in Play ConsoleAUDIO — expo-audio is reported to stop after a certain number of playbacks, with status.didJustFinish never arriving. Nothing throws; the next sound simply never starts, which makes it easy to missNEW — Designing the branch after one Checkout carries four different productsLSAQS — lsapplicationqueriesschemes shows up 22 times in search with zero clicks. The largest implementation-side demand this site sees still has no answer anywhere on itSDK58BETA — Expo SDK 58 is still in beta. The only thing stated officially is "three to four weeks", and no stable date appears in any primary source, so it is safer not to plan around oneRN0.88RC1 — React Native 0.88 reached rc.1 on September 16, with the stable release expected October 12. The type-level changes arriving in SDK 58 are the other half of that story11/01 — For anyone who filed a Google Play target API level extension, the delivery deadline is November 1, thirty-nine days away. The extension can be requested once, from Policy status in Play ConsoleAUDIO — expo-audio is reported to stop after a certain number of playbacks, with status.didJustFinish never arriving. Nothing throws; the next sound simply never starts, which makes it easy to missNEW — Designing the branch after one Checkout carries four different productsLSAQS — lsapplicationqueriesschemes shows up 22 times in search with zero clicks. The largest implementation-side demand this site sees still has no answer anywhere on it
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.

Expo212Android52Crashexpo-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-06-25
Crashes Only in the Release Build — Rescuing Classes R8 Stripped in Expo (Android)
I turned on R8 code shrinking to slim down an AAB, and one screen started crashing only in production. Here is how I traced the stripped class through mapping.txt and added keep rules via expo-build-properties.
📚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