App Launch
People open an app to do something in it. Every second of logo between the tap and the content is a second of waiting for nothing, and a spinner after the logo doubles it. Launch the same app both ways.
Launch, then watch for the content: 2.6 s in, and it jumps at 3 s.
The splash version holds a logo for 1.5 s, spins while it fetches, shows the stories at 2.6 s, and then jumps when a banner loads above them. The other draws its own bars at once, shows the stories it saved last time at 0.4 s, and puts fresh ones in the same slots.
Make the launch screen look like your first screen, draw that screen from cached data, and never hold the launch for the network.
The numbers
- Apple's goal is the first frame within 400 ms of the tap. The system takes about 100 ms of that, leaving you about 300 ms to create views, load content and draw. The frame can have placeholders, but it has to respond to touch.
- Android vitals flags a launch as excessive at 5 s cold, 2 s warm and 1.5 s hot, measured to the first frame (time to initial display).
- On Android, a Baseline Profile makes code run about 30% faster from the first launch, because it skips interpretation and JIT compilation.
iPhone: a picture of the first screen
iOS shows your launch screen the moment the icon is tapped and swaps in your app when its first frame is ready. So the launch screen is your first screen with the content taken out.
You draw the launch screen: your first screen, emptied.
Apple's guidelines ask for a launch screen "nearly identical to the first screen", with no text, because it is never localized. No logos either, unless the logo is a fixed part of that first screen: "The launch screen isn't a branding opportunity." If you want a splash, put it at the start of onboarding. Once the app is up, restore where people were: the tab, the screen and the scroll position.
Android: the system splash
Since Android 12, every cold and warm start shows a system splash, your icon centred on a background colour, until your app draws its first frame. You can't make it look like your first screen, so keep it short.
| Part | Spec | |---|---| | Icon with an icon background | 240 × 240 dp, inside a 160 dp circle | | Icon without a background | 288 × 288 dp, inside a 192 dp circle | | Animated icon | No longer than 1,000 ms on phones |
Call installSplashScreen() before super.onCreate(). Hold it with
setKeepOnScreenCondition only while you read local data, never while you
wait on the network. Delete any separate splash activity: Google asks apps to
migrate to the SplashScreen API, and an extra activity shows a second splash
after the system one.
React Native and Expo
expo-splash-screen keeps the native splash up until you hide it. Call
preventAutoHideAsync() at module scope, then hide() once the first screen
can draw, and on the error path too, or the splash never leaves. fade: true
fades it out on iOS only. Release builds ship Hermes bytecode, which loads
without parsing. Judge startup in a release build, not Expo Go, which shows
your app icon instead of your splash.
On every stack, the first screen shows what was there last time and updates in place, so nothing moves when fresh data arrives (Layout Shift). A skeleton in the right shape covers the very first launch, when there is no cache yet. Speed is part of the design here more than anywhere (Performance Is Design).
Usage
// Info.plist > UILaunchScreen: only your first screen's background
// colour (UIColorName). No logo, no text.
struct RootView: View {
@SceneStorage("tab") private var tab = "today" // reopen where they were
@State private var feed = FeedStore()
var body: some View {
TabView(selection: $tab) {
// iOS 18+ Tab API
Tab("Today", systemImage: "newspaper", value: "today") {
FeedList(stories: feed.stories) // cached, in the first frame
}
}
.task { await feed.refresh() } // never in init or body
}
}
@MainActor @Observable
final class FeedStore {
var stories: [Story] = Story.cached() // small local read
func refresh() async {
if let fresh = try? await API.stories() { stories = fresh }
}
}class MainActivity : ComponentActivity() {
private val vm: FeedViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
val splash = installSplashScreen() // before super.onCreate
super.onCreate(savedInstanceState)
// Local disk only. A slow network must never hold the splash.
splash.setKeepOnScreenCondition { !vm.cacheLoaded.value }
enableEdgeToEdge()
setContent {
AppTheme {
val feed by vm.feed.collectAsStateWithLifecycle()
ReportDrawnWhen { feed.isNotEmpty() } // time to full display
FeedScreen(feed) // cached, then refreshed in place
}
}
}
}import * as SplashScreen from 'expo-splash-screen';
import { Stack } from 'expo-router';
import { useEffect } from 'react';
SplashScreen.preventAutoHideAsync(); // module scope, not in a component
SplashScreen.setOptions({ fade: true }); // iOS only
export default function RootLayout() {
const cache = useCachedFeed(); // reads disk, never the network
const ready = cache.loaded || cache.error;
useEffect(() => {
if (ready) SplashScreen.hide(); // on the error path too
}, [ready]);
if (!ready) return null;
return <Stack />; // first screen renders cached stories
}Match the appearance people are in. Apple asks for a launch screen that
matches the device's current orientation and appearance mode, so give it a
dark variant. On Android, a splash background colour set only in values/
stays light at night; set a dark one in values-night/. Otherwise the first
thing people see at night is a white flash.
Resources
Human Interface Guidelines: Launching - Apple's rules for launch screens, no text, no branding, and restoring where people left off.
Optimizing App Launch - The WWDC19 session behind the 400 ms first-frame goal, and how the time splits.
Splash screens - Android's SplashScreen API, its icon sizes, the animated icon limit, and how to hold or exit it.
App startup time in Android vitals - The 5 s cold, 2 s warm and 1.5 s hot thresholds Google Play flags as excessive.
expo-splash-screen - Keeping the native splash up until your first screen is ready, then hiding it.