Kirito
The Expo Splash Screen That Never Hid (And Users Thought the App Was Frozen)
A misconfigured splash screen stayed visible forever on Android because we forgot to call 'SplashScreen.hideAsync()' after an async operation that threw an error.
The Expo Splash Screen That Never Hid (And Users Thought the App Was Frozen)
"Our app loads forever on Android. The splash screen just stays there."
25% of our Android users saw an infinite splash screen. They thought the app was frozen and uninstalled.
The Setup
Expo’s splash screen with a custom loading flow:
// App.js import * as SplashScreen from 'expo-splash-screen'; SplashScreen.preventAutoHideAsync(); export default function App() { const [appIsReady, setAppIsReady] = useState(false); useEffect(() => { async function prepare() { try { await loadFonts(); await loadUserData(); // ... other async setup } catch (e) { console.warn(e); } finally { setAppIsReady(true); } } prepare(); }, []); useEffect(() => { if (appIsReady) { SplashScreen.hideAsync(); } }, [appIsReady]); if (!appIsReady) return null; return <RootNavigator />; }
The Bug
One of the async setup functions – 'loadUserData()' – would sometimes throw an error when the device had no network (e.g., timeout).
The error was caught and logged, but 'setAppIsReady(true)' was still called in the 'finally' block.
However, 'SplashScreen.hideAsync()' is not guaranteed to work if called immediately after an error that might have left the native splash controller in an inconsistent state.
On Android specifically, if you call 'hideAsync()' while another native operation is pending, the call silently fails – and the splash screen remains visible forever.
The Fix
// Proper error handling async function prepare() { try { await loadFonts(); await loadUserData(); setAppIsReady(true); } catch (e) { console.error('Setup failed:', e); // Show an error screen instead of frozen splash setError(e); setAppIsReady(true); // still hide splash } } // In the hide effect, add a delay and retry useEffect(() => { if (appIsReady) { const hideSplash = async () => { try { await SplashScreen.hideAsync(); } catch (e) { // Retry after 500ms setTimeout(() => SplashScreen.hideAsync(), 500); } }; hideSplash(); } }, [appIsReady]);
Even better: Use 'expo-splash-screen's 'autoHide' option and only prevent when necessary:
// app.json { "expo": { "splash": { "autoHide": false } } } // then manually hide only after all critical loads
What I Learned
- Always handle errors in splash screen setup – an unhandled rejection can prevent hide.
- Hide splash screen in a 'finally' block, but add a safety timeout.
- Test on low‑end Android devices – they are more prone to native race conditions.
- Add a timeout – if splash isn't hidden after 10 seconds, force hide.
That infinite splash screen cost us 2,000 uninstalls. Now we have a forced timeout and retry logic.