npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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 install

Expo: 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 ios

The 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