Get a demo
Symptom → evidence

App breaks after an update for only some users

Answering: “app breaks after an update only for some users”

Evidence before populated after the event empty the same screen, captured before and after, comes back blank
Two grounding screenshots from the run timeline, before and after the event: the populated screen comes back empty.

The users who break are the ones whose devices still hold data the previous version wrote; a fresh install starts from an empty container and never meets it. An update that fails for only some users is new code running against old on-device state, and you reproduce it by upgrading over the old build, not by installing the new one clean.

It installs clean on your machine and dies on theirs

You shipped an update, TestFlight was green, and your own phone updated without a hitch. Then a slice of users report a crash on launch, an empty library, or a feature that used to work and now does nothing. Deleting and reinstalling fixes it for them, which points at data rather than code. You reinstalled on your simulator, could not reproduce it, and now you are staring at crash reports with no repro.

Old data is running through new code

Only-some-users breakage after an update is a persisted-state problem. The users who break are carrying data the previous version wrote, and your fresh installs never touch it.

If you store data in SQLite through GRDB, schema changes run through a DatabaseMigrator. A clean install creates an empty database and replays every registered migration in order, so it always lands at the newest schema. An upgrade replays only the migrations not yet recorded in grdb_migrations, and it runs them against real rows. The failures that bite only upgraded devices are the ones that touch data. A backfill is the clearest: version 4 adds a slug column and fills it by reading every existing title, or splits a stored displayName into first and last by parsing each row. On a fresh database there are no rows to read, so the step commits against nothing. On an upgraded device the same code meets real values, and the one row that breaks the parser’s assumption (an empty string, an unexpected format) throws or writes garbage. Constraints behave the same way. SQLite cannot tighten a column to NOT NULL or UNIQUE in place, so GRDB’s documented path is a table rebuild: create the new table with the stricter constraint, copy rows across with INSERT INTO new SELECT ... FROM old, drop the old table, rename. If any existing row holds NULL in the now-required column, or two rows collide on the now-unique one, the copy throws a constraint violation. The empty table on a fresh install copies zero rows and never trips it.

The second common cause is a Codable value persisted in the old format. A struct you stored as JSON in a SQLite column or in UserDefaults gains a new non-optional property, and decoding the old blob throws DecodingError.keyNotFound. Fresh installs write the new shape immediately and never decode an old one. Upgraded devices still hold the old bytes, and whatever code force-tries the decode either crashes or falls back to an empty state.

The third is a persisted enum whose stored value no longer resolves. If you saved an enum by rawValue and the new version renames a case, the saved raw value no longer maps to a case, so decoding returns nil or throws. Reordering the cases has the same effect only when the raw values are implicit and derived from declaration order (an Int enum with no explicit = 1 assignments). If you assigned the raw values by hand, reordering the declarations leaves the persisted mapping intact. Only devices that saved the old value are affected.

React Native hits the same wall: an AsyncStorage blob or a WatermelonDB/SQLite migration written for the old schema breaks only on devices that already held old data. The mechanism differs, the reproduction does not.

Why your dev loop never stages the upgrade

Every one of these is invisible on a fresh install, and a fresh install is what your development loop produces. Your simulator’s database was created at the latest schema and never carried the previous version’s rows.

You can stage the real path by hand: install the old build, seed data, then install the new build over it so the container survives. It is fiddly to set up, and the users who break are on an iOS point release you do not have locally.

A recorded run of the upgrade makes each cause legible from the outside. For the migration crash, the video shows the app dying between the splash and the first frame, the performance chart marks the termination, and the timeline of steps pins the failure to the launch step where the database opens, with a grounding screenshot of its last frame. The device logs carry the exact DatabaseError with SQLite’s own message, a NOT NULL constraint failed: downloads.duration or a UNIQUE constraint failed, or the Swift error a backfill threw. You do not need to read the container to know the schema reverted: GRDB runs each migration in a transaction and SQLite DDL is transactional, so a failed migration rolls back. Because it never records its identifier, the same migration throws on every relaunch, naming it in the logs. For the Codable and enum cases there is no crash, so the video shows an empty screen or a dead control while the logs carry the DecodingError with its key path, and the timeline’s grounding screenshots show the screen that populated on a fresh install sitting empty on the upgraded one.

Reproduce it by upgrading over the old build

Recreate the users’ situation on a live cloud device. Boot one on the iOS release the affected users are on, install the previous build, drive the flow that writes real data, then install this build over it without wiping the container and watch the first launch. The session records the whole run: video, the timeline of steps with a grounding screenshot at each one, the device logs, and the performance chart with any termination marked. spootify is a clean vehicle: a SwiftUI podcast player backed by on-disk SQLite through GRDB, so you seed downloads on the old build, ship a schema change, upgrade over it, and let the migration meet rows it never saw on a fresh install.

revyl auth login
revyl build --platform ios                             # build + upload the NEW build; note its id from `revyl build list`
revyl device start --platform ios --os-version "iOS 17.5" --build-version-id <old-build-id>   # boot the affected iOS on the OLD build, then seed a library in the session
revyl device install --build-version-id <new-build-id> # install the new build over the old; the container survives
revyl device launch --bundle-id <bundle-id>            # relaunch, and the migration meets the old rows

Technical sources

Common questions

Why does my app only crash for some users after an update?
The users who break are the ones carrying data the previous version wrote to disk, and your fresh installs never touch that data. A migration or a persisted value that only exists on upgraded devices runs through your new code and fails there. Deleting and reinstalling fixes it for them because it wipes the old on-device state.
How do I reproduce a bug that only happens when upgrading over an old version?
Install the previous build first, drive the flow that writes real data, then install the new build over it without wiping the container, on a cloud device matched to the iOS version those users are running. Because the old rows are on disk when the new code runs, the crash reproduces on the first launch. Installing the new build on a clean device will never show it.
Why can't I reproduce the post-update crash on my own simulator?
Your development loop produces fresh installs, and a fresh install creates its database at the latest schema and never carries the previous version's rows. On a clean install the migrator replays every migration in order and always lands correct. The bug only appears when old data meets new code, which your default simulator run never stages.

Reproduce it on a cloud device

Choose a supported model and OS pair, run the flow, and read the full record of what happened.

Start free Read the docs