I added a pause to a Compose game: freeze the round while a quit dialog is up
or the app is backgrounded, then shift all the timestamp deadlines by the pause
duration on resume. It looked correct in code review. It has a one-frame race:
the game loops resume before the effect that shifts the deadlines has run, so
the first tick after un-pausing sees deadlines that expired during the pause
and fires them all at once.
TL;DR — coroutine loops that read MutableState directly resume on their
next delay() tick, but a LaunchedEffect keyed on the pause flag needs a
recomposition plus an effect relaunch to do its bookkeeping; gate the loops on
the bookkeeping state itself (paused || pausedAt != 0L) and park them on
snapshotFlow { frozen() }.first { !it } instead of polling.
The setup
An Android arcade game drawn on a Compose Canvas. The simulation runs in
while (true) loops inside LaunchedEffects: a physics loop ticking every
16 ms, plus slower loops for the countdown and hints. Events like power-up and
boss spawns are scheduled on absolute wall-clock deadlines — plain Long
fields such as nextBossAtMs, compared against System.currentTimeMillis()
each tick. The pause flag is derived from snapshot state:
var showQuitDialog by remember { mutableStateOf(false) }
var isForeground by remember { mutableStateOf(true) }
val paused = showQuitDialog || !isForeground // recomposes on change
The loops cannot capture the paused val — a plain val is frozen at the moment
the coroutine launched — so they re-read the two MutableState delegates
directly on every iteration. Freezing the loops is not enough, though:
wall-clock deadlines keep aging during a pause. A separate effect shifts them
on resume:
LaunchedEffect(paused) {
if (paused) { pausedAt = System.currentTimeMillis(); return@LaunchedEffect }
if (pausedAt == 0L) return@LaunchedEffect
val delta = System.currentTimeMillis() - pausedAt
pausedAt = 0L
nextBossAtMs += delta
nextPowerUpAtMs += delta
}
What I expected
Dismissing the dialog writes showQuitDialog = false; the effect keyed on
paused relaunches, shifts the deadlines, and the loops pick up from where
they left off. Since everything runs on the main thread, I assumed the order
did not matter.
What actually happens
The two consumers observe the state write through different mechanisms, at
different times. The loop’s next delay(16) resumption re-reads the
MutableState and sees false immediately — no recomposition involved. The
effect relaunch needs the full pipeline: snapshot commit, recomposition of the
paused val, then the effect restart. That is up to a frame later.
In the gap, one loop iteration runs against un-shifted deadlines. After a 30 s
pause, now >= nextBossAtMs and now >= nextPowerUpAtMs are both true, so a
boss and a power-up spawn the instant the dialog closes. Worse, the loop then
resets nextBossAtMs = now + interval, and the shift effect — running one
frame later — adds the whole 30 s pause to that fresh value, pushing the next
spawn far out. The same window exists when returning from the background,
because the lifecycle observer’s isForeground = true is just another state
write.

The fix
Make the loops wait for the bookkeeping, not just the pause flag. The
bookkeeping already has an observable footprint: pausedAt is non-zero
exactly between « pause recorded » and « deadlines shifted ». Fold it into the
gate, and park the loop on a suspension instead of polling:
var pausedAt by remember { mutableLongStateOf(0L) } // must be snapshot state
fun isPaused() = showQuitDialog || !isForeground // fresh reads each call
fun frozen() = isPaused() || pausedAt != 0L
LaunchedEffect(screen) {
while (screen == Screen.Playing) {
if (frozen()) {
snapshotFlow { frozen() }.first { !it } // parked, zero wake-ups
lastTickMs = System.currentTimeMillis() // avoid one huge dt
continue
}
step()
delay(16)
}
}
Every value the predicate reads must be snapshot state (mutableStateOf,
mutableLongStateOf). snapshotFlow only re-evaluates when a snapshot commit
touches something it read — a plain var pausedAt: Long would leave the loop
parked forever.
The park also fixes a cost I had not noticed: the previous pause branch was
delay(16); continue, which keeps the main thread waking 60 times per second
for as long as the app sits in the background mid-round.
Why it works
On un-pause, the loop’s flow predicate stays true (because pausedAt != 0L)
through the exact window where the race lived. The shift effect relaunches on
the next frame, adds the pause duration to each deadline, and clears
pausedAt — and that same snapshot commit wakes the parked snapshotFlow
collector. The loop’s first real iteration is therefore guaranteed to see
shifted deadlines. Ordering is enforced by data, not by luck.
I verified the whole behaviour on an emulator by diffing screenshots: the
playfield changed 13–19 % of pixels per second while running, 0.06–0.08 % over
4 s while a dialog was up (freeze), and resumed movement with no instant boss
spawn after dismissing the dialog and after a background/foreground round
trip. Related earlier note on emulator-side verification: Compose animations
finish in one frame when the animator scale is 0.
What I did not test
Compose BOM 2026.02 and Kotlin 2.2 only, single emulator, single window. The
sub-frame case — a dialog opened and closed so fast that paused never
composes to true — leaves pausedAt at zero, so the gate degrades to the
plain pause flag; the un-shifted window there is under one frame and I ignored
it. Multi-window and process death were out of scope (this app resets the
round on process death).
Facts
context: Compose Canvas game, while-loops in LaunchedEffect, wall-clock spawn deadlines
problem: loops re-reading MutableState resume one tick before LaunchedEffect(paused) shifts deadlines, firing expired spawns on resume
solution: gate loops on frozen() = paused || pausedAt != 0L and park on snapshotFlow { frozen() }.first { !it }
verified_on: 2026-08-27
applies_to: [Jetpack Compose BOM 2026.02, Kotlin 2.2, Android API 24+]
does_not_apply_to: [game loops driven by withFrameNanos, which already stop while backgrounded]