| Stage (my iPhone, tap → first usable frame) | Before | After | How I measured it |
|---|---|---|---|
| Pre-main (dyld) | 210 ms | 88 ms | DYLD_PRINT_STATISTICS=1 |
didFinishLaunching |
120 ms | 21 ms |
os_signpost intervals |
| Root view build + first CA commit | 100 ms | 71 ms | Instruments, App Launch template |
| Total | 430 ms | 180 ms | — |
That table is the better part of a year spent chipping at a single number: the time between my thumb landing on the icon and the first usable frame of an iOS app I build alone. On my own phone it fell from 430 ms to 180 ms. In the field, where it actually counts, the number is different and more stubborn, and getting honest about that gap is most of what this post is about.
The short version:
- The cost of a cold start on a small app is mostly dyld pre-main, and the biggest lever there is how many dynamic libraries you link — not how clever your own code is.
- The rest of the cuts came from refusing to do work on the launch path that most launches never use: hydrating the whole note store, checking iCloud, warming the audio stack.
- The 280 ms in the title is a field median, and it is the least honest number in the post, because the cold start users actually feel is the tail and the first launch after an update — and that one is still over a second.
Why does dyld dominate a small app's cold start?
Before your main() runs, the dynamic linker has already done a day's work. It maps your dependent dynamic libraries, applies rebase and bind fixups so every pointer lands in the right place, registers your Objective-C classes and runs any +load methods, then runs your C++ static initializers. All of that is "pre-main," and on a small app it is routinely the largest single slice of the cold start. Your own code has not executed yet.
The number that surprised me when I first turned on DYLD_PRINT_STATISTICS=1 in the scheme's environment variables was how little of pre-main was mine. The breakdown it prints (dylib loading, rebase/binding, ObjC setup, initializer time) was dominated by dylib loading. I was paying, on every cold start, for the privilege of having split my app into tidy dynamic frameworks.
Here is the actual shape of it on a fresh install, the numbers DYLD_PRINT_STATISTICS printed to the console:
Total pre-main time: 210.44 milliseconds (100.0%)
dylib loading time: 128.19 ms (60.9%)
rebase/binding time: 41.02 ms (19.5%)
ObjC setup time: 22.31 ms (10.6%)
initializer time: 18.90 ms (9.0%)
Sixty percent of my pre-main was the linker finding and mapping binaries. No amount of cleverness inside didFinishLaunching can touch that slice; it is spent before my code draws breath. The only knob that moves dylib-loading time is how many separate dynamic binaries dyld has to open, and that is a decision you make in your target settings, not your source.
Two honest caveats before the numbers, because pre-main is the easiest place to fool yourself. First, measure a release build on a device; a debug build on the simulator will lie to you in both directions. Second, modern dyld caches a launch closure after the first run, so a warm cold start is far cheaper than the one that matters most: the first launch after an install or an app update, when the closure is rebuilt. The scary pre-main number is the post-update one, and it is the one DYLD_PRINT_STATISTICS on a fresh install shows you.
The three hypotheses I tested
This kind of optimization (measure, guess, cut, measure again) only works if the guesses are written down before the cut. Here are the three I actually tested, in the order the profiler pointed me at them.
H1: pre-main is dominated by dynamic library count
Tested by counting. I'd already pulled every third-party dependency out of this app, which I wrote about separately; that turned out to be the single biggest thing standing between me and a fast pre-main, and I hadn't even measured it at the time. But I still had my own code carved into a couple of embedded dynamic frameworks for module hygiene. Each one is a separate binary dyld has to find, map, and fix up.
The cut: I collapsed my own modules back into the main app target and let them link statically. Module boundaries are a compile-time nicety; the runtime does not care how clean your dependency graph looked in Xcode. Pre-main dropped from 210 ms to 88 ms on my device — most of it the disappearance of dylib-loading time. Apple has been saying "keep your dynamic framework count low" since the 2016–2017 startup-time talks, and for a small app the advice is close to a free 100 ms.
H2: I'm doing store work on the launch path that the first frame doesn't need
Tested with os_signpost. I wrapped each phase of didFinishLaunching in a signed interval and read it back in Instruments:
let log = OSLog(subsystem: "app.launch", category: .pointsOfInterest)
let id = OSSignpostID(log: log)
os_signpost(.begin, log: log, name: "hydrate-store", signpostID: id)
store.loadAll() // <- the expensive line
os_signpost(.end, log: log, name: "hydrate-store", signpostID: id)
store.loadAll() was a JSONDecoder reading the entire note history to build the first screen. On my store it cost ~60 ms, and the latent bug was worse than the number: it was O(n) in note count, paid on every launch. A user with a few thousand notes was subsidising my laziness on the launch path every single time they opened the app.
The cut: render the first frame from nothing (an empty capture field, the one thing a capture app can always draw instantly) and hydrate the store on a background queue afterward, updating the view when it arrives. The persistence layer already drained its outbox asynchronously, so I was borrowing a pattern I'd already built. In the same pass I moved the iCloud availability check, url(forUbiquityContainerIdentifier:), off the launch path and onto first actual use; that call can block for a surprising while the first time you make it. didFinishLaunching fell from 120 ms to 21 ms.
H3: I'm warming a subsystem for a feature most launches never touch
This is the one I'm least proud of. I had been eagerly building the audio and on-device dictation stack during launch so the mic would feel instant the moment someone tapped it. Signposts put it at ~35 ms on the launch path, and it also poked AVAudioSession awake for no reason on launches where nobody dictated anything.
The trade I'd made without noticing: I was taxing every cold start to make one occasional tap feel faster. I moved the whole stack to lazy initialization on first mic tap. Cold start got cheaper for everyone; the mic's first-tap latency rose by an amount I could hide behind the waveform animation that plays while it spins up. Warm the thing the user is about to use, not the thing they might use — and never on the path to first frame.
The field is not your dev phone
Here is where the tidy lab numbers meet reality. MXAppLaunchMetric from MetricKit reports the real distribution from real devices, delivered once a day to an MXMetricManagerSubscriber, aggregated and privacy-preserving. This is field data, not a hero run on the newest phone in the room. It looked nothing like my 180 ms:
MetricKit timeToFirstDraw (all users) |
Before | After |
|---|---|---|
| p50 (median) | ~610 ms | ~280 ms |
| p90 | ~1.2 s | ~520 ms |
| Worst ~1% (post-update, cold, old devices) | rarely under 2 s | still ~1.3 s |
My lab number was the ceiling, nothing like the typical case. The field median is roughly 1.5x my device; the p90 is closer to 3x. The gap is older CPUs and slower flash, thermal throttling, low-storage devices where the filesystem is slow, launches while the system is busy doing something else, and above all the first launch after an update, when dyld has no cached closure to lean on. The 280 ms I put in the title is honest as a median. It is also, on its own, a little misleading.
Which cold start actually matters to a user?
Not the one I spent the most time on. That is the uncomfortable finding.
The p50 sits below the threshold where a person consciously registers a delay; shaving my own phone from 430 ms to 180 ms was, for the median user, mostly vanity I happened to enjoy. What a user actually feels is two things I under-invested in. The first is the tail: the p95 and p99, the bad-device, bad-moment launches that still run over a second, because one slow launch is remembered longer than fifty fast ones. The second is that most launches are not cold at all. A capture tool gets opened twenty times a day, and nineteen of those are warm resumes governed by applicationResumeTime, not the cold path I obsessed over. I optimised the least frequent launch because it was the most satisfying to measure. The honest allocation would have put more of that year into the tail and into resume time.
I'm keeping the cold-start work (100 ms of free pre-main is still 100 ms, and the store bug was a real scaling landmine), but I'd be lying if I sold you the median as the win. The median was the easy, feel-good part.
A few questions I keep getting
Does this even matter on modern dyld with launch closures? For warm cold starts, less than it used to; the closure does most of the caching for you. It matters exactly on the launch you can't cache: the first one after an app update. If you only ever test warm launches, you are measuring the case your users feel least.
SwiftUI or UIKit — does the choice change the cold start? At this scale, less than the dynamic-framework count does. The first-frame cost differed by a handful of milliseconds between a minimal UIKit root and a minimal SwiftUI one in my testing; the 100 ms swing came from dyld, not the UI framework. Optimise the linker before you re-litigate your UI stack.
Isn't 100 ms of cold start below the human perception threshold anyway? For the median, roughly yes, and that is exactly my counter-take above. The reason to still care is the tail: the same cut that moves a 180 ms launch you can't feel also moves a 1.3 s launch you very much can.
The number I still can't see
The measurement I want and don't have is the post-update first-launch time-to-first-draw, in the field, isolated from ordinary cold starts. MetricKit buckets them together, so the one launch I believe my users actually judge me on is hidden inside an average with all the cheap warm-cache launches. Next, I'm going to tag it myself: stash the last-launched bundle version in UserDefaults, compare it on launch, and when it changed, emit a signpost around first draw so I can pull that specific launch out of the noise. My bet, and it is only a bet until the data lands, is that it is the only cold start that has ever cost me a user.
If you've shipped an iOS app: what is your first-launch-after-update time-to-first-draw, and did you ever manage to measure it apart from ordinary cold starts? That's the number I can't cleanly see, and I suspect it is the only cold start anyone actually feels.
I'm a solo developer. I build Simple Memo, an iOS app that turns one typed line into an email about a second later, and I keep it small enough that the cold-start ledger above is a normal Tuesday for me. It's on the App Store if you're curious what that constraint produces.
Top comments (0)