●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Expo SDK 58 is out of beta as of 10/15 (beta since 09/15). Check your dependencies before upgrading●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Expo SDK 58 is out of beta as of 10/15 (beta since 09/15). Check your dependencies before upgrading●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
One Missing Tutorial Image Was Enough to Stop the Whole App from Launching. Moving Nice-to-Have Features Off the Required Startup Path
On some devices the app never launched at all. The cause was a design where a missing tutorial image failed the whole screen build. A small wrapper that takes optional features off the startup path, and a pre-release check.
Reports came in that the app never launched on some devices. On my own device it started normally, every time.
When that happens, the instinct is to hunt on the device side: the model, the OS version, the memory. This time the cause was in my own design. I had written the tutorial images directly into the screen definition. On a device where one of those images was missing, the tutorial did not simply fail to appear. The code that builds the screen itself failed.
Never put something that is nice to have on the same road as something the app cannot live without. That is the one line I took away from this incident in a wallpaper app. This note redraws that line for an Expo project, with code I actually ran.
The symptom was "never starts", not "crashes"
What made it hard was how little there was to follow. The app did not crash after launch; the first screen was never assembled. A bug that appears mid-use can be traced through what the person was doing. Here, all that reached me was a single phrase: "it won't open".
The reason it never reproduced on my device is obvious in hindsight: my device had the image. It did not reproduce because my device met the condition, not because the code was correct. Had I drawn that distinction first, I would have saved a good deal of time spent on models and OS versions.
Stating the cause in one sentence
A required step, building the screen, depended on something that was not required, a tutorial image.
At launch an app does many things. Some of them are things the app cannot exist without; others can fail while the app itself keeps working. At the time I had lined them all up on one road without telling them apart.
What happens during launch
If it is missing
How to treat it
Build the skeleton of the first screen
Nothing can be shown
Required. A failure is a failure
Read settings and saved state
The app can run on defaults
Fall back to defaults
Tutorial (text and images)
The app is usable without it
Optional. Never take the app down with it
Prepare ads
The app is usable without them
Optional. Never make the app wait
Initialize analytics
Only recording is lost
Optional. Do not discard the failure silently
Writing this table made me a little embarrassed. Filling in the right-hand column is enough to show where the fix belongs. Before writing it, I had thought of the tutorial as part of the app, not as something separate from the conditions the app needs in order to run.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You can now list everything on your app's startup path that should be optional, and find, before shipping, the places where one missing piece stops the whole app
✦You can now drop a small function into your Expo project that runs an optional feature so that neither its failure nor its slowness drags the app down with it
✦You can now reproduce a launch failure that never happens on your own device by deliberately removing the condition, and stop it from coming back with a single audit script
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
The fix: return failure as a result, not an exception
The core of the fix is a single entrance for running optional work. If every caller writes its own try-catch, the one place someone forgets becomes the next trap. So I made one function that returns every failure, and every over-long wait, as a return value.
I ran the function below on Node 22. It works unchanged in an Expo project.
// optional-step.ts — run a "nice to have" step so its failure never takes the app down with itexport type StepResult<T> = | { ok: true; value: T } | { ok: false; reason: 'error' | 'timeout'; error?: unknown };type Options = { timeoutMs?: number; // leave it out to wait without a limit (for steps that wait on a person) onFail?: (name: string, reason: 'error' | 'timeout', error?: unknown) => void;};export async function optionalStep<T>( name: string, run: () => T | Promise<T>, { timeoutMs, onFail }: Options = {},): Promise<StepResult<T>> { let timer: ReturnType<typeof setTimeout> | undefined; const racers: Promise<StepResult<T>>[] = [ (async (): Promise<StepResult<T>> => { try { return { ok: true, value: await run() }; } catch (error) { return { ok: false, reason: 'error', error }; // do not swallow the error; hand it back as a result } })(), ]; if (timeoutMs !== undefined) { racers.push( new Promise<StepResult<T>>((resolve) => { timer = setTimeout(() => resolve({ ok: false, reason: 'timeout' }), timeoutMs); }), ); } const result = await Promise.race(racers); if (timer !== undefined) clearTimeout(timer); if (!result.ok) { try { onFail?.(name, result.reason, result.error); } catch { // a failure to log must not stop the app either } } return result;}
Three things I paid attention to. First, it does not swallow the error; it hands it back in a typed result. Second, you can set a time limit, but if you omit it, no timer is started, so a step that waits on a person is never cut short. Third, even if the logging callback onFail itself fails, the app keeps going.
An optional step only reports success or failure; what to do next is the caller's decision. I prefer that division of labor.
Giving up one image at a time
Next, how to show the tutorial with that function. The real question here was the unit at which to give up.
If one image is missing, do you skip the whole tutorial, or drop only the image and still show the text? I chose the latter. What matters to the person is the text; the image is a supplement.
import { optionalStep } from './optional-step.ts';export type Step = { id: string; title: string; image: string | null };export type Deps = { version: string; storage: { get(key: string): Promise<string | null>; set(key: string, value: string): Promise<void>; }; loadSteps(): Promise<Step[]>; checkAsset(uri: string): Promise<boolean>; show(steps: Step[]): Promise<void>; loadTimeoutMs?: number; onFail?: (name: string, reason: 'error' | 'timeout', error?: unknown) => void;};export async function maybeShowOnboarding(deps: Deps): Promise<'shown' | 'already' | 'skipped'> { const key = `onboarding:${deps.version}`; const opt = { onFail: deps.onFail }; const flag = await optionalStep('read-flag', () => deps.storage.get(key), { ...opt, timeoutMs: 500 }); if (!flag.ok) return 'skipped'; // if the flag cannot be read on this device, do not show it. When unsure, lean toward not showing if (flag.value !== null) return 'already'; const loaded = await optionalStep('load-steps', () => deps.loadSteps(), { ...opt, timeoutMs: deps.loadTimeoutMs ?? 1500, }); if (!loaded.ok || loaded.value.length === 0) { await optionalStep('write-flag', () => deps.storage.set(key, 'skipped'), opt); // record the failure too, so every launch does not repeat it return 'skipped'; } const steps: Step[] = []; for (const s of loaded.value) { let image = s.image; if (image !== null) { const check = await optionalStep(`asset:${s.id}`, () => deps.checkAsset(image as string), { ...opt, timeoutMs: 800, }); if (!check.ok || check.value !== true) image = null; // give up only the image; keep the text } steps.push({ ...s, image }); } const shown = await optionalStep('show', () => deps.show(steps), opt); // this waits on a person, so no time limit await optionalStep('write-flag', () => deps.storage.set(key, shown.ok ? 'done' : 'skipped'), opt); return shown.ok ? 'shown' : 'skipped';}
The line I hesitated over longest is the one that saves a skipped mark. Without a mark, every launch repeats the same failure. If the failure is a slow one, every launch also becomes slower. With the mark, the failure costs you once.
That choice has a price. A tutorial skipped because of a passing network problem will not appear again in that version. I still chose to accept one missed tutorial rather than make people wait at every launch. This is a place where the answer depends on the character of your app.
One more line deserves a word: the one that leans toward "do not show" when the flag cannot be read. If the storage is unreadable, you cannot know whether the tutorial was already shown. Showing it anyway could mean showing it on every launch. When unsure, lean toward not showing.
Reproducing a failure your device will never show
The most expensive part of this kind of failure is reproducing it. Before looking for a device to borrow, create the missing condition in code.
// features/onboarding/load-steps.ts (dev builds only: simulate a missing resource)export async function loadSteps() { if (process.env.EXPO_PUBLIC_SIMULATE_MISSING === 'onboarding') { throw new Error('simulated: onboarding resource missing'); } // the real loading}
What you look at is whether, launched with EXPO_PUBLIC_SIMULATE_MISSING=onboarding, the app still reaches the first screen. If it does not, something optional is on the required road.
For the two functions above I wrote nine cases along these lines: normal, a failed load, a load that never returns, only some images missing, unreadable storage, a failed display, and a failing logger. The case where the load never returns looks like this. mk is a small helper that swaps the dependencies.
// even if loading never returns, startup must move onconst t = mk({ loadSteps: () => new Promise(() => {}) });assert.equal(await maybeShowOnboarding(t.deps), 'skipped');assert.deepEqual(t.fails, ['load-steps:timeout']);
I ran all nine on Node 22 and confirmed the output all 9 passed.
node test.ts
Check first whether the app keeps running when something is missing, before checking that it works when everything is there. The habit of listing cases lasts longer than the test contents.
Auditing the entry file's imports before release
A mechanism alone is not enough. A few months from now, when I add one import line to the entry file, the optional feature is back on the required road. To prevent that, I made a small audit that checks whether the entry file loads any optional feature statically.
#!/usr/bin/env node// audit-startup-path.mjs — check that the entry file does not statically load optional featuresimport { readFileSync } from 'node:fs';const [, , configPath, ...entries] = process.argv;if (!configPath || entries.length === 0) { console.error('usage: audit-startup-path.mjs <optional-patterns.json> <entry files...>'); process.exit(2);}const patterns = JSON.parse(readFileSync(configPath, 'utf8')).optional.map((p) => new RegExp(p));// only top-level imports and column-0 requires count (lazy loads inside functions are fine)const staticLoad = /^(?:import\b[^'"]*['"]([^'"]+)['"]|(?:const|let|var)\s+[^=]+=\s*require\(\s*['"]([^'"]+)['"]\s*\))/;let violations = 0;for (const file of entries) { readFileSync(file, 'utf8').split('\n').forEach((line, i) => { const m = staticLoad.exec(line); const target = m && (m[1] ?? m[2]); if (target && patterns.some((re) => re.test(target))) { console.log(`${file}:${i + 1}: ${target} optional feature wired directly into the startup path`); violations += 1; } });}console.log(violations === 0 ? 'OK' : `${violations} violation(s)`);process.exit(violations === 0 ? 0 : 1);
List the names of optional features in a config file.
If the entry file imports an optional feature directly, the run stops with a line number.
node audit-startup-path.mjs optional-patterns.json app/_layout.tsx# app/_layout.tsx:3: ../features/onboarding/Onboarding optional feature wired directly into the startup path# 1 violation(s)
Loads inside a function (lazy loading) are not flagged. If something is loaded only when needed, it is not on the startup road. The audit becomes useful by adding one line to your pre-release checklist.
Decide the time limits one role at a time
I did not scatter the numbers through the code; each limit has a reason tied to its role.
Step
Limit
Why that value
Read the "already shown" mark
500ms
A tiny on-device read; if it takes longer, treat it as unreadable
Load the tutorial content
1500ms
It directly affects how launch feels, so keep it to the low seconds at most
Check that one image exists
800ms
Do not wait long for one image; past the limit, give up only the image
Show the tutorial
none
It waits on a person, so no limit
Working it out showed me something. If images are checked one by one, the worst case adds the step count times 800ms on top of 500ms and 1500ms. With three steps, the upper bound is 4400ms. You will rarely wait that long, but having the sum on paper puts a brake on the day you want to add more steps. For a tutorial with many steps, I think the better move is to run the checks side by side, or to cut the number of steps.
The audit goes smoothly in this order.
Open the entry file and list everything it loads.
For each item, ask "on a device where this is missing, can the person still do what they came to do?" and sort it into required or optional.
Route every optional one through optionalStep.
Add the missing-condition reproduction and the entry-file audit to your pre-release checks, one line each.
Pitfalls
A try-catch alone is sometimes not enough, because the failure may be that nothing ever returns. Waiting on the network or on storage can simply drag on without failing. That is why the time limit exists.
Swallowing errors makes the cause impossible to find. If you return the failure as a result and record it in onFail, a report of "it won't open" can be traced back to the optional step that failed.
Wrapping too much is dangerous in the opposite direction. If you treat a truly required step as optional, the app launches in a broken state and falls over somewhere else for reasons nobody can see. Keep the "required" rows of the table on the side that treats failure as failure.
Where I draw the line
When I am unsure, I ask one question: "On a device where this is missing, can the person still do what they came to do?" For a wallpaper app, that means viewing a wallpaper and saving it. If they can, the step goes on the optional side.
As a next step, open your app's entry file and sort what it loads by one question: "if this is missing, does the app fail to start?" Writing a single table is enough to show where the fix belongs.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.