21 Jul 2026 · iOS · React Native · Expo
iOS silently drops your notifications after 64
Pingy is a reminder app that works by refusing to shut up: instead of one notification you can dismiss and forget, it schedules a run of them on a repeating interval until you tap "I did it". With one reminder set, that worked perfectly. With two, the second one was half dead — and nothing in the code, the logs, or the app itself said anything was wrong.
The setup
Expo doesn't offer "fire every 30 minutes, but skip my quiet hours" as a single notification trigger, so Pingy faked a repeating reminder by scheduling a stack of one-shot notifications ahead of time:
while (count < 50 && attempt < 500) {
// skip anything that would land inside quiet hours, then:
await Notifications.scheduleNotificationAsync({ ... });
count++;
}
Up to 50 pending notifications, per reminder. Every call resolved successfully. The scheduling code had a try/catch around it, and the app's own "nudges sent" counter kept climbing.
The trap
iOS keeps a hard limit of 64 pending local notifications for the whole app. Anything scheduled past that is silently discarded — the promise still resolves, nothing throws, and there is no API that tells you it happened.
So the maths was: one reminder, 50 scheduled, fine. Two reminders, 100 requested, 64 kept — the second reminder is roughly half dead. Three or more, and the later ones may never fire at all.
It got worse: every edit — deleting a reminder, changing settings, tapping "I did it" — cancelled everything and rescheduled every reminder in a loop. So which notifications survived the cut moved around unpredictably each time.
Why it hides so well
Four things conspired:
scheduleNotificationAsyncresolves even for doomed requests. The try/catch catches nothing, because nothing fails — the system accepts the request and quietly evicts it later.- The in-app counter confirmed the wrong thing. It incremented per scheduling call, so the UI reported notifications that would never be delivered. The app was actively lying to me with my own number.
- It works perfectly with one reminder — which is exactly how you test a reminder app while building it.
- Android has no equivalent cap, so it never reproduces there.
The fix
Treat 64 as a shared budget for the whole app, not a per-feature allowance. This is the actual production code in Pingy 2.0:
/** iOS drops anything past this, app-wide. */
const IOS_PENDING_LIMIT = 64;
/** Left free so an incoming reminder always has room to schedule. */
const HEADROOM = 4;
const USABLE = IOS_PENDING_LIMIT - HEADROOM;
const MAX_PER_REMINDER = 24;
const MIN_PER_REMINDER = 4;
/** How many notifications one reminder may hold when `total` compete. */
export function budgetFor(total: number): number {
if (total <= 0) return MAX_PER_REMINDER;
const share = Math.floor(USABLE / total);
return Math.max(MIN_PER_REMINDER, Math.min(MAX_PER_REMINDER, share));
}
One reminder gets 24 slots; four get 15 each; past that it floors at 4, so every reminder keeps firing — just with a shorter runway that a top-up refills later.
The second half of the fix is trusting the only honest source:
/** What iOS actually kept — the only trustworthy count. */
async function pendingCount(): Promise<number> {
return (await Notifications.getAllScheduledNotificationsAsync()).length;
}
Whatever you asked to schedule is irrelevant; getAllScheduledNotificationsAsync() returns what survived. Pingy's stats now derive from each reminder's cadence instead of counting scheduling calls, so the number on screen finally matches what phones actually receive.
Worth knowing: if you don't need quiet hours, a single trigger with repeats: true sidesteps all of this — one pending notification per reminder, no budget needed. Pingy couldn't use it because quiet hours can't be baked into a repeating interval trigger, which is how it ended up on the budgeting path.
The rule
A scheduling call that resolves is not a scheduled notification. On iOS, 64 pending is a shared budget across your whole app — any loop that schedules an unbounded number of one-shots needs to check getAllScheduledNotificationsAsync() to learn what actually survived.
This bug shipped in Pingy 1.0 and was found rebuilding it for 2.0. Pingy is on the App Store.