react-native-local-notify
v1.0.0
Published
Local notifications for React Native that fire when the app is killed, with native delivery and interaction telemetry
Maintainers
Readme
react-native-local-notify
Local notifications for React Native that fire when the app is killed — and tell you what happened to them afterwards.
- Works from a dead app. Android schedules real alarms; iOS materialises per-occurrence requests and manages the 64-slot budget for you. No server, no network.
- Records delivery and interaction natively. Delivered, tapped, swiped away, action pressed, or blocked — written to an on-device ledger whether or not any JavaScript is running. Nothing else on npm does this.
- Surfaces what the platforms hide. Exact-alarm denial, battery optimisation, a full iOS slot budget, a blocked channel, the unqueryable Android tray cap. All of it reported instead of failing in silence.
- Recurrence that survives DST. Daily, weekly, monthly, yearly, several times a day, intervals, cron, or an explicit list — with correct time-zone maths, proven identical across TypeScript, Kotlin and Swift by a shared fixture corpus.
- Strongly typed end to end. Discriminated unions in TypeScript, sealed classes in Kotlin, enums with associated values in Swift.
- Zero runtime dependencies. New Architecture TurboModule, RN 0.80+.
The example app
Android
iOS — the same screen, reporting the platform's real limits rather than pretending they are not there:
example/ is a bare React Native app covering every feature: schedules, action
buttons, call style, custom sounds, telemetry draining and the health report. It
builds with minifyEnabled and shrinkResources on, so the library is exercised
in the configuration apps actually publish. Run it with yarn example android or
yarn example ios.
Install
npm install react-native-local-notify
cd ios && pod installExpo: add the plugin and prebuild — see docs/expo.md.
What works where
Two platforms, genuinely different capabilities. Read this at runtime instead of
branching on Platform.OS:
import { getCapabilities } from 'react-native-local-notify';
const { fullScreenNotifications, pendingRequestLimit } = getCapabilities();| Capability | Android | iOS | Why |
| --- | :---: | :---: | --- |
| Fires with the app force-killed | ✅ | ✅ | The point of the library. |
| Recurrence, DST-correct | ✅ | ✅ | Same engine, three implementations, one fixture corpus. |
| Delivery observed first-hand | ✅ | ❌ | The Android alarm runs in the app's process. iOS runs no code, so delivery is reconstructed afterwards and counts are a floor, not a total. |
| JavaScript runs while killed | ✅ | ❌ | Android headless task, best-effort. iOS runs nothing at all for a local notification. |
| Pending-notification cap | none | 64 | Shared with every other SDK in the app, silent when exceeded. Managed and reported. |
| Exact-time alarms | ✅ | n/a | Needs SCHEDULE_EXACT_ALARM; iOS delivery is punctual without a permission. |
| Full-screen / call-style | ✅ | ❌ | See below. |
| Expanded styles (big text, picture, inbox, messaging) | ✅ | ❌ | iOS has no template system; use subtitle and attachments. |
| Live Updates (progress) | 16+ | ❌ | Degrades to a plain progress bar below Android 16. |
| Attachments (image, audio, video) | ❌ | ✅ | Android's equivalent is the bigPicture style. |
| Interruption levels / Focus break-through | n/a | ✅ | Android's equivalent is channel importance. |
| Sound set per notification | < 26 | ✅ | From Android 8 sound belongs to the channel. |
| Inline reply | ✅ | ✅ | |
| Background horizon top-up | n/a | ✅ | Android has no cap to top up; a daily watchdog repairs instead. |
Android-only API surface
precision, allowPrecisionDowngrade, configure({ channels, android }),
content.android.*, openSettings('exactAlarm' | 'batteryOptimization' | 'fullScreenIntent'),
setBackgroundEventHandler (killed state), getHealth().android.
iOS-only API surface
configure({ iosCategories, ios }), content.ios.*, setBadgeCount,
getHealth().ios.
Calling either set on the other platform is safe — the fields are ignored, not an error — so a cross-platform app declares both and lets each platform take its half.
Sixty-second start
import LocalNotify from 'react-native-local-notify';
// Once, at app start. One channel becomes the default, so notifications do not
// have to name it.
await LocalNotify.configure({
channels: [{ id: 'reminders', name: 'Reminders', importance: 'high' }],
});
await LocalNotify.requestPermission();
// Weekdays at 09:30, in the user's own time zone, forever.
const result = await LocalNotify.schedule({
id: 'standup',
content: {
title: 'Stand-up in 5 minutes',
body: 'Time to join',
data: { screen: 'standup' },
},
trigger: {
type: 'weekly',
time: '09:30',
weekdays: ['mon', 'tue', 'wed', 'thu', 'fri'],
},
});
// Never ignore this. A schedule can succeed while being quietly degraded.
if (result.warnings.length > 0) {
console.warn(result.warnings);
}That is a working reminder that fires with the app force-killed, the device rebooted, and no internet.
Read the warnings
Every other library in this space fails silently. This one does not — but you have to look:
| Warning | Means |
| --- | --- |
| PRECISION_DOWNGRADED | Android exact alarms are not permitted, so this fires late. Send the user to openSettings('exactAlarm'). |
| SLOT_BUDGET_EXHAUSTED | iOS's 64 pending requests are full. Nothing armed yet; it will arm as slots free. |
| HORIZON_TRUNCATED | Armed only as far as coveredUntil. Tell the user that, not "reminders on". |
| NO_FUTURE_OCCURRENCE | The trigger is valid but every occurrence is in the past. |
| NOT_PERMITTED | Permission or the channel is off; this will not be seen. |
Triggers
{ type: 'date', at: Date.now() + 60_000 }
{ type: 'daily', time: '07:00' }
{ type: 'daily', time: '07:00', interval: 3, startAt: Date.now() } // every 3rd day
{ type: 'weekly', time: '09:30', weekdays: ['mon', 'wed', 'fri'] }
{ type: 'monthly', time: '23:00', days: ['last'] } // last day
{ type: 'yearly', time: '10:00', months: [1, 7], day: 15 }
{ type: 'timesOfDay', times: ['11:00', '14:00', '17:00'] }
{ type: 'interval', everyMs: 3_600_000, anchorAt: Date.now() }
{ type: 'cron', expression: '*/15 9 * * mon-fri' }
{ type: 'custom', at: [instant1, instant2] }Every recurring trigger takes startAt, endAt, count, timeZone,
excludeDates and dstPolicy. Preview one before scheduling:
import { previewOccurrences } from 'react-native-local-notify';
previewOccurrences({ type: 'monthly', time: '09:00', days: ['last'] }, 3);Wall-clock times are interpreted in timeZone, or the device's zone when omitted —
so a 07:00 reminder stays at 07:00 when the user flies. Month-end days that do not
exist are skipped, not clamped: "the 31st" and "the 30th" are different
reminders. Spring-forward gaps either shift (a missing 02:30 becomes 03:30) or skip,
your choice via dstPolicy; an ambiguous fall-back time always resolves to the
earlier instant.
Full-screen and call-style notifications
Android can take over the screen like an incoming call:
await LocalNotify.displayNow({
title: 'Incoming call',
actions: [
{ id: 'answer', title: 'Answer', opensApp: true },
{ id: 'decline', title: 'Decline' },
],
android: {
channelId: 'calls', // importance: 'high'
fullScreenIntent: true,
style: {
type: 'call',
caller: { name: 'Bala' },
answerActionId: 'answer',
declineActionId: 'decline',
},
},
});Needs USE_FULL_SCREEN_INTENT, and one line on your own activity so it can appear
over the lock screen — see docs/display.md.
iOS cannot do this for a local notification, and neither can anything else. The
full-screen incoming-call UI is CallKit, and CallKit may only be driven by a PushKit
VoIP push; UserNotifications is the documented alternative to CallKit rather
than a route into it. The loudest available iOS treatment is
ios: { interruptionLevel: 'critical' }, which needs an Apple-granted entitlement,
and from iOS 26 AlarmKit covers alarm-style alerts. getCapabilities().fullScreenNotifications
is false on iOS so you can branch on it rather than discovering this in review.
Custom sounds
Sound belongs to the channel on Android 8+ and to the notification on iOS — so the same sound is declared in two places:
await LocalNotify.configure({
channels: [{ id: 'reminders', name: 'Reminders', sound: 'chime' }], // res/raw/chime.wav
});
await LocalNotify.schedule({
id: 'x',
content: { title: 'Time to drink water', ios: { sound: 'chime.wav' } },
trigger: { type: 'daily', time: '11:00' },
});Android takes the resource name without an extension; iOS takes the full file name.
The Expo plugin copies and renames the files for you (sounds: ['./assets/chime.wav']).
Changing a channel's sound later needs a version bump — Android freezes it on
first creation. Full detail in docs/display.md.
Telemetry — opt-in
Off by default, and opting in is what creates the tables. An app that does not want a delivery ledger never gets one:
await LocalNotify.configure({ telemetry: { enabled: true } });The rest of the store is not optional. An alarm can start the process with no
JavaScript running, and reading back what to post is the only way killed-state
delivery can work — so schedules and occurrences are always persisted. Only the
deliveries and interactions tables are created on demand.
Full detail in docs/telemetry.md.
const batch = await LocalNotify.telemetry.drain({ limit: 500 });
await uploadToYourAnalytics(batch);
await LocalNotify.telemetry.ack({
deliveries: batch.deliveries.map((row) => row.id),
interactions: batch.interactions.map((row) => row.id),
});drain claims, ack deletes — so a crash mid-upload loses nothing, and two callers
racing cannot double-report. Rows carry latencyMs (delivered → opened), source
(how the delivery became known), appState, and a BLOCKED interaction when the
notification could not be shown at all.
Live events too, while a JavaScript context exists:
import { useNotificationEvents } from 'react-native-local-notify';
useNotificationEvents((event) => {
// DELIVERED | TAPPED | DISMISSED | ACTION | BLOCKED
});Action buttons and their callbacks
Two or three buttons, each with a handler:
// index.js — module scope, so a press can reach it from a cold start
import { setActionHandlers } from 'react-native-local-notify';
setActionHandlers({
drank: (event) => logGlass(event.data?.slot),
snooze: (event) => snooze(event.scheduleId),
});content: {
title: 'Time to drink water',
actions: [
{ id: 'drank', title: 'Done' },
{ id: 'snooze', title: 'Snooze 10m' },
{ id: 'open', title: 'Open', opensApp: true },
],
}Android renders 3 buttons; iOS renders 4 but shows only the first 2 unexpanded.
Extras are dropped by the system, so schedule() warns with ACTIONS_TRUNCATED
rather than letting them vanish — put the important button first.
Handlers always run in JavaScript, keyed by action id rather than as closures
passed to schedule(): a notification outlives the context that created it, so a
function captured at schedule time is long gone by the time the button is pressed.
Full detail, including which JavaScript context runs it when the app was killed, in
docs/display.md.
Diagnosing "notifications don't work on my phone"
const health = await LocalNotify.getHealth();One call, every reason delivery fails: permission state, exact-alarm grant, battery optimisation, App Standby bucket, manufacturer risk, active tray count, iOS slots used and available, horizon end, background-refresh registration, unsynced telemetry. Built for a support screen.
Platform reality
The constraints this library manages rather than pretends away. Full tables in docs/android.md and docs/ios.md.
| | |
| --- | --- |
| iOS 64 pending requests | Hard, silent, and shared with every other SDK in the app. Managed with a fair-share budget across schedules, and reported when it truncates. |
| iOS killed delivery | No code runs. Reconstructed from the tray on next launch using the system's own timestamp. Counts are a floor, not a total — a notification cleared before reopening is unobservable by anyone. |
| Android exact alarms | Not granted on install from Android 14. Schedules degrade to inexact and say so. |
| Android force-stop and OEM killers | Cancel alarms with no callback. A daily WorkManager watchdog re-arms. |
| Android tray cap | 24–50 per app, OEM-dependent, unqueryable. The library checks whether its notification actually landed. |
| Android 12 trampoline ban | An activity cannot be started from a receiver, so tap tracking runs through a library-owned invisible activity. Zero app code. |
| Channel immutability | Importance and sound freeze on creation. Bump version to ship new defaults. |
Migrating
From Notifee (archived April 2026), expo-notifications, or
react-native-push-notification: docs/migration.md.
Docs
- android.md — exact alarms, the precision ladder, why reminders vanish
- ios.md — the slot budget, delivery reconstruction, background refresh
- telemetry.md — events, the ledger, what the fields mean
- display.md — styles, Live Updates, inline replies, attachments
- expo.md — the config plugin
- schema.sql — the on-device store, shared by both platforms
Requirements
React Native 0.80+ (New Architecture), Android 7+ (API 24), iOS 15.1+.
Contributing
yarn # install
yarn test # JavaScript tests
yarn test:kotlin # Kotlin recurrence parity
yarn test:swift # Swift recurrence parity and slot budget
yarn test:parity # all three
yarn example android # or iosThe recurrence engine exists three times — TypeScript, Kotlin, Swift — because it
has to run when no JavaScript exists: on boot, from a watchdog, from an iOS
background refresh. fixtures/recurrence/cases.json is the contract all three are
held to. Change behaviour there first.
More in CONTRIBUTING.md.
Licence
MIT
