Back to Blog

App launch performance in 2026: first-frame thinking, cold-start budgets, and practical fixes

A practical way to measure iOS cold start and ship improvements: define a first-frame budget, diagnose the common failure modes, and verify changes with repeatable runs.

6 min read

Fast launch is not a single trick. It is a feedback loop:

  1. define what “fast” means for your app
  2. measure it the same way every time
  3. remove work from the critical path until you hit a budget

In 2026 the most useful framing is still “time to first frame”: how long it takes from tapping the icon to a usable UI on screen.

Define a cold-start budget (and a definition)

You cannot improve what you cannot describe.

Pick a device and OS baseline that matches your users. Example:

  • baseline device: iPhone 13
  • baseline OS: iOS 17 or later
  • cold start budget (median): 700 ms to first frame
  • cold start budget (p95): 1100 ms to first frame

Be explicit about what you mean by cold start:

  • the app process is not running
  • the app has been force-quit or killed by the system
  • the device is not in a debugger-attached, “hot” state

If you only measure with the debugger attached, you will optimize the wrong thing.

Measure first-frame time with a repeatable harness

There are many tools, but the key is repeatability. A concrete, low-friction approach is an Xcode performance test using XCTApplicationLaunchMetric.

import XCTest

final class LaunchPerformanceTests: XCTestCase {
    func testColdLaunchTime() {
        let app = XCUIApplication()

        measure(metrics: [XCTApplicationLaunchMetric(waitUntilResponsive: true)]) {
            app.launch()
            app.terminate()
        }
    }
}

Verification step:

  • run this test 20 times on a physical device
  • record the median and p95 from the test report
  • repeat after each change, and keep the run conditions stable (same device, same build configuration, similar battery state)

This gives you an objective “did we get faster” signal that you can track in CI.

For deeper diagnosis, use Instruments:

  • App Launch template to see what is happening before applicationDidFinishLaunching
  • Time Profiler to identify expensive synchronous work on the main thread
  • System Trace when you suspect IO contention or thread scheduling issues

The critical path: what must happen before the first frame

At a high level, the first frame requires:

  • loading your main binary and frameworks
  • executing initializers (including Swift type metadata and ObjC +load)
  • running your app startup code (scene setup, dependency graph)
  • building the first view and laying it out

You do not need to finish analytics setup, remote config fetches, or a full database migration to show the first screen. If those are on the path today, that is your opportunity.

A common failure mode: synchronous network or config gating

Failure mode:

  • the app blocks showing UI until it has remote config (feature flags, paywall variants, API base URL)
  • the request is slow, the user is offline, or DNS stalls
  • the user sees a blank screen, a frozen splash, or an unresponsive UI on launch

This is easy to ship by accident if your root view creation depends on a “config loaded” boolean.

Diagnosis:

  1. In Instruments (App Launch or Time Profiler), look for a long gap where the main thread is waiting.
  2. In a hang trace you will often see dispatch_semaphore_wait, pthread_cond_wait, or a synchronous URLSession wait on the main queue.
  3. Confirm by putting a temporary log around the gate.

Example of what to avoid:

// Anti-pattern: blocking the main thread on launch
func application(_ application: UIApplication,
                 didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    let semaphore = DispatchSemaphore(value: 0)

    RemoteConfig.shared.load { _ in
        semaphore.signal()
    }

    semaphore.wait() // This will show up as a long wait in Instruments
    return true
}

Practical fix:

  • show a local default UI immediately
  • load remote config asynchronously
  • apply changes after the first frame (and after a timeout, keep defaults)

The goal is not “ignore remote config”. The goal is “do not hold first frame hostage”.

Practical fixes that usually move the needle

1) Split “launch” work from “post-launch” work

Make a list of everything you do in didFinishLaunching, scene setup, and root view creation. Categorize each item:

  • must happen before first frame
  • can happen after first frame
  • can happen lazily on first use

Then enforce it in code. One approach is to create a small startup coordinator that has an explicit “critical” phase and a background phase.

final class StartupCoordinator {
    func runCriticalPath() {
        // Minimal wiring needed for the first screen
    }

    func runPostLaunch() {
        Task.detached(priority: .background) {
            await Analytics.shared.configure()
            await RemoteConfig.shared.refreshIfNeeded()
            await CacheWarmer.shared.prewarm()
        }
    }
}

This is less about architecture aesthetics and more about making it hard to regress.

2) Remove heavyweight work from init and static initializers

A hidden launch tax is work done in:

  • static let initializers that run early
  • global singletons that do IO in init
  • property wrappers that compute eagerly

If a singleton touches disk in init, it will eventually land on the critical path.

Keep initializers cheap. If a type needs IO, expose an explicit start() or load() and call it after first frame.

3) Avoid doing layout-heavy work before you need it

If your first screen triggers a large layout pass (complex lists, heavy shadows, synchronous image decoding), you pay that cost during launch.

Concrete tactics:

  • use placeholders and progressively load content
  • defer expensive view modifiers until after first paint
  • decode large images off the main thread, then display

4) Make dependency graphs shallow at startup

Dependency injection can make launch worse if “building the graph” eagerly constructs everything.

A good compromise:

  • construct only what the first screen needs
  • lazily create services behind lightweight factories
  • avoid resolving feature modules until navigation reaches them

Verify improvements and prevent regressions

Do not rely on one benchmark run.

A simple process that works:

  1. Add the XCTApplicationLaunchMetric test above.
  2. Run it locally on the baseline device and capture median and p95.
  3. After a change, rerun the same number of iterations and compare.
  4. Gate merges when launch time regresses beyond an agreed threshold.

When you get a regression, treat it like any other performance bug:

  • bisect to find the commit that introduced it
  • validate the regression with the same run conditions
  • fix by moving work off the critical path or deleting unnecessary work

What “good” looks like

A good launch is boring:

  • the first frame shows quickly and consistently
  • the app remains responsive while background initialization runs
  • offline and bad-network conditions still produce a usable UI

If you ship the measurement harness and one or two guardrails (no synchronous waits on main, no IO in init), launch performance becomes something you can maintain instead of a quarterly fire drill.