Home

Cutting cold launch from 2.1s to 900ms

Apple’s guidance is that an app should be usable within 400ms. Mine took 2.1 seconds from tap to first usable frame, and I had never measured it because the app “felt fine” on my phone — which is the newest one I own, warm, with a populated cache.

Measure it properly first

Cold launch means the app is not in memory. Force-quitting is not enough on its own; the surest way is to reboot the device, or leave the app closed long enough to be evicted.

The number that matters is in the console, free, with no instrumentation:

Edit Scheme → Run → Arguments → Environment Variables
DYLD_PRINT_STATISTICS = 1

That prints the pre-main time — dynamic linking, rebasing, binding, ObjC runtime setup, and all your +load methods and static initialisers — before any of your code runs.

Total pre-main time: 780.19 milliseconds
         dylib loading time: 410.31 milliseconds
        rebase/binding time:  91.24 milliseconds
            ObjC setup time:  84.02 milliseconds
           initializer time: 194.55 milliseconds

Everything after main is yours to measure, and the App Launch template in Instruments breaks it down properly. But DYLD_PRINT_STATISTICS costs ten seconds and told me immediately that a third of my launch was gone before main was even called.

Where mine went

Roughly: 780ms pre-main, 600ms in application(_:didFinishLaunchingWithOptions:), 700ms building the first screen.

Dynamic libraries — 410ms. I had eleven dynamically linked frameworks, several from SPM dependencies that defaulted to dynamic. Each one costs a dlopen at launch. Switching the ones I controlled to static linking took this to about 120ms.

In Swift Package Manager, this is the type: on the library product:

.library(name: "DesignSystem", type: .static, targets: ["DesignSystem"])

Static linking has a cost — a larger binary, and it does not work for anything sharing code with an extension — but for a handful of internal modules it is the single biggest launch win available.

Static initialisers — 194ms. Two analytics SDKs doing work in +load, and a static let in my own code that built a large lookup table. +load runs before main and nothing you do can defer it; the fix for the SDKs was to update to versions that had moved to lazy initialisation, and for my table to make it genuinely lazy rather than a global.

didFinishLaunching — 600ms. This was the embarrassing one. In there: initialising a database connection, reading and decoding a 400KB JSON of cached content, configuring three SDKs, and registering for push notifications.

None of it needed to happen before the first frame.

func application(_ app: UIApplication, didFinishLaunchingWithOptions: …) -> Bool {
    // only what the first frame genuinely needs
    window = …
    return true
}

// everything else, after the first frame
.task {
    await database.connect()
    await analytics.configure()
    await pushRegistration.register()
}

Warning

Moving work out of didFinishLaunching is not free of consequences. Anything that must observe a launch-time event — a push notification that launched the app, a URL, a shortcut item — has to stay, or be captured and replayed. Move things one at a time and check the deep-link paths.

The first screen — 700ms. The root view was a list that loaded from disk synchronously in its initialiser, so the first frame waited for the read. Restructuring it to render a placeholder and load in .task moved the whole 700ms behind a visible UI.

That last change is worth stating as a principle: the first frame does not have to contain real data. It has to appear. A list of skeleton rows that fills in 300ms later is dramatically better than a black screen for a second, and it is measured as a much faster launch because it is one.

The result

Phase Before After
Pre-main 780ms 310ms
didFinishLaunching 600ms 40ms
First frame 700ms 550ms
Total 2.1s 900ms

The first frame is still the expensive part, and that is genuine view-building work I have not found a way to remove.

What I would check first in someone else’s app

In order of how often it is the answer:

  1. How many dynamic frameworks? otool -L on the binary. If it is more than a handful, that is the first place to look.
  2. What is in didFinishLaunching? Anything that is not required for the first frame is a candidate to move.
  3. Any synchronous disk or network read on the path to the first view? Especially JSON decoding, which is slower than people expect.
  4. Any static let doing real work? They are lazy in Swift, but a global touched during launch is initialised during launch.

None of that requires Instruments. It requires measuring once, honestly, on the oldest device you support — which is the step I skipped for two years.