The Problem (Technical)
My LongGame app needed three classes of notifications: You can preview and join the waitlist here
- Daily kickstarters — wake the user up with context about their day.
- Reminders — nudge them toward sessions they committed to.
- Performance signals — show them how they're tracking against their own goals.
Three notifications a day, on average. Enough to be useful. Not enough to be noise.
The App
LongGame is a life auditing app. It helps you visualize how much time you have left in your life, and helps you allocate that time across the projects or activities that matter to you. On top of that it nudges you toward follow-through without manipulating your emotions to act or overwhelming you.
The Human Problem (Behavioral Engineering Territory)
LongGame sends notifications about things people don't naturally want to do. Work. Discipline. Commitment to long-term projects. Actually doing the work.
On the flip side, my ideal users are busy people. They hate low-value notifications. They'd rather live their lives away from the phone and all the predatory gimmicks it comes with. They already resent the attention economy and the addiction around cellphones.
Choosing not to send notifications because my clients hate them would also kill my app. It would sink into the graveyard of unused workout trackers and habit apps which are usually forgotten within a week. I had to find a balance.
I believe this idea if executed well, could genuinely help people. Its so much easy to drift and to get into commitments that waste our time. And if I could build something that encourages conscious proactiveness without guilt-tripping people that would be a win for humanity. That cliché about dying with regret over where your time went — maybe it doesn't have to be true.
My key Design Questions for the App
- How do you get people to come back?
- How often do you invite them?
- What tone do you use?
- What do you want them to do when they arrive?
- What reward keeps them coming back?
The Inspiration (Duolingo)
Duolingo's success was largely built on its notification system. The entire product sells you one thing: the streak — your pride and discipline compressed into a single number. Criticisms aside, Duolingo's notifications made them the most popular language learning app in the world.
I studied their system through this talk: How We Created a High-Scale Notification System at Duolingo. They built their system towards resilience. Notifications had to survive server down time and to scale regardless of user pressure. More on that later.
I went in hoping to find a connect-this-to-that solution. Instead, it introduced higher-level operational questions I hadn't considered. How many notifications can my system handle? How much would it cost? If I compute and deliver notifications from the cloud via Firebase Cloud Messaging (FCM), I pay per message. At scale, that would add up into a number I am not comfortable with.
Then a dark thought crept in:
> Notifications are just little ad campaigns.
That reframing changed my approach. If every notification costs the user's attention, it had better be worth it. No filler. No "Don't forget to check in!" garbage. Every notification earns its right to exist by carrying real, personalized information.
The Solution: Dynamic Local Notifications
(For developers, there is a step by step walkthrough of how I implemented this)
My goal was to run notifications efficiently without backend costs. Users had to get their notifications even if my server is down, even if they are offline. Start lightweight. Prove the model works before reaching for FCM.
The key insight: You don't always need a server to push notifications. You can schedule them locally on the device using data the app already has. The app pulls fresh data from Firestore on launch, computes personalized notification content, and schedules everything locally using exact alarms. The cloud never fires a single push.
This gives me three things:
- Zero delivery cost — no FCM charges, no cloud functions.
- Offline resilience — notifications fire even without internet.
- Personalization without a recommendation engine — the content comes from the user's own data.
The First Attempt: WorkManager (And Why It Failed)
My first instinct was workmanager — Flutter's go-to for background tasks. At this time I thought notifications were a backgroud task problem. The plan: register a periodic background task that wakes up, computes the notification content from Firestore, and fires it. Simple on paper.
Here's roughly what that looked like:
// The old approach — periodic background fetch
Workmanager().registerPeriodicTask(
'daily-notifications',
'scheduleDailyNotifications',
frequency: const Duration(minutes: 15), // minimum allowed interval
);The problem? WorkManager doesn't guarantee when your task runs. It only guarantees that your task runs eventually. Android's minimum polling interval is 15 minutes, but Doze mode can delay execution for hours. iOS is worse — background fetch is entirely at the OS's discretion, and iOS aggressively throttles apps that don't drive engagement.
The result: notifications scheduled for 8:00 AM would arrive at 10:47 AM. Or 1:15 PM. Or not at all. My notification system was unreliable in exactly the way that destroys user trust.
The fix was to stop thinking of notifications as background tasks and start thinking of them as alarms. The OS already has a precise alarm scheduler. I just needed to use it.
// The fix — exact OS alarm, fires at exactly 8:00 AM
await plugin.zonedSchedule(
id,
title,
body,
_nextInstanceOfTime(hour, minute), // timezone-aware
notificationDetails,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
matchDateTimeComponents: DateTimeComponents.time, // repeat daily
);zonedSchedule with exactAllowWhileIdle registers an exact alarm at the OS level. It fires at the configured time regardless of Doze mode, app state, or battery optimization. And DateTimeComponents.time tells the OS to repeat it every day at that exact time — no polling, no background task, no 15-minute uncertainty window.
I removed workmanager, removed iOS background fetch modes from Info.plist, deleted the background isolate bootstrap, and replaced everything with six zonedSchedule calls. The diff removed 276 lines and added 191. Simpler code, reliable delivery. More on this here
The Architecture
I used Flutter's flutter_local_notifications package with timezone-aware scheduling. The system is made up of four modules:
1. The Daily Notifications
Four notifications, each with a specific job:
The Morning Intent (default 8:00 AM) Fires every morning with a calculated summary: how many usable hours you have today and which projects are scheduled. The app computes your net available hours (waking hours minus commitments), queries your project schedule for today's weekday, and builds the message dynamically.
"You have 6h of usable time today. Scheduled: Writing (1h 30min), Side Project (45min)"This isn't a generic "Good morning!" — it's a briefing. It tells you what you already committed to.
The Drift Alert (default 2:00 PM) A mid-day check-in. The app calculates what percentage of your usable day has elapsed based on your sleep schedule and alert time, then asks a simple question.
"Your day is 63% gone. Have you logged any time?"No judgment. Just a mirror held up to the clock.
The Mirror (default 9:00 PM) An end-of-day reflection prompt. It computes your total planned project minutes for the day and asks how it went.
"You planned 2h 15min of project work today. How did it go?"The Streak (default 10:00 AM) This one borrows directly from Duolingo's playbook. The app queries two weeks of time entries from Firestore, builds per-project activity maps, and calculates either your best active streak or your longest gap.
"Day 5 of working on 'Writing'. Keep the chain?"or
"It's been 8 days since you touched 'Side Project'. Still on the plan?"The streak notification only fires when it has something meaningful to report. No streak or gap worth mentioning? It stays silent.
2. Session Reminders
Per-project, per-weekday nudges. When a user schedules a project for specific days, the app registers a repeating alarm for each weekday using DateTimeComponents.dayOfWeekAndTime. The message includes the project name and session duration.
"Writing — 1h 30min session. Start?"3. Timer Notifications
Two types here:
- Ongoing notification — a persistent, low-priority notification that appears while a timer is running. On Android, it uses the native chronometer so the elapsed time ticks in the notification shade with low CPU cost.
- "Still running?" check — a one-shot alarm that fires 55 minutes after a timer starts. If the user forgot to stop it, this catches it.
4. Pomodoro Phase Bells
Distinct audio cues for focus and break transitions. The focus bell plays a bright double-ding; the break bell plays a warm chime. Each gets its own Android notification channel so the user can control their volume independently.
This one shipped broken in my first solo initial trials. The pomodoro timer uses Dart's Timer.periodic to tick every second and check if the phase has elapsed:
void _checkPomodoroPhase() {
if (!_isPomodoroMode || _phaseStartedAt <mark> null) return;
final elapsed = DateTime.now().difference(_phaseStartedAt!);
if (elapsed.inMinutes >= _currentPhaseDurationMinutes) {
advancePomodoroPhase();
}
}The problem: Timer.periodic runs in the app process. When you lock your phone, iOS suspends the app after ~30 seconds and Android throttles it. The timer stops ticking, so _checkPomodoroPhase() never detects the phase ended. Your 25-minute focus session ends, and... silence. The bell only rings when you unlock your phone and the app resumes.
For a pomodoro timer, that's a dealbreaker. The whole point is the bell tells you when to stop — you don't check the phone to find out.
The fix: when each pomodoro phase starts, schedule an exact OS alarm for exactly when that phase ends. The OS delivers the alarm regardless of app state — phone locked, app killed, Doze mode, doesn't matter.
// TimerService — when a new phase begins
Future<void> _scheduleNextPhaseAlarm() async {
if (!_isPomodoroMode) return;
// Determine what the NEXT phase will be when the current one ends.
bool nextIsWork;
String nextLabel;
if (_pomodoroPhase </mark> PomodoroPhase.work) {
if (_pomodoroCompletedCycles + 1 >= _pomodoroCyclesBeforeLongBreak) {
nextIsWork = false;
nextLabel = 'LONG BREAK';
} else {
nextIsWork = false;
nextLabel = 'SHORT BREAK';
}
} else {
nextIsWork = true;
nextLabel = 'FOCUS';
}
await NotificationService.schedulePomodoroPhaseAlarm(
durationMinutes: _currentPhaseDurationMinutes,
nextPhaseIsWork: nextIsWork,
nextPhaseLabel: nextLabel,
);
}// PomodoroNotifications — the OS alarm
static Future<void> schedulePhaseAlarm({
required int durationMinutes,
required bool nextPhaseIsWork,
required String nextPhaseLabel,
}) async {
await NotificationCore.plugin.cancel(_idPhaseAlarm);
final fireAt = tz.TZDateTime.now(tz.local)
.add(Duration(minutes: durationMinutes));
await NotificationCore.plugin.zonedSchedule(
_idPhaseAlarm,
nextPhaseLabel,
message,
fireAt,
_bellDetails(isWorkPhase: nextPhaseIsWork),
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
);
}Now two delivery paths race to notify the user:
- Dart timer (foreground) — if the app is alive,
_checkPomodoroPhase()detects the transition and fires the notification instantly, then cancels the pending OS alarm. - OS alarm (background) — if the phone is locked or the app is suspended, the scheduled alarm fires the notification directly.
No duplicates: the instant notification cancels the alarm, and they use coordinated IDs so if both somehow fire, one replaces the other. The user hears one bell, exactly when the phase ends, regardless of app state.
The Scheduling Strategy
Every notification reschedules on app launch. The method rescheduleAllNotifications() runs during startup: it cancels all existing alarms and re-registers them with fresh data from Firestore. This means notification content stays current — if you add a new project at 11 PM, tomorrow's Morning Intent already includes it.
The scheduling uses zonedSchedule with matchDateTimeComponents: DateTimeComponents.time for daily repeats. This tells the OS: "fire this notification every day at this exact local time." Timezone changes, DST transitions — the timezone library handles them.
For the daily notifications, I use AndroidScheduleMode.exactAllowWhileIdle — exact alarms that fire even in Doze mode. For session reminders and timer checks, I use inexactAllowWhileIdle since a few minutes of drift is acceptable.
The entire system degrades gracefully. If Firestore is unreachable, each scheduling method catches the error and falls back to a generic message. The notification still fires. It's less personalized, but it's present.
The Cost Model
| Component | Cost |
|---|---|
| FCM push messages | $0 (not used) |
| Cloud Functions for content | $0 (computed on-device) |
| Firestore reads for content | Bundled with existing app-launch queries |
| Local alarm scheduling | Free (OS-level) |
The entire notification system runs at effectively zero marginal cost to me per user. The only Firestore reads happen when the app is already open and fetching data anyway.
CRITICAL: Android 16 and the Exact Alarm Permission
This bug almost shipped. It was invisible in development and only surfaced on real devices.
Android 14 (API 34) introduced a change that directly affects this architecture: SCHEDULE_EXACT_ALARM is no longer granted by default. On Android 16 (API 36), the OS surfaces a non-critical warning if your app schedules exact alarms without the user explicitly granting the permission.
This matters because my entire daily notification system depends on exactAllowWhileIdle. Without the permission, zonedSchedule silently degrades to inexact delivery — and we're back to the WorkManager problem of notifications arriving hours late.
The options:
- Request
SCHEDULE_EXACT_ALARMat runtime — adds a permission dialog, but guarantees exact delivery. - Use
USE_EXACT_ALARM— auto-granted for apps whose core function depends on precise timing (clocks, calendars, timers). A life auditing app with scheduled reminders arguably qualifies. - Fall back to inexact alarms gracefully — accept that on some devices, "8:00 AM" might mean "8:00–8:15 AM" and design the notification content to tolerate that drift.
I'm currently shipping with option 3 as the default and monitoring. For most users, a 10-minute window on the Morning Intent doesn't break the experience. For the pomodoro timer — where the bell needs to fire at exactly the 25-minute mark — I request the exact alarm permission explicitly during the pomodoro setup flow.
What I Learned
Start local. I almost defaulted to FCM because that's what every tutorial recommends. But for an app sending 3 personalized notifications a day to users who already open the app regularly, local scheduling with fresh data on launch is simpler, cheaper, and more reliable.
Notification content is product design. The words in your notification matter more than the infrastructure behind them. "Don't forget to check in!" is a waste of attention. "You have 6h of usable time today. Scheduled: Writing (1h 30min)" is a service.
Duolingo's real lesson isn't the streak — it's the infrastructure thinking. Studying their system forced me to ask operational questions I would have ignored: cost per notification, failure modes, what happens offline.
Long Game is a life auditing app for people who want to be intentional with their time. Built with Flutter.
