If you've run a Rork-generated app on a real device and felt that awkward pause where the splash screen lingers a few seconds too long, or spotted a quick white flash right as the splash disappears, you're not alone. These issues look minor, but App Store reviewers weigh launch impressions heavily, and I've personally seen submissions sent back because of exactly these symptoms.
This guide walks through the four patterns I've reproduced on real devices, with the underlying cause and a concrete fix for each. The examples assume the app.json and app/_layout.tsx (Expo Router) that Rork generates by default, so you can drop them straight into a fresh project.
Symptom 1: The splash hangs around for several seconds
This is by far the most common one, and the cause is almost always the same: you're await-ing too much business logic (session restore, remote config, font loading) before calling SplashScreen.hideAsync().
Here's what Rork's template often looks like out of the box. It reads fine, but the awaits stack up.
// ❌ If fetchRemoteConfig is slow, the splash stays for seconds
import { useEffect } from "react";
import * as SplashScreen from "expo-splash-screen";
import { Stack } from "expo-router";
import { useFonts } from "expo-font";
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const [fontsLoaded] = useFonts({
Inter: require("../assets/fonts/Inter.ttf"),
});
useEffect(() => {
(async () => {
if (!fontsLoaded) return;
await fetchRemoteConfig(); // 3-5s on a slow connection
await restoreSession();
await SplashScreen.hideAsync();
})();
}, [fontsLoaded]);
return <Stack />;
}On a slow network, or right after the user toggles airplane mode off, this looks like the app is frozen. The fix is to shift your hide trigger to the bare minimum needed to render any UI, and run the heavy work in the background.
// ✅ Hide as soon as UI can render; keep the heavy work in the background
import { useEffect, useState } from "react";
import * as SplashScreen from "expo-splash-screen";
import { Stack } from "expo-router";
import { useFonts } from "expo-font";
SplashScreen.preventAutoHideAsync().catch(() => {});
export default function RootLayout() {
const [fontsLoaded] = useFonts({
Inter: require("../assets/fonts/Inter.ttf"),
});
const [appReady, setAppReady] = useState(false);
useEffect(() => {
if (!fontsLoaded) return;
setAppReady(true); // UI can render the moment fonts are ready
fetchRemoteConfig().catch((e) => console.warn("remote config failed:", e));
restoreSession().catch((e) => console.warn("session restore failed:", e));
}, [fontsLoaded]);
useEffect(() => {
if (appReady) SplashScreen.hideAsync().catch(() => {});
}, [appReady]);
return <Stack />;
}Design your first screen so it can render in a guest / default-config state even when remote config and session data haven't arrived yet. If your product truly can't show anything before auth resolves, add a lightweight loading screen after the splash instead of holding the splash open—users find a clear loader far less alarming than a static splash.
Symptom 2: A white flash right after the splash disappears
This is the gap between when SplashScreen.hideAsync() runs and when your root screen actually paints. It's especially visible on dark-themed apps.
The first place to check is whether the three backgroundColor values in app.json—root, splash, and per-platform—are consistent. Rork's output frequently has them drifting out of sync.
// app.json (excerpt)
{
"expo": {
"backgroundColor": "#0B0F14",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#0B0F14"
},
"ios": {
"backgroundColor": "#0B0F14"
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#0B0F14"
}
}
}
}Align all three to the same color, matching your home screen's background, and the flash disappears. For extra insurance, make the background color explicit in your root _layout.tsx as well.
import { View } from "react-native";
import { Stack } from "expo-router";
export default function RootLayout() {
return (
<View style={{ flex: 1, backgroundColor: "#0B0F14" }}>
<Stack screenOptions={{ contentStyle: { backgroundColor: "#0B0F14" } }} />
</View>
);
}Without contentStyle, React Navigation's default white background leaks through for a single frame on each transition. This particular detail catches a lot of developers off guard.
Symptom 3: The splash looks fine on iOS but off-center on Android
From Android 12 onward, the platform replaced the classic full-screen splash with a centered, circular, icon-based splash. Rork's default template doesn't always reflect this, and you'll see the image cropped to a small circle in the center of the screen only on Android.
Add Android-specific configuration in app.json so the splash respects the new platform behavior.
{
"expo": {
"plugins": [
[
"expo-splash-screen",
{
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#0B0F14",
"imageWidth": 200
}
]
],
"android": {
"backgroundColor": "#0B0F14",
"splash": {
"image": "./assets/splash-android.png",
"resizeMode": "cover",
"backgroundColor": "#0B0F14"
}
}
}
}In my experience, the safest approach is to ship two assets: a full-bleed iOS splash (around 1284×2778px) and a tighter, centered Android 12+ splash (1024×1024px with generous padding). Reusing a single image almost always leaves part of your logo clipped inside the circular mask on newer Android devices.
Symptom 4: Dark mode works in-app but the splash stays white
iOS will always show a light splash unless you set UIUserInterfaceStyle, and Android needs a matching android:theme branch. Expo lets you handle both platforms with a single app.json change.
{
"expo": {
"userInterfaceStyle": "automatic",
"ios": {
"userInterfaceStyle": "automatic"
},
"android": {
"userInterfaceStyle": "automatic"
},
"splash": {
"image": "./assets/splash-light.png",
"backgroundColor": "#ffffff",
"dark": {
"image": "./assets/splash-dark.png",
"backgroundColor": "#0B0F14"
}
}
}
}The splash.dark block only takes effect when expo-splash-screen is configured as a config plugin. If Rork's app.json doesn't have that key yet, add it manually. After editing, run npx expo prebuild --clean so the native projects are regenerated—skip this step and the old native resources will keep shipping, leaving your dark splash stubbornly white.
A quick triage flow
Use this to narrow down which symptom you're facing:
- Splash visible for 3+ seconds → Symptom 1 (initialization blocking)
- One-frame white flash just after dismiss → Symptom 2 (background color mismatch)
- Logo cropped or tiny only on Android 12+ → Symptom 3 (Android 12 splash API)
- White splash only on devices in dark mode → Symptom 4 (
darkkey missing)
A tiny instrumentation snippet at the top of _layout.tsx is surprisingly useful. It tells you whether the fix is actually shaving seconds off your cold start.
const bootStart = Date.now();
useEffect(() => {
if (appReady) {
console.log("splash shown:", Date.now() - bootStart, "ms");
SplashScreen.hideAsync().catch(() => {});
}
}, [appReady]);Under 500ms feels instant; above 1000ms feels slow; above 2000ms reads as "frozen." Share those numbers with your team and your pre-release checks will stop being subjective.
Common edge cases I keep running into
A few situations have cost me more debugging time than they should have, so they're worth flagging explicitly.
The first is iOS simulators caching an older splash image. If you update splash.png but still see the previous asset after a rebuild, reset the simulator via Device → Erase All Content and Settings, then delete the app from the simulator before the next npx expo run:ios. Simulators sometimes keep the prerendered launch screen around across rebuilds, and no amount of expo prebuild --clean will fix it from inside your project directory.
The second is OTA updates silently breaking the splash after the first install. expo-updates will fetch a new JS bundle at launch, and if your update code calls SplashScreen.preventAutoHideAsync() but never resolves because the update check stalls, the splash is effectively pinned until the timeout fires. Wrap the update check in Promise.race with a 2-second ceiling and treat a timeout as "proceed with the current bundle."
await Promise.race([
Updates.checkForUpdateAsync(),
new Promise((resolve) => setTimeout(resolve, 2000)),
]);The third is that some Android OEM skins (One UI, MIUI) show their own system splash between yours and the first React render. There is nothing you can do in code to remove this, but if your splash background color matches what the OEM shows, the transition feels seamless rather than jarring.
Verifying your fix
Once all four fixes are in place, run npx expo prebuild --clean and verify on three environments. Emulators miss surprisingly many of these:
- iOS device in both light and dark mode
- Android 12+ device (Pixel or Galaxy) in dark mode
- Airplane mode cold launch (to surface any remaining network-bound await)
Splash screens are one of those areas you tune once and leave alone for half a year. Investing an hour now to kill all four symptoms pays off every time you ship. For adjacent launch-flow bugs, the Rork app launch crash and white screen fix and the Rork app loading stuck debug guide are worth a read. If you'd rather step back and rethink splash design itself, the Rork and Expo splash screen launch guide covers the bigger picture.
Start with the symptom that bothers you most, and drop in the console.log timing line first. Once the numbers are visible, the right fix almost always becomes obvious.