A timer app has one job: be right. In Flutter the easy version is wrong the moment the user locks the phone.
I build Flip Timer, a Pomodoro timer with a flip-clock display. This is how I made it keep correct time when the OS suspends the app. The snippets are trimmed from the real code.
The problem
The obvious design is a Timer.periodic that ticks once a second and adds a second to elapsed. It works while the app is in the foreground. Both iOS and Android suspend the process shortly after the app goes to the background, so the ticks stop. Come back after 10 minutes and a naive timer still says 24:50.
Two things are needed:
- When the app comes back, work out how much time really passed.
- While the app is gone, still tell the user when the session ends.
Step 1: store the end time, not the elapsed time
When a session starts, save the wall-clock time it should end:
_sessionEndAt = DateTime.now().add(state.remaining);
_startTicker(); // Timer.periodic, only for updating the UI
The ticker now only redraws the digits. The end time is the source of truth.
Step 2: reconcile when the app resumes
Flutter's AppLifecycleListener tells you when the app is back:
_lifecycleListener = AppLifecycleListener(
onStateChange: (appState) {
_lifecycleState = appState;
if (appState == AppLifecycleState.resumed) _reconcile();
},
);
_reconcile() compares the stored end time with DateTime.now():
void _reconcile() {
if (!state.isRunning || _sessionEndAt == null) return;
final now = DateTime.now();
final endAt = _sessionEndAt!;
if (now.isBefore(endAt)) {
// Still inside the session: just correct the displayed time.
emit(state.copyWith(elapsed: state.target - endAt.difference(now)));
return;
}
// The session finished while we were away: record it with its REAL end time.
...
}
The user sees the correct remaining time even though no tick ran in between.
Step 3: be careful when the session ended while away
If auto-start is on, several sessions might have finished while the app was suspended. My code replays them in a loop and records each one with its own real end time (not "now"), so stats and streaks stay correct.
I also put two limits on this, because a bug here would invent focus time that never happened:
static const int _maxReconciledSessions = 8;
static const Duration _maxReconcileWindow = Duration(hours: 4);
If the user was away more than 4 hours, the app credits only the one session that genuinely finished and stops. A week-long absence with auto-start must not create hundreds of fake sessions in the heatmap.
Lesson: when you rebuild history from a clock, set a ceiling on how much you are willing to believe.
Step 4: tell the user while the app is suspended
Reconciling only helps when the user returns. To signal the end of a session while the app is frozen, I schedule a local notification for the end time, using flutter_local_notifications:
await _plugin.zonedSchedule(
id, title, body, scheduledTime, details,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
);
The title is "Session complete" or "Break over". After a reconcile, the notification is scheduled again for the new end time.
Step 5: exact alarms can fail, so fall back
On Android, exact alarms need a permission the user can deny or revoke. So the scheduling call is wrapped: try exact, and if it throws, retry with inexact scheduling.
try {
await _plugin.zonedSchedule(..., androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle);
} catch (e) {
await _plugin.zonedSchedule(..., androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle);
}
An inexact alarm may fire a bit late. A late notification still beats none.
Step 6: screen on and fullscreen
A timer on a desk shouldn't dim. I use wakelock_plus for keep-screen-on (a setting, on by default) and a sticky immersive mode for the fullscreen view:
await WakelockPlus.toggle(enable: state.keepScreenOn);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
What this does not do
Honesty section: nothing counts down in the background. The app is suspended, and the timer is reconstructed from the clock when you return. What you get while away is the scheduled notification, and its sound is the system default, not the alarm sound you pick inside the app.
Takeaways
- Store the end time, not the elapsed time.
- Reconcile on
AppLifecycleState.resumed. - Cap how much time you are willing to reconstruct.
- Schedule an OS notification for the end of the session.
- Expect exact alarms to fail and have a fallback.
If you want to see it running: Flip Timer is free and supported by ads, and available on Android. I'd like to hear how you handled background timing in your own apps.

Top comments (1)
Storing the end time is the right call, and the 4-hour ceiling on reconstructed sessions is a nice touch. It's the kind of limit you only add after picturing a heatmap full of fake sessions.
Two edge cases I'd test on top:
Does the scheduled notification get cancelled when the user pauses the session in the app? That's the bug report I'd expect first.