Mobile Computing · Mobile Performance

Cold Start Phases and Where Launch Time Is Spent

The stages an app passes through between a tap and its first frame, which stages scale with code size, and why the reported launch time depends on where the clock starts.

A tap on a launcher icon starts a chain of work that ends with a frame on the panel, and almost none of that work is under the app’s direct control. Some of it happens in the kernel, some in a system process that has been running since boot, some in a linker, and only the last part in code the app author wrote. Launch time is the sum, and the sum is dominated by different stages on different devices and different codebases. Treating it as one number hides where the milliseconds actually are.

Three launch kinds, three different costs

Platforms classify a launch by how much state already exists. A cold start begins with no process for the app, so the platform must create one, initialize a runtime, load code, and construct the app object graph before any view exists. A warm start begins with a process still resident but no visible activity, so runtime initialization is already paid and only the activity lifecycle runs. A hot start begins with the activity still in memory behind another one, so the work is almost entirely measure, layout, and draw.

The three are not variations of one process; they are different code paths with different cost profiles. A hot start is bounded by the frame budget described in Frame Budget Arithmetic and the Real Deadline, because the process is already warm and the only question is whether one frame’s worth of layout fits. A cold start is bounded by disk and CPU work that is largely independent of the display, which is why it is measured in hundreds of milliseconds rather than in frame periods.

Platform telemetry separates two endpoints even within a cold start. The first is the moment the first frame is submitted for composition, often called time to initial display. The second is the moment the app reports that it has finished populating that frame with content it fetched asynchronously, sometimes called time to full display. The gap between them is a design choice: an app can show a skeleton quickly and fill it later, and the two metrics move in opposite directions. Any comparison of launch numbers is meaningless without knowing which endpoint was recorded.

What happens before the app’s own code runs

On a platform that preloads a shared runtime, the app process is created by forking an already-initialized template process. That template was started at boot and has already loaded the framework classes, inflated common resources, and initialized the runtime’s heap. Forking copies the parent’s page tables and marks its pages copy-on-write, so the child begins with a large amount of read-mostly memory mapped and no work to do to populate it. The cost of the fork itself is proportional to the number of mapped pages rather than to the app’s code, and the cost of the child’s first writes is proportional to how much of that shared image it dirties.

This is why the same app can cold start in different times on devices with the same CPU: a device under memory pressure has a template process that was killed and must be restarted, and a device with a different preloaded class list has a different amount of work already done. The consequence for time accounting is that the first measurable stage of a cold start is not uniform across devices, which is why a launch figure quoted without the device’s memory state is a figure about that device rather than about the code.

The next stage is linking. A dynamically linked app resolves its dependencies before main runs, and on mobile the dependency graph includes a system framework whose size dwarfs the app’s own. Modern linkers cache the result of that resolution as a launch closure: a precomputed list of images, symbols, and fixups that can be applied without re-running the general-purpose search. The first launch after an install or an update pays the full resolution; later launches read the closure. That asymmetry is large enough that a single cold start measurement after installing a build is not representative of steady state.

The app-controlled segment

Once the runtime is up, the app’s own initialization runs in a fixed order that is easy to get wrong. On Android, content providers declared in the manifest are created and their onCreate methods run before Application.onCreate. An app that puts database opening or preference loading in a provider, because a provider was a convenient hook, has moved that work ahead of everything else, including the code that would have deferred it. Provider initialization order between independent providers is not something the app controls, so two providers that both touch the same file can produce an order-dependent startup cost.

Application.onCreate follows, then the launcher activity: construction, onCreate, onStart, onResume. Only after onResume does the framework schedule the first traversal, which measures the view hierarchy, lays it out, and records draw commands. The traversal is where view count turns into time, and it is quadratic in the worst case for nested weighted layouts, because a weight forces a second measurement pass over the children.

First-frame submission happens after that traversal and after the render thread has consumed the recorded commands. On a cold start the render thread is itself being created, so the first frame also pays thread creation, GPU context setup, and shader compilation for whatever the first screen draws. Shader compilation is the stage most likely to be invisible in a profile that only samples the main thread, and it is the reason a first frame can be submitted on time and still arrive late.

Which stage dominates, and how the ratio moves

For a small app, the dominant costs are usually process creation and framework initialization, both of which the app cannot change. For a large app, the dominant costs shift to class loading and verification, provider and application initialization, and first-screen inflation. The shift is not gradual: an app that crosses a threshold of referenced classes starts paying verification cost for classes it never executes, because the runtime verifies a method when the class is first used and the verifier walks bytecode that the method may never reach.

Two levers change the ratio predictably. The first is ahead-of-time compilation: code compiled to native instructions at install time is not interpreted and not verified at launch, at the cost of install time and storage. The second is profile-guided compilation, where the runtime records which methods actually execute and compiles those, leaving the rest to be interpreted or just-in-time compiled on first use. Both are covered in Class Loading and Verification Cost on the Startup Path, because the accounting is the same: every class touched before the first frame is a cost paid on the critical path, and every class not touched is free.

Storage speed sets the floor. A cold start reads the executable, the resources it inflates, and any data the first screen needs. On a device with slow flash and no page cache warmth, those reads are tens of milliseconds each in the worst case, and they serialize with CPU work rather than overlapping with it, because the CPU cannot proceed until the bytes arrive. This is why the same build shows a wide spread of cold start times across a fleet: the median is a property of the code, and the tail is a property of the storage and the memory situation.

Where the clock starts

Launch time is only well defined once the start point is fixed, and every candidate start point gives a different number. Starting at the tap excludes the launcher’s own work and the input dispatch latency. Starting at process creation excludes the platform’s decision to launch. Starting at the first instruction excludes linking. Each choice is defensible for a different question, and mixing them produces comparisons that look precise and mean nothing.

The end point has the same problem. A frame is submitted when the app hands a buffer to the compositor, but it is displayed one or more vsync periods later, after composition and after the panel’s own latency. An app that submits its first frame at 300 ms has not shown anything at 300 ms. Platform tooling usually reports submission rather than photon latency, and the difference is a fixed offset of one to three refresh periods depending on how many buffers are in flight, as described in Buffer Queue Depth and Added Display Latency.

Failure modes that look like slow code

The most common misattribution is a cold start that is slow only on the first launch after an update. That launch pays ahead-of-time compilation or profile installation, and it can be several times slower than the second launch with no change to the code. Comparing a post-install measurement against a steady-state measurement from an older build produces a regression that does not exist.

The second is background initialization that runs concurrently with launch and steals CPU. Work started from a provider or from an application-level initializer on another thread does not disappear from the launch critical path; it competes for the same cores, and on a device with few big cores it delays the main thread’s own work. The launch looks slow and the main thread looks idle, which is the signature of contention rather than of a long method.

The third is a first frame that is submitted on time but never appears because the app blocks the main thread immediately afterwards. A launch that starts a synchronous network call in onResume submits nothing until the call returns, and the reported time to initial display becomes the network’s latency. The stage accounting still adds up; it just includes a stage that has nothing to do with startup.

References

  1. App startup time — Android Developers
  2. Android Runtime (ART) and Dalvik — Android Open Source Project
  3. dyld, the dynamic linker — Apple open source distributions