A bug that fires only on the very first launch is a code path that assumed state which does not exist yet: an empty cache, a missing keychain token, a one-time migration, or an unanswered permission prompt. It vanishes the moment you launch again, and the empty-to-populated write that triggered it is already gone, so the only way to observe it is a captured record of the clean boot itself.
It breaks once, then works on every launch after
You install a fresh build and the app crashes on the splash screen, lands on a blank view, or skips a step in onboarding. You relaunch to grab a screenshot and everything is fine. Deleting the app and reinstalling reproduces it on some tries and not others. Attach a debugger and it refuses to fire, because the run you are inspecting is no longer a first launch.
First launch runs against state that is not there yet
Four mechanisms cover most of these.
Empty defaults and caches. On a clean install, UserDefaults returns nil, false, or 0 for every key you have never set, the caches directory is empty, and the Core Data store has zero rows. A guard let that assumed a seeded value takes its failing branch, or a force-unwrap on the first row of an empty result crashes, only when there is nothing there.
Missing keychain entry. Your auth layer reads a token from the keychain and the item does not exist yet. Keychain items live outside the app sandbox and survive app deletion on iOS, so “delete and reinstall” does not give you a clean auth state. That is exactly why deleting and reinstalling from Xcode leaves the old token in place and fails to reproduce it, even though it hits every first-time user.
A migration that runs once. Core Data lightweight migration executes on the first launch after a model version bump, and hand-rolled migrations are usually gated behind a schemaVersion flag in UserDefaults. If the migration is wrong, it runs, sets the flag, and never runs again. The window to observe it is a single launch.
The permission prompt race. On first run, authorization status is .notDetermined, so requestAuthorization presents the system modal and calls back asynchronously on a queue you do not control. Code that reads authorizationStatus synchronously, or updates UI before the completion handler returns, behaves differently only on the one launch where the prompt appears. (React Native and Expo repeat the same shapes: AsyncStorage and expo-secure-store are empty on first run, SecureStore persists across reinstall like the keychain, and Expo permission modules carry the identical first-run callback timing.)
How you’d catch it
You read the boot from the outside, off the artifacts the run leaves behind. The video is the full replay, so it shows exactly what a first-time user sees: a crash frame on the splash, a blank view where content should be, an onboarding step that gets skipped, or the system permission alert appearing mid-flow. The timeline is the step-by-step agent trajectory, and every step carries a grounding screenshot plus the action it took, so you get the screen right before and right after the step that ran against empty state and stop guessing which interaction broke.
The device logs carry the failure itself. A force-unwrap on a nil keychain read or a missing UserDefaults value lands here as a crash stack, a runtime error the app printed shows up color-coded by severity, and if the process was killed the termination is marked on the performance chart. For the permission race the video shows the system modal sliding up while the timeline screenshots show the view mutating a frame before the callback would have returned, which is the ordering bug made visible without reading a single variable. If the empty state was a missing token, the network waterfall shows the first-launch request firing without auth and coming back rejected, so the request table tells you the token was not there yet. Nothing here reaches into your app’s memory or its stored files; it infers the empty-state cause from what the run visibly did.
Your own machine stops short here. A full simctl erase and re-provision does return the simulator to a clean state, so you can make the crash fire again. But that clean state is your simulator’s, on whatever model and iOS release you happen to have checked out. Permission prompts and system behavior shift between OS versions, so if a user hit this on a model or release you are not running, a local erase reproduces nothing at all. And you can break at launch and step through the empty state, but a stepping session is not a record you can replay or send, and stepping through an async first-run callback perturbs the very timing you are trying to catch. Seeing the failure reliably means recording the boot as it runs and reading the report once the run is over.
Reproduce it on a cloud device that boots clean
Revyl boots the device fresh, installs your build, and records the whole run, so the point is running that boot on the model and iOS release where the bug was actually reported rather than the one on your desk. Aim it at that target, install the build, and drive the first-launch flow. Because each run starts from a fresh boot and a clean install, the flow meets the empty state a first-time user gets, with no manual erase between attempts, and the boot is captured as video, device logs, and a step-by-step timeline you can scrub back through. Running it this way gives you the same fresh start every time, recorded so the one-shot failure outlives the run that produced it. Vault, a SwiftUI Revolut-style banking app, is a good worked example. Its onboarding walks account signup through KYC identity verification before it opens the account, so the first-run empty state that trips these bugs is exactly the trajectory you exercise from a clean install. Poke it live to feel the crash, then replay the same path as an agent test and read the report: the video of the empty-state screen, the device logs with the crash, and the timeline screenshots on either side of the step that failed.
revyl auth login
revyl build --platform ios
revyl device start --platform ios # poke the clean first-launch flow live
revyl test run vault-onboarding --build-id <id> # replay it as an agent test with a full report