Skip to content

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.

Logo splash
LogoSpinner
Content first
CachedFresh
0 s1 s2 s3 s

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.

TodayUpdated 2 h ago

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

RootView.swift
// 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

Search concepts

Search for a command to run...