Cold start is the one piece of an app that every single user experiences, in full, before they experience anything else. Munchable's version of it is five lines, and the interesting thing about those five lines is not what they wait for. It is what they are not allowed to do.
const [fontsLoaded, fontError] = useFonts(fontMap); const hydrated = useProfile((s) => s.hydrated); const taxonomyHydrated = useTaxonomy((s) => s.hydrated); const ready = (fontsLoaded || !!fontError) && hydrated && taxonomyHydrated; if (!ready) return null; With SplashScreen.preventAutoHideAsync() called at module scope and hideAsync() fired in an effect once ready turns true, the return null is never visible. The splash stays up, and the first thing drawn is a finished screen.
The rule I hold myself to is that every one of those three conditions has to be a promise that cannot reject. Not "should not". Cannot. A launch gate with a failure mode is an app that bricks on somebody's phone in a way you will never reproduce.
The font error that counts as loaded
const ready = (fontsLoaded || !!fontError) && ... useFonts hands back a loaded flag and an error. Treating the error as equivalent to loaded looks like sloppiness and is the whole point: there are two bad outcomes available when a font fails, and only one of them is recoverable.
If the gate requires fontsLoaded, a font that fails to load holds the splash screen forever. The user sees a cream square with a cookie on it and force quits. If the gate accepts the error, the app renders with the system font. The type is wrong, the brand is wrong, a few labels wrap differently, and every button still works.
The app loads three families across eight faces, bundled through expo-font rather than fetched at runtime, which makes a failure unlikely and not impossible. Unlikely failures with unrecoverable handling are exactly the ones that reach production.
Two hydrates that resolve in a finally
The second and third conditions are stored state: the health profile (persisted via zustand) and the on-device ingredient knowledge the rules engine reads.
Both are in the gate for the same reason, which is not performance but correctness of the first frame. Render before the profile has loaded and the home screen briefly looks like a fresh install, and the conditions picker briefly shows nothing selected. One frame of that is worse than 200 milliseconds of splash, because the user reads it as data loss.
The knowledge hydrate is where the "cannot reject" rule earns its keep. Its docblock states the invariant, which I am paraphrasing here only to keep the punctuation in line with this blog's house style:
hydrateis one storage read at launch; loads a cached snapshot when it is newer than the bundled one. ALWAYS resolves withhydrated: true(bad cache becomes a deleted cache, bundled data used), so the launch gate can never blank the app.
In code that is a try with two escape hatches and a finally that cannot be skipped (the bundled version constant is renamed here for readability):
hydrate: async () => { if (get().hydrated) return; let lastCheckedAt = 0; try { const stored = await readStoredTaxonomy(); lastCheckedAt = stored.lastCheckedAt; if (stored.overlay && isNewerVersion(stored.overlay.version, BUNDLED_VERSION)) { loadTaxonomyOverlay(stored.overlay); } else if (stored.overlay) { // An app update shipped a newer bundled snapshot: the cache is stale. await clearStoredOverlay(); } } catch { // Corrupt or unloadable cache: drop it and run on the bundled snapshot. resetTaxonomyOverlay(); await clearStoredOverlay(); } finally { set({ hydrated: true, version: getTaxonomyVersion(), lastCheckedAt }); } }, Three things in there are worth copying.
The bundled copy is the floor, not the fallback. The app ships with a snapshot inside it. The cache can only ever be an improvement on that, and it has to prove it by version. This is what lets the whole product work with no network at all, which matters because the place people use this app is the middle of a supermarket, which is reliably the worst signal in the building.
An app update can make a cache stale in the other direction. The device holds a snapshot from February and then installs a release whose bundled copy is from March. The cached one is not corrupt, it is just behind, and the branch that handles it deletes it rather than keeping it around to be compared again on every launch. The failing version of this logic is the one that says "use the cache if there is one".
Versions are compared as numbers. They are epoch milliseconds, stored as decimal strings, and the helper exists purely so nobody ever compares them as text:
/** Versions are epoch-ms decimal strings; compare numerically, never as text. */ export function versionNumber(v: string | null | undefined): number { const n = Number(v); return Number.isFinite(n) ? n : 0; } A string comparison gets this right for years and then quietly stops when the digit count changes. An unparseable value reads as 0, which is the bundled copy, which is the safe answer.
The condition that is deliberately missing
There is a fourth thing happening at launch: the session restore. It is not in the gate.
useEffect(() => { initAuth(); }, [initAuth]); Fired and forgotten. The app renders while it is still in flight, and its restored flag is read by the screens that need an account rather than by the boot sequence. Munchable requires an account to scan, so the temptation to put it in the gate is real: it would make every screen below able to assume a known session.
It stays out for two reasons. The restore talks to a network, so putting it in the gate means a slow connection becomes a long splash screen, and a failing one becomes an app that will not open. And the screens that need it are a minority: the ones outside the authenticated group ended up behind a hook rather than a layout, which is a check in the handful of places that actually care.
There is even a line that exists only so the gate cannot lock the app when there is no backend configured at all:
// No backend keys (local dev): nothing to restore, and sign in is not // reachable, so mark restored so the account gate does not lock the app. if (!isSupabaseConfigured) { set({ restored: true }); return; } A build with no keys is a build where "not signed in" is permanent, and a gate that waits for a permanent condition is a dead app.
What happens after the gate opens
The interesting work is all after the splash. Once the session is known, the app asks the server whether there is newer knowledge, with If-None-Match carrying the version it holds, at most every six hours, and it is allowed to fail in silence: a thrown request leaves the bundled or cached copy in use and the app does not mention it. It also learns about new versions opportunistically, from a header on requests that asked about something else.
That refresh has one subtle line in it, and it is the only concurrency bug in the file that actually bit:
// Claim the single flight BEFORE the first await. Two same-tick callers must // share one request; with the session check ahead of this assignment both // passed the guard, awaitTaxonomyRefresh() tracked only the survivor, and a // read could be computed against an older snapshot than the server had just // used. inflight = (async () => { ... })(); The guard was if (!(await hasSession())) return; placed before inflight was assigned. Two callers in the same tick both got past it, two requests went out, and the promise other code was awaiting belonged to only one of them. Claiming the slot synchronously, then doing the session check inside the claimed promise, is the fix, and it is the kind of ordering that reads as arbitrary until you have seen it fail.
The short version
If you are building the launch path of a mobile app, the useful question is not "what do I need before I render". It is "what happens to this user if one of these never resolves".
Three gates here, each one a local read with a guaranteed answer. Everything that touches a network is on the other side of the splash screen, where failure is a quieter kind of event.
You can watch the cold start for yourself at app.munchable.app, which is the same codebase the Android build comes from, and the public pages describing what it checks are at munchable.app/conditions and munchable.app/answers.
