The cost of a cold start
Launch time is the first promise an app makes, and the only performance metric every user measures: thumb on icon, eyes on screen, an unconscious verdict in well under a second. In an app that touches money or identity, that verdict carries extra weight, because slow reads as untrustworthy. Cold start rarely gets fixed once and stays fixed. It gets re-broken by the next feature team that ships an eager singleton or a synchronous call on the main thread, so the real problem is keeping it fast for years, not hitting a number once and calling it done.
You pay before main() is called
A surprising share of launch happens before your first line of code: dyld maps and links your binary and every dynamic framework, then runs static initializers and Objective-C +load methods. Each dynamic framework adds real milliseconds on real devices; a dependency-happy app can spend hundreds of milliseconds in a phase no profiler sample will attribute to your code.
Set DYLD_PRINT_STATISTICS=1 on a device build and read what comes back. The offenders tend to repeat across codebases: too many dynamic frameworks where static linking would do, a logging or analytics dependency whose entire runtime value is two functions, and static initializers quietly doing real work, a “constant” that turns out to be a date formatter or a compiled regex built before the app exists to use it.
Measure like you don’t trust yourself
Launch numbers lie freely: the simulator lies, debug builds lie, a warm iPhone 16 Pro in your hand lies about the aging device in your user’s. The rules we hold: release configuration, real hardware including the oldest supported device, cold starts only (reboot or long-idle between runs), and the median of many runs. Never the demo run that happened to go well.
Two instruments keep us honest. In development, signposts wrap every phase of startup so the App Launch template in Instruments shows exactly where time went. In production, MetricKit reports the launch histogram users actually experience: MXAppLaunchMetric ships you the truth about devices you’ll never hold. And to catch regressions before they ship at all, launch is a test:
final class LaunchPerformanceTests: XCTestCase {
func testColdLaunch() {
measure(metrics: [XCTApplicationLaunchMetric()]) {
XCUIApplication().launch()
}
}
}
CI runs it on device and fails any pull request that regresses the baseline beyond noise. Nobody has to remember to care. The pipeline remembers.
The startup architecture
Sustained launch performance is not a bag of tricks; it’s an opinion about what launch is. Ours: launch is the path to first meaningful content, and everything not on that path is deferred by construction. Concretely, startup work is declared, not scattered. Every subsystem registers a task with a phase, and nothing else is allowed to run early:
enum LaunchPhase {
case critical // before first frame: session, root UI state
case afterFirstFrame // first idle: caches, sync, push registration
case idle // whenever: analytics, cleanup, prefetch
}
StartupScheduler.register(.critical) { SessionStore.restore() }
StartupScheduler.register(.afterFirstFrame){ SyncEngine.warm() }
StartupScheduler.register(.idle) { Analytics.flushQueue() }
The scheduler runs critical synchronously, schedules afterFirstFrame on the first idle moment after render, and trickles idle work behind that. The design’s real value is social, not mechanical: when every startup task is a line in one file, “can this wait?” becomes a code-review question with a visible answer. An analytics initializer that would have silently camped on the critical path now has to argue for the privilege, in public.
Fast launches aren’t achieved. They’re defended, one code review at a time.
What actually moves the needle
- Dynamic framework consolidation moves the needle most. It’s also invisible to any profiler that starts sampling at
main(), which is exactly why it gets skipped. - Deferring work by construction beats deferring it by memory. A scheduler turns a one-time win into a permanent property, because nothing new gets to skip the queue without a reviewer noticing.
- Killing eager singletons pays for itself immediately. Every
sharedinstance that used to initialize on first import either went lazy or was deleted outright. - First-screen honesty covers for everything else. Render cached content immediately, then reconcile with the network after; the skeleton-screen-then-spinner ritual is usually a launch-time confession in disguise.
None of this is glamorous. That’s rather the point: launch performance is compound interest on boring decisions, enforced forever. The apps that feel instant five years in aren’t the ones that optimized hardest once. They’re the ones that never let the budget slip twice.