A started workout survives an app kill. The session store writes a JSON snapshot to disk on every change, and reads it back synchronously when the store is created. The code is in src/features/workouts/store/sessionPersistence.ts. All paths are relative to the mobile repo root.

Where the snapshot lives

The file is read and written through src/lib/storage/jsonFileStore.ts:
  • On native it is a file in the app’s document directory, new File(Paths.document, name) from expo-file-system, using the synchronous textSync(), write() and delete() calls.
  • On web it is a localStorage entry under the same name.
  • Every helper (readJsonSync, writeJsonSync, removeJsonSync) swallows errors. A failed read returns null, a failed write does nothing.
The snapshot is not in SecureStore or AsyncStorage. It is a plain file so it can be read synchronously before the first render.

Snapshot shape

session is the whole WorkoutSession object from src/features/workouts/store/workoutSession.ts, including every exercise, every set and the entered values. rest describes a running rest timer: absolute start and end timestamps, the exercise name, the alert content and the id of the scheduled notification.

Writing

The store file ends with one subscription:
saveWorkoutSession(session) behaves as follows:
  • No session, or a session that is not started. If a snapshot exists it is cleared from the cache and the file is removed straight away. An info-mode preview is therefore never persisted.
  • A started session. The in-memory cached snapshot is replaced, keeping the current rest value, and a write is scheduled.
saveWorkoutRest(rest) updates only the rest part and schedules a write. It does nothing when there is no snapshot. It is called from persistRest() in src/features/workouts/live-activity/workoutActivityController.ts whenever a rest starts, is adjusted, gets its notification id, or ends.

Throttling

schedule() writes at most once every 400 ms. If the last write was longer ago it flushes immediately. Otherwise it sets one timer for the remaining time. Typing in a weight field therefore does not write on every keystroke. A pending write is not lost when the app goes to the background:
flush() writes the cached snapshot, or removes the file when the cache is null.

Loading and validation

readWorkoutSnapshot() loads once and caches the result in module state. load() rejects the file, and deletes it, when any of these is true:
  • raw.version !== SNAPSHOT_VERSION
  • isStartedSession(raw.session) fails. It requires a non-empty string dayId, started === true, a numeric startedAt greater than zero, and an exercises array.
  • Date.now() - raw.session.startedAt > MAX_SESSION_AGE_MS
rest is kept only when isRest passes (numeric startedAt and endsAt, string exerciseName). Otherwise it loads as null.
Validation is shallow. isStartedSession does not look inside exercises or their sets. A snapshot written by an older build is accepted as long as the version number matches, whatever the set objects contain.

When to bump SNAPSHOT_VERSION

Bump SNAPSHOT_VERSION in the same change whenever you alter the persisted shape in a way old data does not satisfy:
  • Adding, renaming or removing a field on ExerciseSet, SessionExercise, WorkoutExercise or WorkoutSession that code reads without a fallback. Store actions call .trim() on weight, reps, rir and rm, so a missing string field throws.
  • Changing the type or meaning of an existing field, for example a unit change.
  • Changing RestSnapshot fields that isRest does not check.
A bump discards every in-flight workout on devices that update, because load() deletes snapshots with another version. That is the intended trade: losing one live workout is better than restoring a session the new code cannot read. A purely additive field that every reader treats as optional does not need a bump. The constant is not exported. It is only used inside sessionPersistence.ts.

Restore flow

1

The store starts with the snapshot

The initial state is session: readWorkoutSnapshot()?.session ?? null. This runs when the store module is first imported, synchronously, so the first render already has the session.
2

The layout restores the rest timer

The effect in src/app/(app)/_layout.tsx calls restoreWorkoutActivity() first. It runs once. If the snapshot has a rest whose endsAt is still in the future, it copies startedAt, endsAt, exerciseName, alert and alertId back into the controller’s module state. A rest that already ended is ignored. It also sets adoptionAllowed when a workout or cardio snapshot exists, which lets the controller adopt a Live Activity that is still running from before the kill.
3

The Live Activity is synced

syncWorkoutActivity(session) is called with the restored session when it is started.
4

The rest bar resumes

useRestTimerStore.getState().resume() reads the restored end time. If time is left it sets running, remaining, totalSeconds and exerciseName and restarts the 250 ms interval. The id of the notification scheduled before the kill was restored too, so skip and adjust can still cancel it.
5

The trainee gets back to the workout

No screen is pushed automatically. GlobalWorkoutBar appears on the tab screens because the session is started, and the workouts tab shows the day as in progress. Opening the day calls start(data), which is a no-op because sessionMatches is true, so the restored values are kept and not replaced by fresh server data.

Clearing

The snapshot is removed when the session becomes null or not started:
  • After a successful log, finishWorkout calls reset().
  • Discarding from the day screen, the floating bar or the “another workout is running” prompt calls reset().
  • Leaving an info-mode day calls reset(), which only matters if a snapshot existed.

Preview mode

When IS_PREVIEW from src/lib/preview.ts is true, load() returns null and both save functions return early. The embedded preview never reads or writes a snapshot. The cardio timer has its own file, perform-active-cardio.json, with its own SNAPSHOT_VERSION and the same 6 hour limit. See Cardio session.