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:
- How many dynamic frameworks?
otool -Lon the binary. If it is more than a handful, that is the first place to look. - What is in
didFinishLaunching? Anything that is not required for the first frame is a candidate to move. - Any synchronous disk or network read on the path to the first view? Especially JSON decoding, which is slower than people expect.
- Any
static letdoing 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.