How to measure startup on real user devices, define trustworthy production percentiles, and use XCTest and Instruments to explain regressions.
The easiest way to measure app launch time is to start a stopwatch, open the app, and write down the number.
It is also one of the easiest ways to produce a measurement that cannot be reproduced, compared, or trusted.
An iOS launch depends on more than the code in the repository. The result changes with the process state, system caches, device model, thermal state, available memory, network behavior, stored user data, app version, and the destination the user is trying to open. Two measurements can both be technically correct while describing different experiences.
In Part 1, we separated cold launch, warm launch, resume, scene creation, and entry points. We also defined three possible endpoints: First Draw, Meaningful Content, and Time to Interactive.
Now we can turn that model into a measurement system.
This article is the second part of iOS Startup Performance in Practice, a six-part series:
The goal of this part is not to find a single perfect number. It is to build a production measurement system that describes what real users experience - and then use controlled tests and profiling to explain why that distribution changes.
Suppose one engineer reports a launch time of 1.4 seconds and another reports 2.1 seconds. Before comparing them, we need to know:
Without these answers, the numbers are labels rather than evidence.
Let’s start with production metrics. For every launch metric, define a measurement contract:
|
Dimension |
Example value |
|---|---|
|
Population |
Real user launches of app version `8.24.0` |
|
Launch class |
New-process launch; resume reported separately |
|
Entry point |
Icon, link, notification, and quick action segmented where custom telemetry allows it |
|
Start boundary |
System launch start for MetricKit; first application marker for custom metrics |
|
End boundary |
First Draw for the system metric; Home Interactive for the product metric |
|
User segments |
Signed-out and authenticated users reported separately |
|
Technical segments |
Device performance tier, iOS version, app version, and migration state |
|
Aggregation window |
Seven complete days after the target rollout reaches a stable cohort |
|
Reported values |
Every sample, median, p90, and maximum |
|
Data source |
MetricKit or Organizer for system launch; custom telemetry for product milestones |
|
Exclusions |
Internal builds, synthetic runs, invalid or incomplete milestone sequences |
This table describes measurement on user devices. There are no fixed test phones, stubbed networks, or XCTest iterations in the production contract. Real device and network diversity are not noise to remove; they are part of the experience we are trying to understand.
The first rule of launch measurement is therefore:
Define the population and user-visible endpoint before naming the number.
Instead of writing “launch time is 1.8 seconds,” write:
For production new-process launches of version 8.24.x, Time to First Draw is 1.1 seconds at p50 and 1.8 seconds at p90 across 2.4 million samples. Authenticated users on the lowest device tier have a p90 of 2.6 seconds.
The values are illustrative, but the form is important: it tells us who experienced the delay, which boundary was measured, and whether the percentile is statistically meaningful.
There is a fundamental limitation in iOS startup measurement:
This means one production number cannot answer every startup question.
Use Xcode Organizer or MetricKit to understand the system-observed launch distribution. These sources can describe Time to First Draw, optimized launch behavior influenced by prewarming, resume duration, and extended launch measurements supported by the OS version.
This is the authoritative source for questions such as:
Use lightweight custom telemetry for milestones the operating system cannot infer:
This is the authoritative source for questions such as:
Do not present an application timer that begins in didFinishLaunching as full launch time. Name it by its actual boundaries, for example AppCodeToHomeInteractive or RouteReceivedToDestinationInteractive.
Together, the two clocks give us a useful production model:
|
Metric family |
Earliest observable start |
Product context |
Recommended use |
|---|---|---|---|
|
Organizer / MetricKit |
System launch |
Limited |
System launch distribution and device/OS trends |
|
Custom milestone telemetry |
First available application marker |
Rich |
Entry-point-specific Meaningful Content and TTI |
|
XCTest |
Test-controlled launch |
Deterministic fixture |
Reproduce and prevent a known regression |
|
Instruments |
Instrumented launch |
Diagnostic trace |
Explain where time is spent |
Instruments, XCTest, Xcode Organizer, MetricKit, and custom telemetry do not compete with one another. They answer different questions.
|
Question |
Best starting point |
|---|---|
|
What startup time do real users experience? |
Organizer, MetricKit, and production milestone telemetry |
|
How does the distribution change by app version, device, OS, or user state? |
Production telemetry segmented by those dimensions |
|
How long until a requested destination becomes useful? |
Custom production milestones |
|
Which launch phase is slow? |
Instruments App Launch template |
|
Which application service delays startup? |
|
|
Did this commit introduce a regression? |
XCTest on controlled hardware |
|
Is resume slow rather than launch? |
Separate resume metric and lifecycle instrumentation |
A common mistake is to use the most convenient tool and then reformulate the problem so that it aligns with the result obtained using that tool.
For example:
didFinishLaunching is called “total startup time” even though it excludes pre-main work;viewDidAppear is treated as proof that a frame was displayed and interactive;The correct order is:
Measurement answers:
How long does this scenario take under defined conditions?
Profiling answers:
Where is the time being spent, and why?
These activities form a loop, but they should not be confused:
Observe a production regression
↓
Reproduce the affected cohort
↓
Profile a representative run
↓
Form one hypothesis and change the implementation
↓
Measure again in the controlled benchmark
↓
Validate in production
Instruments collects thread states, samples, stack traces, allocations, signposts, and launch phases. That visibility has a cost. Apple explicitly demonstrates that CPU time and wall-clock time in an instrumented trace can differ because profiling itself introduces overhead. It makes Instruments a diagnostic tool rather than the final stopwatch.
Use production data to discover and quantify the problem. Use Instruments to explain its root causes. Use XCTest to make the reproduction scenario repeatable and protect the issue from returning. Then, refer back to production data to verify the result.
As soon as production data reveals which cohort or user journey is performing slowly, create a benchmark that reproduces the situation. This serves as a tool for diagnosing and preventing regressions, rather than as a primary description of the user experience.
Launch tests are sensitive to the state of the whole device. A useful benchmark removes unrelated variance while preserving the characteristic that made the production cohort slow: device class, data volume, migration state, authentication path, or entry point.
The simulator is useful for development, lifecycle experiments, and fast feedback. It is not a reliable representation of iPhone launch performance because it runs on Mac hardware, shares host resources, and does not reproduce the same storage, memory, thermal, scheduling, and rendering environment.
Use physical devices for performance conclusions.
The minimum useful device set normally includes:
Do not compare an “after” result from a new phone with a “before” result from an old one.
Measure a Release configuration, or a dedicated Performance configuration derived from Release.
The measured build should preserve:
Avoid drawing conclusions from a Debug build. Debugger attachment, assertions, sanitizers, logging, and the absence of compiler optimizations can change both the duration and the shape of launch.
Keep observability enabled, but make it lightweight. Signposts are designed for this purpose; verbose synchronous logging is not.
Before a benchmark session:
Do not cool the device only for the “after” measurement. The goal is not to produce the smallest possible number. The goal is to make before and after comparable.
Startup often depends more on user state than teams initially expect.
Create explicit fixtures such as:
The fixture should be deterministic. A performance test that uses an account whose data changes every day is not deterministic even if the test code never changes.
The network is part of the user experience, but it is usually a poor dependency for a code-regression benchmark. Latency, DNS, TLS, server load, routing, and radio state can overwhelm a small application-side change.
For local and CI measurements, prefer one of these contracts:
Then validate real network behavior separately in production.
This creates two complementary truths:
There is no public API that reliably clears every relevant system cache and requests a “true cold launch.” Force-quitting an app and immediately reopening it creates a new process, but the second launch can still benefit from warmed pages and system services.
Apple recommends warm launches for repeatable development measurements because they are more consistent. XCTest also performs a throwaway iteration before recording measured iterations, reducing first-run cache variance.
That does not mean cold launch is unimportant. It means it needs a different strategy.
Use warm launches for high-signal regression detection:
Evaluate cold behavior through a combination of:
Never label “terminate and immediately relaunch five times” as five cold launches. It is five new-process launches under increasingly warm system conditions.
XCTApplicationLaunchMetric provides the most practical automated baseline for application launch.
A minimal UI performance test looks like this:
import XCTest
final class AppLaunchPerformanceTests: XCTestCase {
func testReturningUserLaunchToFirstFrame() {
let app = XCUIApplication()
app.launchArguments = [
"-ui-testing",
"-launch-scenario", "returning-user"
]
app.launchEnvironment = [
"NETWORK_MODE": "stubbed",
"DATASET": "returning-user-small"
]
let options = XCTMeasureOptions()
options.iterationCount = 10
measure(
metrics: [XCTApplicationLaunchMetric()],
options: options
) {
app.launch()
}
}
}
Without an extended launch task, XCTApplicationLaunchMetric() measures to the first frame. XCTest runs the measured block repeatedly and ignores an additional initial iteration used to reduce warm-up variance.
If the requirement is that the first frame is also responsive to input, use:
XCTApplicationLaunchMetric(waitUntilResponsive: true)
This waits until the main thread is responsive after the first frame. It is often a better default for products that can technically draw a frame while still blocking user interaction.
The two metrics answer different questions:
|
Metric |
Endpoint |
|---|---|
|
|
First frame, plus any explicitly registered extended launch tasks |
|
|
First frame and responsive main thread |
Do not silently switch between them when comparing builds.
Launch arguments and environment variables are useful for choosing deterministic fixtures, but the application must process them carefully.
For example:
struct PerformanceScenario {
let networkMode: NetworkMode
let dataset: Dataset
static func fromProcessInfo() -> PerformanceScenario? {
let info = ProcessInfo.processInfo
guard info.arguments.contains("-ui-testing") else {
return nil
}
return PerformanceScenario(
networkMode: .stubbed,
dataset: .returningUserSmall
)
}
}
Do not replace the normal startup architecture with a completely different test-only app. The test should control external variance, not remove the code you intend to measure.
A production application usually needs several launch tests:
func testSignedOutLaunch() { /* fixed signed-out fixture */ }
func testReturningUserSmallDatasetLaunch() { /* small fixture */ }
func testReturningUserLargeDatasetLaunch() { /* large fixture */ }
func testPendingMigrationLaunch() { /* migration fixture */ }
func testOfflineLaunch() { /* offline fixture */ }
Entry-point-specific journeys can be added separately, but remember that the built-in application launch metric ends at a system launch milestone. If the product destination becomes useful later, add an application-owned measurement for that interval.
Xcode performance tests can compare results with an accepted baseline and flag regressions. A baseline is only meaningful for a stable environment.
Do not share one absolute baseline across:
For CI, record the device identity, OS, Xcode version, commit, build configuration, fixture, and raw measurements with every run. If any of those dimensions changes, treat the comparison as a new series until you have evidence that the values remain comparable.
Once the benchmark shows that launch is slow, open the same scenario with the App Launch template in Instruments.
Profile a Release build on the same class of physical device used for measurement.
The App Launch template separates major phases such as:
In the trace, inspect more than the total duration:
main because of linked dependencies or static initialization?Pin the app launch phases, main thread, and relevant worker threads. Add Points of Interest so that application-owned phases appear on the same timeline.
Then form one narrow hypothesis. For example:
The root interface waits synchronously for the complete catalog cache, although the first screen needs only the first page.
Change that behavior, return to the controlled XCTest, and measure again. Do not report the duration of the new Instruments trace as the improvement; report the repeated benchmark under the original contract.
System metrics can tell us when the first frame appears. They cannot infer that a product screen contains meaningful data or that a requested route is ready.
Use OSSignposter to make application phases visible:
import OS
enum StartupSignposts {
static let subsystem =
Bundle.main.bundleIdentifier ?? "ExampleApp"
static let category = "Startup"
static let signposter = OSSignposter(
subsystem: subsystem,
category: category
)
}
A small interval wrapper can keep ownership explicit:
import OS
final class StartupInterval {
private let name: StaticString
private let state: OSSignpostIntervalState
private var finished = false
init(_ name: StaticString) {
self.name = name
self.state = StartupSignposts.signposter.beginInterval(name)
}
func finish() {
guard !finished else { return }
finished = true
StartupSignposts.signposter.endInterval(name, state)
}
}
Use it around a product-owned phase:
@MainActor
final class StartupCoordinator {
private var interactiveInterval: StartupInterval?
func begin() {
interactiveInterval = StartupInterval("LaunchToInteractive")
buildRootInterface()
}
func didBecomeInteractive() {
interactiveInterval?.finish()
interactiveInterval = nil
}
}
The end marker must represent a real product condition. For a home screen, that may require:
Do not end the interval because a convenient callback fired.
XCTest can record the duration between matching signpost events:
func testLaunchToInteractive() {
let app = XCUIApplication()
app.launchArguments = [
"-ui-testing",
"-launch-scenario", "returning-user"
]
let metric = XCTOSSignpostMetric(
subsystem: "com.example.app",
category: "Startup",
name: "LaunchToInteractive"
)
let options = XCTMeasureOptions()
options.iterationCount = 10
measure(metrics: [metric], options: options) {
app.launch()
}
}
This creates an application-specific benchmark, but it has an important boundary: the signpost cannot begin before application code runs. It measures the application-owned interval, not the full system launch.
Keep both metrics:
One does not replace the other.
Good startup signposts are few, stable, and architectural:
RestoreSessionBuildRootInterfaceResolveInitialRouteHomeMeaningfulContentLaunchToInteractiveAvoid adding an interval around every function. A trace with hundreds of unstable names becomes harder to compare and expensive to maintain.
Signpost names also become part of the observability contract. Rename them deliberately.
An app can produce a fast first frame and still feel slow.
Consider this sequence:
0.0 s User taps a product link
1.0 s App draws the home container
1.2 s Authentication restoration finishes
2.3 s Initial route is resolved
3.1 s Product data appears
3.5 s Buy button becomes enabled
The system First Draw metric may report approximately one second. The user asked for a product and could not act on it for three and a half seconds.
Both numbers are correct:
The problem begins when the first number is presented as the complete user experience.
Define a product milestone for each important entry point:
|
Entry point |
Suggested endpoint |
|---|---|
|
App icon |
Home screen interactive |
|
Product link |
Requested product visible and primary action enabled |
|
Notification |
Target state rendered and actionable |
|
Scan quick action |
Camera preview active and accepting input |
|
Search shortcut |
Search field focused and accepting text |
This is also why a universal “startup budget” is insufficient. The system launch budget can be shared, but route execution and destination readiness need their own budgets.
Production launch performance is a distribution across users, devices, operating systems, application states, and entry points. A single average hides the experiences that most need attention.
For production launch data, report at least:
Always segment the distribution before interpreting it. At minimum, compare:
A global p90 can remain unchanged while the p90 on the oldest supported devices regresses by several seconds. It can also appear to improve simply because adoption shifted toward newer devices. Segmentation is part of the metric, not an optional dashboard filter.
Do not use production maximum as a release KPI. In a sufficiently large population, isolated extreme values are expected and may reflect interruptions the application cannot control. Use high percentiles, SLO compliance, and sample count to quantify the tail; use diagnostics to investigate pathological individual cases.
For a controlled test, preserve every raw iteration and report the median, p90, and maximum. A small local dataset answers a different question: whether the scenario is stable enough to reproduce a change.
Suppose ten controlled runs produce these durations in seconds:
0.91, 0.94, 0.96, 0.98, 1.00,
1.02, 1.05, 1.09, 1.37, 2.10
The median describes the center, while the p90 and maximum reveal instability that an average would blur. Do not delete the slow runs merely because they are inconvenient. Investigate whether they came from the application or from a documented failure of the experiment.
Ten local runs can provide quick feedback, but they do not make a production-quality p99. High percentiles require many samples.
Use:
Do not compare a p90 from ten local launches with a p90 from millions of production launches as though they were the same experiment.
Production data is the primary description of startup quality. Controlled tests answer whether a code change affected a reproducible scenario, but they cannot represent every real user.
Production launch depends on:
Xcode Organizer aggregates launch performance from users who share diagnostics and allows teams to compare distributions across app versions and devices. It is the fastest way to establish a system-level production baseline before building any custom pipeline.
MetricKit provides programmatic access to daily aggregated performance reports. On currently deployed iOS versions, an application can subscribe with MXMetricManager:
import MetricKit
final class MetricsReceiver: NSObject, MXMetricManagerSubscriber {
func start() {
MXMetricManager.shared.add(self)
}
func stop() {
MXMetricManager.shared.remove(self)
}
func didReceive(_ payloads: [MXMetricPayload]) {
for payload in payloads {
persistOrUpload(payload.jsonRepresentation())
}
}
}
MetricKit reports include histogrammed launch and resume information rather than one application-wide scalar. Depending on the OS and SDK generation, launch data includes time-to-first-draw, optimized time-to-first-draw for prewarmed launches, resume time, and extended launch measurements.
Apple is evolving MetricKit toward the newer MetricManager, MetricReport, and typed MetricResult APIs in upcoming SDKs. If you adopt those APIs, isolate the SDK-specific ingestion layer. The measurement model should remain stable even when the transport type changes:
Apple metric payload
↓
Version-specific decoder
↓
Internal launch histogram model
↓
Device / OS / app-version dimensions
↓
p50 / p90 / p95 dashboards and alerts
Do not upload a daily payload and discard its dimensions. At minimum, preserve:
Aggregating everything into one average makes regressions on older devices almost invisible.
System metrics understand launch and rendering. They do not automatically know that the product card requested by a notification is ready for purchase.
For entry-point-specific Meaningful Content and Time to Interactive, add privacy-safe custom telemetry:
struct StartupEvent: Encodable {
let launchID: UUID
let appVersion: String
let deviceClass: String
let entryPoint: String
let lifecyclePath: String
let milestone: String
let elapsedMilliseconds: Int
}
Avoid including raw URLs, notification payloads, user identifiers, search text, or product data unless the analytics design explicitly requires and permits them. The useful dimensions are normally categories, not content.
Startup observability runs on the critical path, so its design matters.
Avoid:
Prefer:
OSSignposter for timeline intervals;A lightweight custom recorder might look like this:
import Foundation
actor StartupMetricsBuffer {
struct Mark: Sendable {
let name: String
let uptime: TimeInterval
}
private var marks: [Mark] = []
func append(_ name: String) {
marks.append(
Mark(
name: name,
uptime: ProcessInfo.processInfo.systemUptime
)
)
}
func drain() -> [Mark] {
defer { marks.removeAll(keepingCapacity: true) }
return marks
}
}
Even this code starts only after application code exists. It must never be described as measuring the full interval from the user tap.
didFinishLaunching: his excludes process creation, dyld work, runtime initialization, static initializers, and earlier UIKit work. Name it didFinishLaunchingToInteractive if that is what it measures.viewDidAppear: The callback does not guarantee that the frame has reached the screen, content is meaningful, or the main thread is available for the next action.The following protocol is a useful starting point for a production team. It begins with user devices and introduces laboratory measurements only after the production metric identifies a problem.
Select the launch scenarios that represent meaningful user traffic and risk:
Keep launch and resume in separate series.
For each journey, write down:
Before creating test fixtures, define the production segments needed to explain the journey: app version, device tier, OS, authentication state, entry-point category, and migration state. Do not collect dimensions that have no analytical purpose.
Use Organizer or MetricKit for system launch and resume distributions. Add lightweight custom milestones for Meaningful Content and Time to Interactive. Buffer and upload the data outside the launch critical path.
Collect a complete, stable rollout window. Report p50, p90, p95, sample count, SLO compliance, and the important segments. Treat resume as a separate metric.
Determine whether the regression belongs to a device tier, OS, app version, entry point, user state, or migration path. This cohort becomes the specification for the controlled experiment.
Choose stable physical devices based on the affected production population. Create deterministic fixtures that reproduce its data volume, authentication state, feature flags, cache state, and network contract.
Use XCTApplicationLaunchMetric for First Draw or responsiveness and signposts for application-owned stages. Use Instruments to connect the slow interval to threads, stacks, dependencies, and launch phases.
Measure after every meaningful change. Once the improvement is reproducible, run the benchmark regularly on fixed hardware and alert on a sustained regression or budget violation rather than one noisy sample.
Compare Organizer, MetricKit, and custom product milestones by app version and the original affected segment. A local win is complete only when the relevant production distribution moves in the expected direction.
A production report should first show the user impact and the affected cohort, then connect it to the controlled experiment used to explain the change:
Production metric: New-process launch to First Draw
Versions: 8.23.x → 8.24.x
Window: Seven complete days at stable rollout
Population: 2.4 million launches
All production devices:
p50 1.08 s → 1.11 s
p90 1.76 s → 1.84 s
within 2 s SLO 93.2% → 91.8%
Affected cohort:
Device tier: lowest supported tier
User state: authenticated, large local dataset
p90 2.41 s → 3.06 s
Samples 184,000
Controlled reproduction:
Physical reference device for the affected tier
Release build, returning-user-large-v3 fixture
10 measured iterations + 1 XCTest throwaway
Local before:
p50 1.42 s
p90 1.81 s
max 2.03 s
Local after:
p50 1.19 s
p90 1.43 s
max 1.55 s
Change:
Deferred catalog cache hydration beyond First Draw
Production validation:
Compare the same affected cohort in version 8.25.x
The exact values above are illustrative. The important property is traceability: the report begins with the real-user regression, identifies the cohort, shows how it was reproduced, and defines how the fix will be validated.
Avoid reports such as:
Startup is now 16% faster.
Faster for which scenario, device, endpoint, and percentile?
At this point, the pieces form one system:
Real user devices
├── Organizer → aggregated field trends
├── MetricKit → programmatic system histograms
└── Custom telemetry → entry-point-specific milestones
Affected production cohort
↓
Controlled physical device
├── XCTest → repeatable reproduction and regression protection
├── OSSignposter → application-owned phases
└── Instruments → causal diagnosis
Each layer corrects a blind spot in another:
The goal is not to choose one source of truth. It is to know which source is authoritative for each question.
Correct launch measurement begins with real users.
First define the production population, lifecycle path, entry point, start boundary, and meaningful endpoint. Collect enough samples to report p50, p90, p95, SLO compliance, and the device, OS, app-version, and user-state segments that can explain the distribution.
Use Organizer and MetricKit for system-observed production launch and resume metrics. Add privacy-safe custom telemetry for route resolution, Meaningful Content, and Time to Interactive. When a cohort regresses, reproduce that cohort on physical hardware, use XCTest to make the scenario repeatable, and use Instruments to understand the phases, threads, and stacks behind it. Finally, return to the same production segment to validate the fix.
The most important distinction is this:
A benchmark tells you whether the code changed. Production telemetry tells you whether the user experience changed.
In Part 3: Finding Bottlenecks from dyld to the First Frame, we will use the measurement system built here to read an App Launch trace phase by phase. We will examine pre-main work, linked frameworks, static initialization, disk I/O, main-thread blocking, rendering, and the difference between CPU time and waiting time.