Building Mintly: keeping a focus timer accurate when Android kills the process
A running Pomodoro timer has a harder reliability problem than a one-shot reminder. Here's how Mintly survives Doze, process death, and screen-off drift with a foreground service and a wall-clock end time.
A one-shot reminder just has to fire once, at roughly the right time. A focus timer is a harder problem: it has to stay visibly, continuously correct for the full 25 minutes, even while the screen is off, even if Android decides to kill the process mid-session and restart it. Get this wrong and the failure mode isn’t a missed notification — it’s a timer that says “12:41 remaining” for a full minute, or resets to 25:00 after you glance at your phone.
I hit both of those building Mintly. This is the architecture that fixed them: a foreground service, a wall-clock end time instead of a decrementing counter, and a notification that draws its own countdown instead of trusting the service to redraw it.
The bug that taught me the lesson
My first version was the obvious one: a CoroutineWorker-adjacent loop that ticked every second, decremented a remainingSeconds field, and posted an updated notification each tick. It worked perfectly in the emulator and fell apart on a real phone within a day. Two failure modes:
- Coalesced ticks. Doze mode batches wakeups. A “tick every second” loop doesn’t actually get to run every second once the screen is off — the OS defers it, then delivers several ticks in a burst. The visible countdown stutters or jumps.
- Silent process death. If the system needed memory and killed the app’s process, the in-memory
remainingSecondsfield went with it. On restart there was nothing to resume from, so the timer either vanished or restarted at its original duration — from the user’s point of view, five minutes of focus just disappeared.
The fix wasn’t “tick more reliably.” It was to stop treating the remaining time as state that needs to survive, and start treating it as something you can always recompute.
Store an end time, not a countdown
The core change: persist a single sessionEndAtMillis (wall-clock, System.currentTimeMillis()-based) the moment a session starts, and derive everything else from it on demand.
data class FocusSession(
val id: Long,
val ritual: RitualType,
val startedAtMillis: Long,
val endAtMillis: Long,
val state: SessionState, // RUNNING, PAUSED, COMPLETED
)
fun FocusSession.remainingMillis(nowMillis: Long = System.currentTimeMillis()): Long =
(endAtMillis - nowMillis).coerceAtLeast(0L)
This one change makes the whole system self-healing. It doesn’t matter how many ticks were dropped, or whether the process was killed and restarted between them — remainingMillis() is correct the instant it’s called, computed from two timestamps instead of accumulated from a loop. The session is persisted to Room the moment it starts, so a fresh process can reload it and immediately know exactly where it stands.
A foreground service that mostly doesn’t tick
The service’s job shrinks to: keep the process alive long enough to notice completion, and keep the notification reasonably fresh. It doesn’t own the source of truth.
class FocusTimerService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val session = repository.activeSessionOrNull() ?: run { stopSelf(); return START_NOT_STICKY }
startForeground(NOTIFICATION_ID, buildChronometerNotification(session))
scope.launch {
while (isActive) {
val remaining = session.remainingMillis()
if (remaining <= 0L) {
repository.markCompleted(session.id)
postCompletionNotification(session)
stopSelf()
break
}
delay(15_000) // refresh cadence, not the source of truth
}
}
return START_REDELIVER_INTENT
}
}
Two details do most of the work here:
setUsesChronometer(true)on the notification, seeded withchronometerCountDown = trueand a base computed fromendAtMillis. Android’s own notification system then redraws the countdown every second using its own clock — not my loop. The visible number never stutters, even though my service only refreshes every 15 seconds.START_REDELIVER_INTENT, notSTART_STICKY. If the system kills the service, it restarts with the original intent, andonStartCommandre-readsactiveSessionOrNull()from Room rather than trusting anything held in memory. A killed-and-restarted service produces an identical notification to one that never died.
The exact-alarm safety net
A foreground service is far less likely to be killed than a background one, but “far less likely” isn’t a guarantee — an aggressive OEM battery manager can still take it down. So completion has a second path that doesn’t depend on the service surviving at all: an exact alarm, scheduled for endAtMillis the moment the session starts, using the same setExactAndAllowWhileIdle pattern I’ve written about for Hydrame’s reminders. If the service is alive when the alarm fires, it’s a harmless duplicate check. If the service was killed, the alarm is the thing that actually posts “Session complete” on time. Belt and suspenders, but the suspenders only cost one AlarmManager call.
What resuming the app checks first
When the app comes to the foreground — from a fresh launch, from the notification, or from the multitasking switcher — the first thing the focus screen does is read activeSessionOrNull() and call remainingMillis() before it renders anything. There’s no “was the timer running before I left?” flag to get stale. Either there’s an active session in Room with time left, and the UI shows exactly that, or there isn’t, and it shows the ritual picker. The timer can’t disagree with itself because there’s only one place it’s allowed to compute the answer.
The general lesson
The pattern generalizes past focus timers: any time you’re tempted to keep a live countdown as mutable in-memory state, replace it with a fixed end timestamp and a pure function of “now.” Ticks become a refresh cadence instead of a source of truth, which means dropped ticks, killed processes, and Doze-mode batching stop being correctness bugs — they’re just moments where nobody happened to ask the question, and the answer is still right the next time someone does.
// Related reading
More from the journal
Building Subly: the calendar math behind subscription renewal dates
Predicting a subscription's next charge date sounds trivial until you hit month-end billing, leap years, and trial conversions. Here's how Subly gets it right, on-device.
Android notification actions done right: marking a dose taken without opening the app
A practical guide to Android notification action buttons — PendingIntents, the trampoline restriction, goAsync(), and updating Room state safely.
Reliable Android reminders in 2026: WorkManager, exact alarms, and the new battery rules
How to ship reminders on Android in 2026 that actually fire — WorkManager vs AlarmManager, SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS, and the OEM quirks that still bite.