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.
A reminder app on Android only works if the reminder shows up. Sounds obvious — until you ship one. Then you discover doze mode, app standby buckets, OEM battery optimizers, the SCHEDULE_EXACT_ALARM permission, the POST_NOTIFICATIONS runtime permission, foreground service restrictions, and the lovely fact that Samsung and Xiaomi will silently kill your scheduled work to “save battery” no matter how correctly you wrote it.
I built Hydrame, a water-tracking app whose entire value proposition is “remind me at the right time, gently.” I’ve spent more time than I’d like to admit fighting the Android reminder stack into reliability. This post is the 2026 version of the playbook that finally works.
The reminder problem in one paragraph
Android is aggressive about killing background work, and it should be — left unchecked, every app would wake the radio every few minutes and your battery would die by lunch. The result for legitimate reminder apps is a stack of overlapping APIs that each behave slightly differently across versions and OEM skins. The job is to choose the right primitive for your level of “must be exact” and accept that the rest is testing on real devices.
The two primitives that matter in 2026 are:
WorkManager— for inexact, deferrable work. Periodic checks, “remind me sometime in the next hour” use cases. Battery-aware, gets coalesced, runs even after reboots.AlarmManager.setExactAndAllowWhileIdle— for exact user-visible notifications at a specific time. The expensive one. Use sparingly and only when “off by 15 minutes” would make the feature feel broken.
If your reminder must fire at exactly 19:30, you need an exact alarm. If “sometime around 19:30” is fine, use WorkManager. Most apps default to the wrong one.
Three permissions you have to get right
In 2026, three runtime/install permissions decide whether the user ever sees a reminder:
POST_NOTIFICATIONS(runtime, Android 13+). Without this, your notification literally doesn’t appear. Ask at the right moment, not on first launch.SCHEDULE_EXACT_ALARM(Android 12+, restricted on 13+). Required forsetExactAndAllowWhileIdle. On Android 14+ this is granted only after the user explicitly toggles it in system settings; the app has to send the user there.USE_EXACT_ALARM(Android 13+, special-use). For apps where exact alarms are core (alarm clocks, calendar reminders, hydration). Play asks for justification.
The Hydrame manifest looks like this:
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
RECEIVE_BOOT_COMPLETED is the one most people forget — without it, your scheduled alarms disappear on the next reboot. Re-schedule from a BroadcastReceiver on ACTION_BOOT_COMPLETED.
Asking for POST_NOTIFICATIONS at the right moment
Don’t ask on first launch. The acceptance rate is dramatically higher if you ask at a moment that maps to the value: right when the user enables their first reminder. A real flow:
@Composable
fun EnableReminderButton(onEnabled: () -> Unit) {
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) onEnabled()
}
FilledButton(onClick = {
if (Build.VERSION.SDK_INT >= 33) {
val state = ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS,
)
if (state == PackageManager.PERMISSION_GRANTED) onEnabled()
else launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
onEnabled()
}
}) { Text("Enable reminders") }
}
Two more things I always do:
- A second-chance rationale. If the user denies, don’t ask again immediately. Show a small inline message next time they try to enable a reminder, with a button that opens the app’s settings screen so they can flip it themselves.
- Detect a denied-with-don’t-ask-again state by remembering the last refusal in DataStore —
shouldShowRequestPermissionRationaleis unreliable on its own for this.
Inexact: WorkManager periodic work
For a reminder where “sometime in the morning” is acceptable, WorkManager is the boring right answer:
class DailyHydrationCheckWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
scheduleTodayReminders()
return Result.success()
}
}
fun scheduleDailyHydrationCheck(context: Context) {
val request = PeriodicWorkRequestBuilder<DailyHydrationCheckWorker>(
repeatInterval = 1, repeatIntervalTimeUnit = TimeUnit.DAYS,
)
.setConstraints(
Constraints.Builder()
.setRequiresBatteryNotLow(true)
.build(),
)
.setInitialDelay(secondsUntil(6, 0).toLong(), TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"daily_hydration_check",
ExistingPeriodicWorkPolicy.UPDATE,
request,
)
}
A few things that bite people:
- 15-minute minimum period.
PeriodicWorkRequestwon’t run more often than every 15 minutes regardless of what you ask for. For sub-15-minute scheduling, use one-time work that chains itself. - No exact timing. WorkManager schedules within a window. If you need “at 8:00:00 sharp”, this is the wrong primitive.
- Battery-not-low is friendly to your users and friendly to the system; turn it on unless you have a reason not to.
Exact: AlarmManager.setExactAndAllowWhileIdle
When the reminder must fire at a specific moment — e.g., a user said “remind me at 19:30” and they mean it — you need an exact alarm. Pattern:
fun scheduleExactReminder(
context: Context,
triggerAtMillis: Long,
requestCode: Int,
) {
val am = context.getSystemService(AlarmManager::class.java)
if (Build.VERSION.SDK_INT >= 31 && !am.canScheduleExactAlarms()) {
// Send the user to the system page to grant the permission.
context.startActivity(
Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM)
.setData(Uri.parse("package:${context.packageName}"))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
return
}
val intent = Intent(context, ReminderReceiver::class.java)
.putExtra("requestCode", requestCode)
val pending = PendingIntent.getBroadcast(
context, requestCode, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, pending)
}
setExactAndAllowWhileIdle is the one that respects doze. Use setAlarmClock instead only if your app genuinely is an alarm clock — it shows the next-alarm chip on the system UI and gets the highest priority.
Receiver + foreground service for the heavy moment
When the alarm fires, you have a few seconds of wake-time to post a notification. For a simple “ding!” reminder that’s enough:
class ReminderReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
// Post the notification synchronously.
val nm = context.getSystemService(NotificationManager::class.java)
nm.notify(
intent.getIntExtra("requestCode", 0),
buildHydrationNotification(context),
)
// Re-schedule the next one.
scheduleNext(context)
}
}
If you need to do real work on alarm (sync, download), promote to a foreground service immediately. Don’t try to be clever — the system will revoke you in a moment if you stall in the receiver.
Rebooting and timezone changes
Two events will silently break a naive implementation:
- Reboot. Alarms are wiped. Listen for
ACTION_BOOT_COMPLETEDand re-schedule from your persistent state. - Timezone change. A 7 a.m. reminder shouldn’t fire at 7 a.m. local before the user moved and 7 a.m. Istanbul after. Listen for
ACTION_TIMEZONE_CHANGEDand re-compute.
A single receiver handles both:
<receiver android:name=".BootAndTimezoneReceiver" android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
<action android:name="android.intent.action.TIMEZONE_CHANGED"/>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>
</intent-filter>
</receiver>
MY_PACKAGE_REPLACED is the one app updates fire — your code is fresh, but your alarms are gone. Re-schedule there too.
OEM optimizers — the silent killers
In 2026 the worst remaining offender is still Samsung’s “Sleeping apps” list, with Xiaomi’s autostart and battery saver close behind. Two practical defenses:
- In-app diagnostic banner. If notifications haven’t fired in 24 hours and the user has reminders enabled, surface a small banner with a “Fix this” deep-link into the right OEM settings page. Stack Overflow and the Don’t Kill My App project keep the deep links current; cache them locally.
- Don’t ask for battery whitelist eagerly. It scares users and Play frowns on it. Save it for the user who has already seen a missed reminder.
The pattern that ended up working
After three iterations on Hydrame, the architecture that finally felt reliable:
- One periodic
WorkManagerjob runs nightly. It reads the user’s schedule and inserts the next 24 hours of exact alarms. - Each exact alarm posts a notification via a
BroadcastReceiver. The receiver is dumb — no DB calls, no network. - Reboot + timezone-change + app-update receivers trigger the same nightly job manually to re-create the schedule.
- A heartbeat metric (last successful notification timestamp, stored in DataStore) powers the in-app “your reminders may be killed by your phone” banner.
It’s more wiring than I would have written if I’d trusted my first instinct (“just use WorkManager”). But every piece exists because something silently broke without it.
Testing on real phones, again
I’ll repeat the same line from every Android post I write: you cannot test reliable reminders on the emulator alone. Get a cheap Samsung, a cheap Xiaomi, and a Pixel. Set a reminder for 30 minutes from now. Lock the phone and leave it on the desk. Come back and see what fired and what didn’t. That single experiment is worth more than any blog post.
Including this one. Go test it.
// Related reading
More from the journal
Privacy-first OCR on Android: how Subly reads bills without the cloud
A practical 2026 guide to on-device OCR on Android with ML Kit Text Recognition — the setup, the code, and the field extraction tricks that keep user data off your servers.
Local-first Android in 2026: SQLite, Room, and keeping user data on the device
A 2026 guide to building local-first Android apps with Room and SQLite — schema design, migrations, WAL, exports, and when (and when not) to add sync.
How I ship Android apps as a solo developer in 2026
An actual end-to-end launch playbook for shipping a Kotlin + Compose Android app alone in 2026 — scope, build, Play Store listing, and the boring bits that decide whether you ever launch.