Skip to content

05
Writing

How I Cut Cold Start Time in Half and Found a Config Bug Hiding Behind It

A writeup of a startup-performance investigation that turned into a configuration bug fix — and then, while writing it up, into three more.

2026-08-03 · EN · 17 min · revised —

Diagram: before, the app starts up step by step (package info, Firebase, Remote Config, network listener, auth and SSO, other tasks) and draws its first frame at about 1,200 ms; after, the steps run in parallel and the first frame arrives at about 450 ms. Below: the hidden bug, a disconnected real-time listener that kept the latest config values from the app, and the fix that restored live updates.

As part of an ongoing effort to make the app feel faster on first open, I set out to reduce cold start time. Along the way I found and fixed an unrelated bug that had quietly been preventing configuration changes — including new menu items — from reaching users in real time, even when the system reported those changes as fully live. This post covers both, since the second problem only became visible because I went looking for the first. I called the effort internally Cold Path. One note before any of it: the fixes described under What I Got Wrong are committed and pushed, not yet deployed anywhere — I’ll come back to exactly what that means near the end.

Background: What Startup Looked Like Before

The app uses Firebase Remote Config to control menus, feature availability, and force/optional update prompts without shipping a new binary for every change. Before any screen can render, the app also needs to initialize several services — analytics, push notification registration, a network connectivity listener, and a check for pending store updates — what I’ll refer to here as core initialization.

Historically, core initialization ran as a single sequence of roughly fifteen await steps, executed one after another, and the app did not call the function that renders the first frame until every step had completed.

Diagram: sequential initialisation (package info, Firebase, Analytics, Remote Config, push, network listener, SSO, login, then a delayed first frame) reordered into parallel initialisation: the first frame comes first, then three branches run side by side.

Measurement Setup

Every number in this post comes from the same script, run identically on both sides of the change. Stating the setup up front, because a startup benchmark with an unstated harness is just a number someone made up:

  • Device Android Emulator sdk_gphone64_arm64 (AVD "Medium Phone API 36.0"), Android 16 / API 36
  • Build mode --profile, flavor staging — not debug
  • Toolchain Flutter 3.41.6
  • Harness scripts/measure_startup.sh, identical on both branches
  • TTN launch method adb shell monkey cold-launching an already-installed profile APK, with am force-stop between runs and no tooling attached
  • TTFF launch method flutter run --profile --trace-startup, reading timeToFirstFrameRasterizedMicros
  • Sample size TTN n=10 per condition; TTFF n=3 per condition
  • "Slow" network adb emu network speed edge
  • Reported statistic median

Definitions, precisely. Two metrics, and they are not interchangeable:

  • TTN — Time to Navigation decision. From the start of Dart main() to the moment the splash controller picks a destination — the checkpoint immediately before the force-update / web-restore / login / landing branch. Not first frame, not "the UI can accept a tap": the point at which the app knows where it is going and has the state it needs to go there. Implemented as a Stopwatch started in main() and a checkpoint logged to logcat, parsed back out by the harness.
  • TTFF — Time to First Frame rasterized. Flutter’s own timeToFirstFrameRasterizedMicros from --trace-startup.

Compare TTN to TTN and TTFF to TTFF, never across. They are measured by different mechanisms: TTFF’s harness attaches a VM service and debug adapter, real overhead that no end user tapping an icon ever pays. TTN’s harness attaches nothing.

Neither metric includes native launch overhead — the time Android spends starting the process and booting the Flutter engine before Dart main() runs. That overhead is constant and untouched by this work, so every percentage below is larger than the end-to-end improvement a user perceives from tapping the icon. I'll come back to this in Limitations.

Why It Felt Slow

With that instrumentation in place, I recorded a baseline before changing anything.

Under normal conditions, TTN averaged 2,490 ms; under the throttled profile, 2,403 ms. TTFF averaged 3,379 ms and 2,497 ms respectively.

The shape of those numbers is the whole story. TTFF was worse than TTN on a good network — the app was taking longer to paint a single frame than to finish deciding where to navigate. That only happens if you have explicitly told it to.

The splash screen wasn’t slow because the device was slow. It was blank because I had asked it to wait for work it didn’t need yet in order to paint a single frame.

Parallelizing Initialization

I grouped the fifteen sequential steps by actual data dependency, collapsing them into five dependency-ordered phases with independent work inside each phase running concurrently. And I moved the call that renders the first frame to run immediately after the Flutter binding initializes, with core initialization kicked off alongside it rather than in front of it.

// Conceptually, before:
await coreInit();       // ~15 steps, strictly sequential, gating the frame
runApp(App());

// After:
runApp(App());          // first frame is no longer gated
unawaited(coreInit());  // proceeds concurrently, grouped by dependency

Two steps — syncing the push notification token and checking for a pending store update — had no first-frame dependency at all and were converted to explicit fire-and-forget background tasks, using Dart’s unawaited() to make that intent explicit rather than implicit.

The FCM one was worth backgrounding on its own merits: on iOS, waiting for an APNS token can take several seconds, and if the user hasn’t answered the notification permission dialog yet, it doesn’t resolve at all until they do. That is an unbounded, human-paced wait sitting in the middle of a startup path.

I also replaced a flat one-second splash delay with an adaptive floor: the splash stays visible until a minimum of 800 ms, topping up only whatever time real initialization work didn’t already consume. On a slow device — the case we’re actually optimizing for — it adds exactly zero. The old fixed delay penalized fast launches and slow launches identically.

What did not need isolates

The obvious question about parallelized startup is why none of it runs in an isolate via compute(). It doesn't, deliberately.

Almost every step is a platform-channel call — package info, Firebase initialization, push registration, secure storage reads — and those can’t be made from an ordinary background isolate without routing through BackgroundIsolateBinaryMessenger anyway. The rest is network. The only meaningful CPU work in the whole path is JSON decoding of Remote Config payloads, and that happens lazily at the read sites, not during init. Spawning an isolate costs tens of milliseconds to save approximately zero. Future.wait over I/O-bound work was the right tool; compute() would have been cargo cult.

Rethinking How the App Loads Remote Configuration

Remote configuration turned out to be involved in both the performance problem and a separate correctness problem.

On the performance side, I moved to a cache-first loading strategy: loading strategy (Firebase’s own “Strategy 3”): on startup, previously fetched values are activated immediately from local storage — near-instant, no network — while a fresh fetch runs in the background for next time. A real-time listener, already present in the app, picks up changes published while the app is running, independent of the periodic fetch cycle.

The one exception is a genuine first run, where no config has ever been cached on the device. There, activate() has nothing to activate and every read would return empty — the menu grid would render blank for a few seconds — so the app does a bounded wait for one real fetch rather than activating nothing.

One detail here is the most instructive thing in the whole change, and it runs against the theme of the rest of the post. Remote Config initialization is deliberately excluded from the concurrent batch and awaited on its own:

// Independent of each other; all only need the Firebase app to exist.
await Future.wait([
  Analytics.init(),
  PushNotificationRouter.init(),
  setNetListener(),
]);

// Awaited on its own, NOT grouped into the Future.wait above - screens
// mounted right after splash read config-backed values (API base URL etc.)
// synchronously, so this must be the only thing left running by the time
// init returns, with nothing else able to interleave with its internal awaits.
await Frc.init(forceRefresh: forceRefresh);

Parallelizing that step is technically legal — it has no data dependency on the others — and it breaks the app, because the first screens after splash read config values synchronously during mount. Concurrency isn’t free just because the dependency graph says it is; the dependency graph doesn’t know what the widget tree does on frame one. Nine lines of comment in the source explain why not to “optimize” it back.

The bug that had nothing to do with speed

That work would have made configuration loading faster. It would not, on its own, have explained why a published menu change wasn’t reaching users.

The real-time listener was already receiving those changes. Nothing downstream was subscribed to act on them.

Flowchart: the server publishes a new config to the real-time listener. If a handler is registered for that key, the menu or grid refreshes and updates at once; if not, nothing happens.
// Conceptually, before:
onConfigUpdated(() {
  // fires correctly — nothing registered here
});

// After:
onConfigUpdated(() {
  refreshMenus();
  refreshServiceGrid();
});

The listener had been firing correctly the entire time. The update simply had nowhere to go once it arrived, so the screen stayed exactly as it was until something else — typically a reinstall — forced a full reload from scratch.

Why nobody caught it for months. Two reasons, and both are worth naming because they generalize:

  1. The fallback masked it. The periodic fetch cycle did eventually deliver the same values, so the change wasn’t lost, only late — late by up to the minimum fetch interval. “Slow” doesn’t page anyone. “Broken” does. This is the failure mode of every system with a redundant slow path behind a fast one: the fast path can be entirely dead and the system still looks like it works.
  2. The tests couldn’t see it. Unit tests around Remote Config stub the fetch-and-activate call. The real-time stream isn’t part of that surface, so a test suite that fully passes tells you nothing about whether anything is subscribed to it. The bug lived in the seam between two mechanisms, and every test covered exactly one of them.

Rather than hardcoding which keys refresh what, the fix uses a small registry: components register a callback against a config key on mount and deregister on dispose, and both the real-time listener and the background fetch dispatch through the same registry. The fetch path can’t report which keys changed, so it notifies every key that has a registered listener.

An early version of this fix tried to avoid a visual flicker by preserving the previous menu state whenever new data looked momentarily empty. That broke an invariant the UI relied on — the number of tabs was expected to always match the number of items displayed — and surfaced as a runtime error during testing. I resolved it by always syncing to the actual current count instead of special-casing emptiness, and verified by manually cycling the item count through several states with the screen mounted.

Because this change also touched tab lifecycle behavior, I shipped it behind a kill switch, separate from the startup-timing work.

The kill switch is itself a Remote Config boolean, which sounds circular given the bug I had just fixed. It survives the irony by design: it’s checked at dispatch time rather than at listener-registration time, defaults to enabled if unreadable, and reaches the device through the background fetch path even if the real-time channel is the thing that’s broken. Turning it off stops UI refresh callbacks from running; it does not stop config from activating. Worst case, behavior degrades to what it was before this work — values are correct on next launch — rather than to stale values with no path forward.

A Second Bug Hiding Inside the First Fix

While bounding how long the app would wait for a fresh configuration fetch before falling back to cached values, I added a timeout on the caller side. It shipped, and looked correct.

Several hours later, a closer look showed the timeout only stopped the app from waiting. It did not stop the fetch. The request could keep running in the background for up to a minute past the point the app had already moved on — the SDK’s default fetch timeout — consuming a connection for a result nothing was going to use.

dart

// Caller-side: bounds how long WE wait. Does not cancel anything.
await refreshFuture.timeout(timeout, onTimeout: () {});

// Operation-side: bounds the work itself.
await instance.setConfigSettings(RemoteConfigSettings(
  fetchTimeout: const Duration(seconds: 10),   // was the SDK default: 60s
  minimumFetchInterval: ...,
));

Future.timeout() returns a new future that completes early. The original future — and whatever socket it's holding — carries on to completion, oblivious. Dart futures have no cancellation, by design. If you need work to actually stop, you need a handle on the operation: fetchTimeout here, CancelToken if you're on Dio, HttpClientRequest.abort() at the bare-metal level.

No user-facing behavior broke because of this. But abandoned work kept running invisibly, and “invisible” is the part that should bother you.

The caller-side timeout is still there, and should be — the two solve different problems. The caller-side bound is what lets the force-update check say “I’d like fresher config, but I will not hold up launch for it,” while the fetch continues and its result still lands for whoever reads next. That’s deliberate. The bug was only ever that nothing else was bounded underneath it.

Results

MetricBeforeAfterChangeTTN, normal network2,490 ms1,556 ms−37.5%TTN, throttled network2,403 ms1,564 ms−34.9%TTFF, normal network3,379 ms444 ms−86.9%TTFF, throttled network2,497 ms501 ms−79.9%

I’m reporting TTN, not TTFF, as the headline number. TTFF improved by 87% because it measures precisely what I unblocked — reaching one rendered frame. But the initialization work still has to happen; it’s concurrent now, not gone. Leading with 87% would be technically true and functionally a lie about what a user feels. TTN improved by 37.5%, and 37.5% is the honest number.

The two numbers being so far apart is itself the finding. A 50-point gap between “painted a frame” and “actually ready” is the measurement telling you which one you were optimizing.

One more caveat belongs directly under this table, not buried in Limitations: these numbers were captured before the four correctness fixes below existed. None of the four touch a timing-critical path in a way I’d expect to move TTN or TTFF — but “I’d expect” is exactly the kind of sentence this post has already caught itself getting wrong once. I haven’t re-run the benchmark since those fixes landed, and I will before treating this table as final.

What I Got Wrong

Four things, three of which I only found while writing this post — which is a reasonable argument for writing these posts.

Where these four fixes actually stand right now: committed and pushed, not deployed to staging or production, and not exercised outside my own local profile build. Everything below describes what changed in the code and why. It does not mean any of it has survived contact with a real environment yet. I’m leaving the past tense in place because that’s what happened in the code — but “fixed in the diff” and “verified” are two different claims, and I don’t want this post to blur them the same way the caller-side timeout blurred “stopped waiting” and “stopped running.”

1. My “explicit” fire-and-forget calls were explicit about the wrong thing. I used unawaited() to document intent, and stopped there. unawaited() documents that you don't want the result; it does nothing about failure. Of the fire-and-forget calls I introduced or inherited, one was properly wrapped in its own try/catch and two were bare — including a store-version check that makes a network request. A rejection there becomes an uncaught async error with no caller left to handle it. Fixed by giving each background task ownership of its own failure, plus a timeout, since the whole point was that nobody is waiting on it.

2. A failed init poisoned every retry. Core initialization caches its in-flight future so concurrent callers share one run. If that future failed, the cached failure stayed cached — so every later call awaited an already-failed future and the retry could never actually re-run. Worse, because splash awaits init inside a Future.wait, a throw meant the navigation branch was never reached at all: the app sat on the splash screen indefinitely with no recovery but a force-quit. It now fails open — logs, records to the crash reporter, clears the cached future, and lets navigation proceed with degraded state. A logged-out launch is recoverable. A hung splash isn't.

3. The performance trace leaked on exactly the launches worth measuring. The custom trace around core init was stopped by straight-line code after the init steps. Any throw skipped trace.stop(), and Firebase silently discards traces that are started but never stopped. So failed and slow-enough-to-fail launches were systematically absent from the console — a survivorship bias baked directly into my own instrumentation. Moved to try/finally.

4. The real-time listener had an activation race. onConfigUpdated fires when new values have been fetched, not when they're readable — reads keep returning the old values until activate() completes. The listener called activate() without awaiting it and then immediately dispatched to every registered callback. So a live update could refresh the UI using pre-activation values, i.e. repaint exactly what was already on screen.

That last one deserves the emphasis: its symptom is indistinguishable from the original bug. A published change appears not to arrive. I fixed “no handler is subscribed” and left “the handler runs too early” sitting right behind it, in the same twelve lines, for the entire time I was writing a post about the first one.

And one thing that’s still wrong. The real-time listener isn’t registered on web at all — an early return skips it. Web gets cache-first loading and the background fetch, but no live updates, and there’s a 200 ms settle delay plus a retry wrapper around config reads to work around a transient JS-interop error after page refresh. That’s a workaround, not a fix, and it’s still there.

None of the four fixes above — including the web-parity gap that’s still open — have run anywhere but a local build and a close read of the diff. The plan for closing that gap is in What’s Next, not in this section, on purpose: I don’t want a bug list and a verification status sharing the same paragraph.

Limitations

Stated plainly, because the numbers above are more fragile than a table makes them look:

  • One emulator, one machine, one afternoon. No real-device diversity, no thermal throttling, no real flash storage, no memory pressure from other apps. An emulator’s I/O goes through the host filesystem and is faster than any entry-level device we support.
  • Native launch overhead is excluded from both metrics, so the true end-to-end improvement is smaller than the percentages shown.
  • The host machine was under load — a shared dev Mac with a sustained load average around 4.2–4.4 during measurement, browsers and editors open. Both sides were measured under comparable conditions, but absolute values carry that noise.
  • “Slow network” is an emulator preset (edge), a proxy for a bad connection and not a real cellular network.
  • TTFF’s n=3 — each run costs a full rebuild and reinstall, so n=10 would have taken hours. It’s a supporting metric with a small sample, treated accordingly.
  • One after-batch was discarded. The first post-install batch showed a median of 5,218 ms, far worse than baseline. That traced to Android’s post-install ART verification and dexopt: the first several cold starts after installing a new APK are genuinely slower for reasons unrelated to app code. Re-running on the same warm install gave 1,556 ms, consistent with both the throttled batch (1,564 ms) and an isolated manual measurement taken during implementation. The reported numbers use the warm-install batch; the discarded CSV is still in the repo rather than deleted.

Point six is the one I’d want a reviewer to push on hardest, because “we re-ran it and got a better number” is what a mistake sounds like too. The reason it survives scrutiny is that the re-measured value agrees with two independent measurements taken under different conditions, and the discarded one agrees with nothing.

One more limitation, not measured but disclosed: I haven’t checked the splash-to-first-frame handoff on a physical device. I don’t know whether the transition from the native launch screen to Flutter’s first frame produces a visible white flash, and this benchmark can’t answer that — it needs actual eyes on an actual screen, which I haven’t had time for yet. I’m naming it here instead of leaving it implicit, since a startup-performance post that goes quiet on this specific failure mode would be exactly the kind of gap this post is otherwise trying not to leave.

Lessons Learned

A performance fix and a correctness fix can hide inside the same investigation. I went looking for startup latency and found a config delivery bug that had nothing to do with speed. The reason it was findable at all is that instrumenting a system forces you to state what it’s supposed to do.

A caller-side timeout does not stop the work it was waiting on. If a background operation needs to actually stop, the bound has to live at the layer that controls the operation, not the layer that’s merely observing it.

A redundant fallback will hide a dead primary path indefinitely. The real-time listener was broken for months and nothing alerted, because the slow path kept delivering. If you build a fast path behind a slow one, you need a signal that specifically says the fast path worked — otherwise you’ve built a system that can silently degrade to its own worst case.

Report the metric closest to what users feel, not the one that looks best. The more impressive number and the more honest number were not the same number here.

Measure before optimizing, keep the raw baseline, and keep the runs that make you look bad. The discarded batch is more useful in the repo with an explanation than deleted.

Write it up. Three of the four bugs in What I Got Wrong were found by re-reading my own code closely enough to describe it to someone else. None were found by the tests, which all pass.

A commit is not evidence. Reading a diff convinced me four things were fixed. None of those four have been proven against anything but that same diff yet. I’m treating “compiles and reads correctly” and “is actually fixed” as different claims until staging tells me otherwise.

What’s Next

Staging, first, before anything else in this list matters. Everything in What I Got Wrong is committed and pushed, not deployed. Before I call any of those four fixes done, they need to survive:

  • A staging deploy — the minimum bar, and the one that hasn’t been cleared yet.
  • A manual kill-the-network-mid-init test, to see whether the fails-open path in Bug #2 behaves the way its code reads, not just the way I want it to read.
  • A rapid, repeated config-toggle test, to see whether the activation race in Bug #4 is actually closed rather than just less likely to show up in a quick check.
  • One run on a physical device, specifically to look at the splash-to-first-frame handoff for the white-flash question I couldn’t answer in Limitations.

I’ll update the numbers and the bug write-ups above once that’s done — or say plainly if staging finds something my own second read of the code didn’t.

Real-device data. The next step after that is validating against the automatically collected _app_start trace and the custom core-init trace in Firebase Performance Monitoring once this ships broadly. That's real device diversity, real networks, and it includes the native launch overhead this local benchmark deliberately excludes. The custom trace is already instrumented and running in production — which means the real-device p50/p90 largely already exists and just needs to be read, before and after.

Fetch volume. The periodic fallback fetch I shortened as a safety net behind the real-time listener increases fetch frequency at the SDK level. Remote Config is moving to usage-based pricing for high fetch volumes later this year, so I’m watching that path’s usage rather than assuming today’s configuration stays free.

Web parity. The real-time listener still doesn’t run on web, and the JS-interop workarounds around config reads deserve a real fix rather than a settle delay.

Acknowledgments

Thanks to the teammates who reviewed the timing changes and caught the tab-state regression before it shipped, and to the users whose reports — a menu that should have been there, and wasn’t — were the actual signal that sent me looking in the first place.

Further Reading

2026-08-03 · EN · 17 min · revised — · first published on Medium

Further reading: medium.com/@GuidoRefices