A five-second cold start doesn't annoy your users. It quietly kills your retention.
The people you lose to a slow launch never complain. They don't leave a one-star review. They just don't come back — and three months later it surfaces as a "retention problem" no one can trace to a cause. This is the full version of that story: what cold start actually is, how to measure it properly, where the time hides, and the specific things to cut.
Cold, warm, hot — and why only one matters here
Three launch states, and they are not the same problem:
- Cold start — the process isn't running. The OS has to create it, spin up the engine/runtime, run your init, and render the first frame. This is the slowest and the one that greets new and returning users after a while away.
- Warm start — the process was killed but some things are cached; faster.
- Hot start — the app is resumed from memory; near-instant.
Optimise cold start. It's the worst case, it's what a first-time user feels, and it's the number that correlates with whether they come back.
Measure it before you touch anything
Almost every team optimises by guessing. Don't. Get a number first, on a real mid-range device, not a flagship on office Wi-Fi.
Android
- Quick signal:
adb shell am start -W <package>/<activity>reportsTotalTimeandWaitTimeper launch. - Real rigour: a Macrobenchmark with
StartupTimingMetricgives you Time-to-Initial-Display and Time-to-Full-Display across runs. - Field data: Play Console → Android vitals shows cold-start times from real users, segmented by device — that's the distribution that actually matters.
Flutter
flutter run --trace-startup --profilewritesstart_up_info.jsonwithtimeToFirstFrameMicrosand time-to-first-frame-rasterized.- DevTools → Performance timeline to see what's blocking the first frame.
- Instrument the gap from
main()to your first meaningful frame yourself and log it.
Both
- Firebase Performance captures an
_app_starttrace from real users automatically — the cheapest way to watch the field number over time.
Pick one, take a baseline, and write it down. You can't defend an improvement you never measured.
Where the time actually goes
When you profile that first window, the culprits are almost always the same handful:
- Eager SDK / plugin initialisation — analytics, crash reporting, ads, remote config, and half a dozen third-party SDKs all initialising synchronously before first frame.
- A dependency-injection graph built up front — constructing objects the first screen never touches.
- Synchronous work on the main thread — JSON deserialization, disk reads, decryption, migrations running where they block rendering.
- A blocking network call at launch — waiting on config or auth before showing anything.
- Heavy first-frame build — an over-complex initial widget tree or layout pass.
- App size and cold code paths — more code to load and JIT/AOT resolve; oversized assets and fonts loaded eagerly.
The fixes, in rough order of payoff
- Defer non-critical initialisation. Anything not needed to render the first screen runs after first interaction, not before it. Most analytics, ad, and remote-config init can wait a few hundred milliseconds.
- Make dependency injection lazy. Construct on first use, not at boot. GetIt's
registerLazySingleton, Hilt's lazy bindings — the first screen should build only what it needs. - Get work off the main thread. Move deserialization, decryption, and migrations to a background isolate (Flutter) or a worker/coroutine (Android) so the first frame isn't blocked.
- Kill the blocking launch network call. Render from cached config immediately, then refresh in the background. A launch should never wait on a round trip.
- Cache what you recompute every launch. Config, feature flags, parsed data — read from disk once, not rebuilt from scratch each start.
- Shrink the first frame. Simplify the initial widget tree/layout; defer heavy sections until after paint.
- Trim app size. Enable R8/shrinking, split by ABI/deferred components, and stop eagerly loading fonts and assets the first screen doesn't show. Smaller binaries cold-start faster, especially on low-end Android.
None of these are features. They're all "stop making the user wait for work that could happen later."
Then make it a budget, not a one-off
The reason cold start silently regresses is that no one owns the number. Fix that:
- Add a startup trace you track release over release (Firebase Performance or your own metric).
- Put a budget on time-to-interactive and fail CI or flag the release when a change blows it.
- Watch the field distribution, not your dev device — your users are on hardware you don't own.
Startup then becomes something the team can see and defend, instead of a number that quietly drifts until it shows up in the retention chart.
A 20-minute audit you can run this week
- Measure cold start on a real mid-range device (
adb shell am start -Wor--trace-startup). - List everything that runs before your first screen is interactive.
- For each item ask: does the first screen actually need this? Usually two or three don't.
- Defer those; move any blocking work off the main thread.
- Re-measure. Bank the win. Add the metric to your dashboard so it stays won.
Most teams don't know their app's cold-start time. That's the first thing to fix — because everything above is invisible until you have the number.
This is the kind of work I do as a consultant — mobile performance audits and Flutter/Android optimisation for teams whose app has quietly gotten slower. If that's you, book a call.