+1 (415) 843-4662

Local notifications in Ionic apps: reminders, Android exact alarms and tap handling with Capacitor

Push notifications get the attention, but a large share of the notifications we ship are local: medication reminders, appointment alerts, "finish your checkout" nudges, timers. They need no server, work offline, and avoid push infrastructure entirely. They also have sharp edges, mostly on Android. This tutorial covers the full path with @capacitor/local-notifications. Examples use TypeScript and are framework-agnostic.

Install

npm install @capacitor/local-notifications
npx cap sync

1. Ask for permission at the right moment

On iOS and on Android 13+ notifications need runtime permission, and you usually get one good chance to ask. Do not ask on first launch. Ask when the user turns a reminder on.

import { LocalNotifications } from '@capacitor/local-notifications';

export async function ensureNotificationPermission(): Promise<boolean> {
  let { display } = await LocalNotifications.checkPermissions();
  if (display === 'prompt' || display === 'prompt-with-rationale') {
    ({ display } = await LocalNotifications.requestPermissions());
  }
  return display === 'granted';
}

If the result is denied, show an in-app explanation with a button that opens system settings. Asking again will not show a prompt.

2. Create an Android channel

Android 8+ groups notifications into channels, and the user controls sound and importance per channel. Create channels once at startup.

await LocalNotifications.createChannel({
  id: 'reminders',
  name: 'Reminders',
  description: 'Reminders you set in the app',
  importance: 4,
  visibility: 1,
});

Channel settings cannot be changed by the app after creation, so choose the importance carefully. Changing it later means creating a new channel id.

3. Schedule a notification

await LocalNotifications.schedule({
  notifications: [
    {
      id: 1001,
      title: 'Time for your check-in',
      body: 'Tap to log how you are feeling.',
      channelId: 'reminders',
      schedule: { at: new Date(Date.now() + 60 * 60 * 1000) },
      extra: { route: '/checkin' },
    },
  ],
});

Two habits that save pain:

  • Use deterministic integer ids derived from your own data (for example a reminder's database id), so you can cancel or replace it later. Ids must fit in a 32-bit integer.
  • Put routing data in extra, not in the body text.

Repeating schedules

Use on for calendar-style repeats, such as every day at 08:30, and every for intervals.

schedule: { on: { hour: 8, minute: 30 }, allowWhileIdle: true }

Remember that a repeating schedule keeps firing until you cancel it. Store what you scheduled so a settings screen can show and cancel it.

4. Android exact alarms

By default Android may batch notifications to save battery, so a reminder can arrive a few minutes late. For user-set times that matter (an alarm, a dose reminder), exact scheduling needs the exact alarm permission on Android 12 and later, and from Android 14 it is denied by default for most newly installed apps. Check before relying on it:

const { exact_alarm } = await LocalNotifications.checkExactNotificationSetting();
if (exact_alarm !== 'granted') {
  // Explain why, then send the user to the special-access screen
  await LocalNotifications.changeExactNotificationSetting();
}

Be honest in your store listing: Google Play restricts which apps may declare exact-alarm permissions to alarm clock, calendar and similar core-function apps. If your reminders are not time-critical, accept inexact delivery and say "around 8:30" in the UI. That is simpler and avoids a policy review.

5. Handle taps and foreground delivery

Register listeners once, early, and route from extra. Cold starts matter here: the tap that launched the app is delivered after the app boots, so register the listener before your first route renders.

LocalNotifications.addListener('localNotificationActionPerformed', (event) => {
  const route = event.notification.extra?.route;
  if (route) router.navigateByUrl(route); // guard: validate against known routes
});

LocalNotifications.addListener('localNotificationReceived', (n) => {
  // Fires while the app is in the foreground. Show an in-app toast instead.
});

Validate route against an allow-list of known paths. It is your own data, but it is persisted, and a bug or old app version can leave a stale path that lands on a blank screen.

6. Cancel, replace and reconcile

await LocalNotifications.cancel({ notifications: [{ id: 1001 }] });
const { notifications } = await LocalNotifications.getPending();

Treat your database as the source of truth and run a reconcile at startup: compare getPending() with what should be scheduled, and cancel or add the differences. This fixes the common failures: edited reminders, deleted items, and a user restoring a backup onto a new phone.

Testing checklist

  • Deny permission, then confirm the UI explains how to recover.
  • Schedule 2 minutes ahead, background the app, and lock the device. Test on a real Android device, not just an emulator.
  • Force-stop the app and reboot. Confirm pending notifications behave as you expect, and test on several manufacturers, since aggressive battery managers can suppress delivery.
  • Tap the notification from a cold start, from the background and from the foreground.
  • Change the device time zone and check that daily reminders still land at local time.
  • Android 13+: revoke notification permission in settings and confirm your app notices on next launch.

When to use push instead

Local notifications cannot react to something that happens elsewhere, such as a new message, an order status change or a server event. For those, use push (see our FCM HTTP v1 and APNs guide). Many apps use both: push for events, local for user-defined schedules.

Need help with notification reliability, store policy for exact alarms, or a Capacitor plugin that does not quite fit? That is routine work for our senior Ionic team, so get in touch.