Skip to content
All posts

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.

MFKAPPS 7 min read

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:

  1. POST_NOTIFICATIONS (runtime, Android 13+). Without this, your notification literally doesn’t appear. Ask at the right moment, not on first launch.
  2. SCHEDULE_EXACT_ALARM (Android 12+, restricted on 13+). Required for setExactAndAllowWhileIdle. On Android 14+ this is granted only after the user explicitly toggles it in system settings; the app has to send the user there.
  3. 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 — shouldShowRequestPermissionRationale is 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. PeriodicWorkRequest won’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_COMPLETED and 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_CHANGED and 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:

  1. 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.
  2. 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 WorkManager job 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.