Blog

August 15, 2026 · 10 min read

iOS Cold Start: From 873ms to 482ms, Measured

Where 873ms of app-side launch time went, why RootView.init alone took 310ms, and what the signed-in path still costs. Measurements first, then the fixes.

  • iOS
  • SwiftUI
  • cold start
  • performance

I knew the app opened slowly. I did not know where.

Instruments showed the overall picture, but it did not divide which line of our code spent how many milliseconds. So I built a measurement tool first.

After building it and measuring, the app-owned interval was 873ms. 310ms of that came from one default argument.

I first built a tool that sets process birth to zero

Optimizing without measurement is guessing. First I needed a reference point.

If I set the call to main() to zero, time before it remains invisible. So I set process birth to zero, reading it from kinfo_proc.

LaunchTrace + scripts/audit/launch-bench.sh

The start comment gives the reason.

Without stage logs based at process birth time (kinfo_proc), where the slowness is remains a guess.

I fixed the conditions too: iPhone 17 Pro simulator, iOS 27, Release build, median of five to seven runs.

A Debug build is not useful. Optimization is off and logging is alive, so its time differs from what a user sees.

I used medians, not averages. The first run is slow because the file cache is empty. An average lets that one run pull everything upward.

I defined the interval with two points

Point What
init.begin First point where app code runs
splash.hidden Splash is removed and first screen is visible

The interval between these points is the “app-owned interval.” Before it, the system loads the binary and we cannot reduce it directly.

init.begin splash.hidden Interval
Before 1357 2230 873ms
After 1421 1903 482ms

Note that init.begin grew from 1357 to 1421. It is the time from process birth to app-code start, and varies between runs independently of what I fixed.

I must record both values to avoid being misled. Looking only at the end says 2230 became 1903, but the start differs, so the interval is what matters.

310ms came from one default argument

I added probes and found RootView.init spending 310ms on the main thread.

The first line of RootView.init looked like this.

// 문제가 있던 구조
@MainActor
final class HomeViewModel {
  init(summaryService: SummaryOrganizationProviding = SummaryOrganizationService.runtime()) {
    //                                                ^^^ 여기가 310ms
  }
}

// 호출부
struct RootView: View {
  init() {
    let home = HomeViewModel()   // 기본 인자가 여기서 평가됩니다
  }
}

Simply calling HomeViewModel() executes SummaryOrganizationService.runtime(). Inside it, SystemLanguageModel.default wakes up: the cost of initializing the on-device model handle.

The first screen does not summarize anything. A screen that does not summarize was waking the summary engine.

Swift evaluates default arguments at the call site

This is the trap.

Because the default appears in the function definition, it looks as if it is evaluated inside the function. It is not. It is evaluated at the call site and passed as the argument.

So every HomeViewModel() executes SummaryOrganizationService.runtime(). Even with no heavy code in the view-model body, assembly itself incurs the cost.

Dependency injection through a default argument is convenient and lets tests supply another implementation. I must use it knowing this property.

Fixing only one moves the cost sideways

There were two more in the same area.

What Where
SummaryOrganizationService.runtime() HomeViewModel default argument
fmAvailability SettingsViewModel default argument
profileOrchestrator StreamRuntime default argument

All three paths wake the on-device model. Delay only one and the cost appears in another path.

The comment says it plainly.

Fixing only one merely moves the cost sideways.

I changed all three to lazy evaluation. The result was 1.7ms instead of 310ms for RootView.init.

I moved the cost to the background

I did not remove it. The model has to wake at some point.

I call it once on a utility thread after the splash disappears.

// FMAvailability.swift:45-47
static func warmInBackground() {
  Task.detached(priority: .utility) { _ = check() }
}

By the time the user opens Settings or saves the first note, it is ready. Nobody waits for that 310ms.

The splash waited for a server response

The second cause was authentication.

The splash disappeared only after authStore.ready() finished. That function waited for the token refresh round trip.

The problem is clear when I calculate a dead network: timeoutIntervalForRequest is 60 seconds. Opening the app in a subway can leave the brand screen standing for a minute.

I split restoration into local and remote

Function What it waits for Who uses it
ready() Only reads the token from Keychain Splash
readyIncludingRemote() Includes server check Callers needing server result

ready() proceeds when a local token exists. Server checking continues afterward.

This is optimistic authentication. If the token has expired, the server returns 401 and I roll back the optimistic state.

I checked on a real device. One second after tapping the login button, eight records appeared on Home. The log says t=1s memory.row=8.

I reverted it to see the tests fail

I needed to prove the fix fixed the problem.

I reverted ready() to the old server-waiting version. Two tests failed.

testReadyDoesNotWaitForServerRefresh
testExpiredTokenClearsOptimisticAuthentication

They pass with the fixed version. The order creates evidence: showing failure under old code is stronger than writing a new test that passes.

Background work competed with the first frame

The third cause was priority.

Three cleanup jobs ran when the app started.

Job What
Temporary-file cleanup Delete old caches
Trash expiry cleanup Permanently delete expired items
Embedding backfill Generate 200 search vectors

All three used the default priority, the same priority as drawing the first frame. They shared the CPU.

I lowered them to .utility. They are not urgent and need not finish before the user sees the screen.

It was reading the whole DB on the main thread

Trash cleanup had something worse.

purgeExpired synchronously scanned all trash with GRDB reads on @MainActor. The main thread waited on disk.

This is the same path found in the real-device heat investigation. Synchronous I/O on the main thread freezes the screen, and while it is frozen the system decides something is wrong.

I moved it off the main thread.

I left regression tests behind

I need to prevent the fix from returning, especially because the default-argument issue is hard to notice by reading code.

I added two tests to PerformanceResourcePolicyTests. Their contract is that assembling a view model does not wake the model.

If someone puts runtime() back into a default argument for convenience, these tests fail.

I compared the full-suite failure count before starting

Eight of 1,420 tests failed, the same set as before. All had already been failing.

Without this comparison I cannot know whether the eight are mine. Before performance work, I must save the failure list.

Two were ambiguous. Two VoiceManualSummaryDiagnosisTests crossed the StreamRuntime path I changed. I ran them separately on the reverted version and confirmed the same failures. They were not mine.

I omitted which tree those 1,420 tests belonged to

Later I learned the number was wrong.

The 1,420 tests and 8 failures were measured on the entire working tree, which included uncommitted changes from four other sessions. Running the same suite at HEAD gives 1,398 tests and 20 failures.

Where measured Tests Failures
Entire working tree 1,420 8
HEAD 1,398 20

Reading the 12-test difference as “already failing” obscures the judgment. Other people's uncommitted changes covered my failures.

So I added another rule: whenever I write a failure count, I also write which tree it came from.

The 1.33 seconds I decided not to touch

The app interval is 482ms, while pre-main before it is 1.33 to 1.42 seconds. More than three times as long.

Reducing that would be a larger gain, but I did not touch it.

I could not identify the cause. I tried three hypotheses.

Hypothesis Check Result
Fat binary is heavy Build arm64 only with ONLY_ACTIVE_ARCH=YES No change at 1377ms; rejected
We have too many frameworks Count loaded images 5 of 1,318 were ours
Dynamic linking is slow DYLD_PRINT_STATISTICS Printed nothing in this runtime

None pointed to a cause.

Simulator pre-main also differs from a real device. The simulator uses the Mac filesystem and dyld, so its value cannot stand in for the device's.

The comment says:

Do not change the link structure on top of a number you have not measured.

Changing link structure is hard to reverse. Statically merging frameworks or merging modules changes the build structure. I cannot pay that cost for a benefit I have not measured.

I will revisit it when real-device profiling is available.

The 482ms was logged out

I wrote 482ms in the work document. Later I measured on a real device while signed in and got 680ms.

The number was not wrong; the condition was missing. I measured both states side by side on the same device and Release configuration, with medians of seven runs.

Interval Logged out Signed in Difference
pre-main (init.begin) 1,421 1,341.8 −79
auth.ready interval 156 348.4 +192
App-owned interval 482 679.5 +198

The difference is all in auth.ready. With a real account, the first frame has more to do: project nine account-store records into the screen and wire synchronization.

I did not claim a percentage reduction while signed in. The starting code had no instrumentation, so comparing with the same ruler would require a separate version that keeps only instrumentation and reverts behavior. I did not run that experiment.

Performance numbers need their conditions: device, OS version, build setting, repetition count, and user state.

UI automation leaks passwords and swaps the store

I learned two incidental things during real-device verification.

First, password input. When UI automation uses type, AutoFill intervenes and leaves one character. paste is needed to enter all 14 characters.

Second, and more dangerous, xcodebuild test launches the host app, so account tests replace the simulator's real app-group store. Running the full suite twice left 2 of 7 user records.

If I need to capture a simulator screen, I must do it before running the test suite.