Splash screens exist to cover up work that hasn't finished yet: fonts loading, auth tokens refreshing, a local database opening. That's a reasonable job for a splash screen to do. The problem is what happens underneath it, where most teams write startup code as a rough sequence of steps and hope the timing works out.
It usually does, in testing, on a fast device, on a fast connection. Then it doesn't, for a fraction of real users on a slow network or an older phone, and the bug report says something vague like "app opens to a blank screen sometimes."
The Race Nobody Designed On Purpose
A typical mobile app startup kicks off several things at once: reading cached auth state from disk, validating a token against the network, opening a local database, fetching a remote feature-flag config, and initializing whatever crash reporting or analytics SDK the team uses. None of these naturally happen in a guaranteed order unless someone explicitly wires dependencies between them.
The bug shows up when screen B assumes screen A's data is ready, but under real-world network latency, A hasn't finished. Nothing crashes outright, so it doesn't show up in obvious QA. Instead you get an inconsistent UI: a profile screen showing a loading spinner forever, a feature flag defaulting to "off" because the config fetch was still in flight when the check ran, or worse, a screen rendering with a null value that only crashes three taps later.
Name the Dependencies Explicitly
The fix is unglamorous: draw out what actually depends on what, and enforce that ordering in code instead of trusting that things usually finish fast enough. Auth state has to resolve before you know whether to show a logged-in or logged-out experience. Local cache has to open before you can safely read from it. Feature flags ideally resolve before you render anything that branches on them, with a defined default if the fetch times out.
A simple pattern that works well: model startup as a small state machine with explicit states (booting, authResolving, dataLoading, ready) rather than a pile of async calls with implicit ordering. Every screen that depends on startup data checks the state machine's current phase instead of guessing whether a particular async call has resolved yet.
Feature Flags Deserve Their Own Careful Handling
Feature flags are worth calling out specifically because they're often treated as an afterthought in startup sequencing, fetched lazily whenever convenient, when they actually gate rendering decisions that need to be consistent across a session. If your flag fetch is still in flight when a screen checks a flag's value, and that screen defaults to "off" on an unresolved flag, users on slow connections systematically see a different app than users on fast connections, not because you intended a staged rollout, but because your default-while-loading behavior became a silent second rollout mechanism nobody designed.
The fix is the same state machine discipline: block flag-dependent rendering behind an explicit "flags resolved" state, with a bounded timeout that falls back to a defined default only after genuinely waiting long enough, rather than defaulting instantly the moment a screen happens to check.
This Is the Same Problem As Deep Link Timing
If you've ever debugged why a deep link opens to the wrong screen only on a cold start, you've already met this race condition wearing a different hat. A deep link handler that fires before auth state resolves has exactly the same bug as a profile screen that renders before its data loads: code running before its dependencies are actually ready. We wrote up a longer breakdown of that specific case if you want the deep link version of this problem in more detail, including how to hold a pending link until the app's state machine says it's safe to act on it.
Testing for It Requires Artificial Slowness
Because this bug is timing-dependent, testing it on a fast simulator with a fast connection tells you almost nothing. Use network link conditioning tools to simulate 3G-equivalent speeds, and test on the oldest device your support matrix still covers. If your startup sequence only breaks under artificially slow conditions, that's exactly the population of real users who will hit it in production, just distributed across days instead of concentrated in one QA session.
Reference implementations for state-machine-driven app initialization are common in both major ecosystems; both Apple's developer documentation and Android's developer guides describe lifecycle callbacks you can hook a startup state machine into cleanly, rather than layering one on top of implicit async timing. Tools like Firebase Test Lab also let you run your app against a matrix of real device and network conditions in CI, which catches this class of bug before it ships rather than after a support ticket names it.
Crash Reporting SDKs Have the Same Startup Ordering Problem
There's a meta version of this bug worth calling out: the crash reporting or analytics SDK itself is one of the async initializers competing for startup time, and if it hasn't finished initializing when an early crash happens, you lose visibility into the exact bug you'd most want logged. Teams debugging "we have crash reports for basically nothing that happens in the first second of app life" have usually run into this directly, the crash reporter wasn't ready yet when the crash it should have caught actually happened.
The fix is to initialize crash reporting and basic logging infrastructure as close to first possible instant as your platform allows, ahead of anything else in your dependency graph, specifically so it's available to catch failures in every other part of startup. Most major crash reporting SDKs document a specific "initialize me first" pattern for exactly this reason, and skipping that guidance in favor of initializing it alongside everything else quietly blinds you to your earliest and often most impactful startup bugs.
Logging That Actually Helps Debug This Later
Even with careful state-machine design, add explicit logging at every state transition during startup, including a timestamp and the network condition if you can detect it. When a user reports an intermittent startup bug months from now, "logged in at T+0.4s, cache opened at T+1.8s, flags resolved at T+4.2s" tells you immediately which dependency was slow for that specific session. Without that logging, you're stuck asking a support-ticket reporter to describe timing they never observed directly, since the bug is, by definition, invisible to the user experiencing it beyond "the app seemed stuck."
The unglamorous truth is that most "flaky" mobile bugs reported as one-off glitches are actually deterministic race conditions that only look random because the timing that triggers them varies by device and network. Naming the dependency graph explicitly, and logging every transition through it, turns a mystery bug into a solvable one.
Retrofitting This Into an Existing Codebase
Most teams reading this already have a startup sequence built as a pile of implicit async calls rather than an explicit state machine, and rewriting it from scratch is rarely worth the risk on a shipping product. A safer path: add explicit state tracking alongside the existing code first, without removing anything, purely as an observability layer. Log every meaningful startup milestone with a timestamp, ship that for a release or two, and use the resulting data to actually see how often and how badly timing varies across your real user base before deciding how aggressively to refactor.
This matters because "add a state machine" is easy advice to give and genuinely risky advice to execute carelessly on code your whole app depends on. Understanding your actual timing distribution first tells you whether the fix needs to be a full architectural change or whether a couple of targeted fixes to the worst-offending dependencies gets you most of the benefit for a fraction of the risk.
When This Is Worth Prioritizing
Not every team needs to drop everything and fix this immediately. If your crash and ANR (application not responding) rates are already low and your support tickets don't mention intermittent blank screens or stuck loading states, this may genuinely not be costing you much yet. The signal worth watching for is a pattern of vague, hard-to-reproduce bug reports that mention timing-sensitive symptoms: "sometimes," "on my old phone," "only when my wifi is bad." Those specific phrases in support tickets are a strong indicator this exact class of bug is present and worth the investment to fix properly.
Top comments (0)