●MAX — Rork Max builds native Swift apps instead of React Native, supporting iPhone, iPad, Watch, TV, Vision Pro, and iMessage●NATIVE — It unlocks native capabilities: AR/LiDAR, Metal 3D games, Dynamic Island, Live Activities, HealthKit, and Core ML●CORE — Standard Rork generates iOS/Android apps with React Native (Expo), taking you from plain English to the app stores●FUNDING — Rork raised $2.8M from a16z (Andreessen Horowitz)●GROWTH — The platform now sees 743,000 monthly visits with 85% growth●PRICING — Free to start, with paid plans from $25/month●MAX — Rork Max builds native Swift apps instead of React Native, supporting iPhone, iPad, Watch, TV, Vision Pro, and iMessage●NATIVE — It unlocks native capabilities: AR/LiDAR, Metal 3D games, Dynamic Island, Live Activities, HealthKit, and Core ML●CORE — Standard Rork generates iOS/Android apps with React Native (Expo), taking you from plain English to the app stores●FUNDING — Rork raised $2.8M from a16z (Andreessen Horowitz)●GROWTH — The platform now sees 743,000 monthly visits with 85% growth●PRICING — Free to start, with paid plans from $25/month
Your Daily Reminder Stops Firing After a Couple of Weeks — iOS's Invisible 64-Notification Cap
When a daily reminder built with Rork (Expo) goes silent after a while, the cause is usually iOS's 64 pending-notification limit. Design a repeating calendar trigger for fixed messages and a rolling reschedule for daily-changing content, with working code that survives DST and multiple reminders.
"Deliver one line every morning at 8." When I added that to a daily affirmation app, I naively scheduled thirty days' worth in one shot. It worked perfectly in the simulator. Then, a while after release, users started writing in: "It fired for the first few days, then stopped."
It wasn't a bug in my code. It was a limit the official tutorials barely mention: iOS only holds up to 64 pending local notifications per app. Thirty days is far over that — and if you run a morning-and-evening pair, you can't even bank a full month. Anything past the cap is dropped silently, with no error.
This article walks through rebuilding a daily reminder so it keeps firing, using working expo-notifications code. I'll leave the basic setup to the local notifications setup guide and focus only on living within the cap.
iOS keeps only 64 pending notifications — the rest are dropped silently
Apple keeps at most 64 un-fired (pending) local notifications per app. If you try to schedule a 65th with scheduleNotificationAsync, no exception is thrown. iOS keeps the 64 with the soonest fire times and quietly drops the rest from the pending list.
The subtle part: a repeats: true notification counts as one slot, no matter how many times it will fire. "Every day at 8" built as a repeating trigger costs a single slot forever. But the moment you want "a different body every day," repeats is off the table, and you start banking one notification per date — which hits the 64-slot wall fast.
So the decision is simple:
The text can be the same every day → one repeating trigger. The cap is basically a non-issue.
You want the text to change daily → individual scheduling, and you must budget the 64 slots.
I lost time early on by not making this call up front. Classifying each reminder as "fixed text" or "daily-changing" before writing anything makes the whole design fall into place.
Measure first — how many are you actually banking?
Before changing the design, measure. getAllScheduledNotificationsAsync returns the current pending array, so a single log line at launch shows how many slots you're using.
import * as Notifications from 'expo-notifications';// Call once at launch. Guard with __DEV__ in production to keep logs quiet.export async function inspectPendingNotifications(): Promise<number> { const pending = await Notifications.getAllScheduledNotificationsAsync(); if (__DEV__) { console.log(`[notif] pending=${pending.length}/64`); pending.forEach((n) => { // Trigger shape differs by platform, so log it loosely. console.log(' -', n.identifier, JSON.stringify(n.trigger)); }); } return pending.length;}
If pending.length ever crosses 60, you're already in the danger zone. In my production apps I ship this number to internal metrics on every launch, so I never discover after the fact that I've been pinned at the cap. Banking notifications without watching the count is like topping up fuel without looking at the gauge.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You'll understand why a reminder that delivers a different line each morning suddenly goes quiet — iOS's invisible 64 pending-notification cap — and how to design around it
✦You'll be able to decide, from working code, when a single repeating calendar trigger is enough and when daily-changing content needs a rolling reschedule
✦You'll be able to rebuild your own app so the fire time never drifts across DST, time-zone moves, or a morning-plus-evening reminder pair
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
If the text is fixed, one repeating calendar trigger is enough
If "the same line every morning at 8" (say, a nudge to open the app) is fine, the answer is almost anticlimactically simple: a SchedulableTriggerInputTypes.CALENDAR trigger with repeats: true, passing hour and minutedirectly.
import * as Notifications from 'expo-notifications';import { SchedulableTriggerInputTypes } from 'expo-notifications';const MORNING_TAG = 'daily-morning';export async function scheduleFixedMorning(): Promise<void> { // Clear our own existing entry first to avoid duplicate registration. await cancelByTag(MORNING_TAG); await Notifications.scheduleNotificationAsync({ content: { title: 'Today\'s line', body: 'Open the app and take in today\'s words.', data: { tag: MORNING_TAG }, // marker so we can cancel selectively later }, trigger: { type: SchedulableTriggerInputTypes.CALENDAR, hour: 8, minute: 0, repeats: true, }, });}// Cancel only our notifications, identified by data.tag.export async function cancelByTag(tag: string): Promise<void> { const pending = await Notifications.getAllScheduledNotificationsAsync(); await Promise.all( pending .filter((n) => n.content.data?.tag === tag) .map((n) => Notifications.cancelScheduledNotificationAsync(n.identifier)), );}
The key is that a repeating trigger consumes exactly one slot, permanently. A calendar trigger binds to "8:00 in the device's local time," so it also absorbs the DST issue described below. Anything that can be fixed text is most robust as this single trigger — don't force it to be daily-changing.
Daily-changing content needs a "rolling reschedule"
This is the real case. To show a different body each day you can't use repeats, so the strategy is a rolling reschedule: instead of banking the entire future, you bank only the next N days and refill from the front every time the app opens.
Spending all 64 slots on one reminder is risky, so I cap mine at 16 days. That leaves headroom for other reminders and one-off notifications, and a user who doesn't open the app for 16 days won't be revived by a notification anyway, so the real-world loss is negligible.
As an indie developer running several apps in parallel, I recommend fixing the morning window at 16 days and the evening reflection at 12. With that split, even when a sudden one-off notification grabs about 10 slots the total stays under 64, and I've never once pinned the cap in production.
import * as Notifications from 'expo-notifications';import { SchedulableTriggerInputTypes } from 'expo-notifications';const DAILY_TAG = 'daily-affirmation';const WINDOW_DAYS = 16; // a safe window that won't devour the 64 slotsconst HOUR = 8;const MINUTE = 0;// Pure function that picks the content for a given day (seeded by the date).function affirmationFor(date: Date, pool: string[]): string { // Seed with the local day count → the same day yields the same line. const dayIndex = Math.floor( date.getTime() / 86_400_000 - date.getTimezoneOffset() / 1440, ); return pool[((dayIndex % pool.length) + pool.length) % pool.length];}export async function rescheduleDailyWindow(pool: string[]): Promise<void> { // 1) Clear all of our daily notifications first. await cancelByTag(DAILY_TAG); // 2) Schedule N days individually, starting from today's fire time. const now = new Date(); for (let offset = 0; offset < WINDOW_DAYS; offset++) { const fireAt = new Date(now); fireAt.setDate(now.getDate() + offset); fireAt.setHours(HOUR, MINUTE, 0, 0); // Skip times already past (e.g., it's already after 8am today). if (fireAt.getTime() <= now.getTime()) continue; await Notifications.scheduleNotificationAsync({ content: { title: 'Today\'s line', body: affirmationFor(fireAt, pool), data: { tag: DAILY_TAG }, }, trigger: { type: SchedulableTriggerInputTypes.DATE, date: fireAt, // a concrete date-time; no repeats }, }); }}
The trick is passing a concrete Date to the DATE trigger. A time built from new Date() plus setHours is interpreted in the device's local zone, so it lands on the user's real "8 in the morning." Keeping affirmationFor pure lets you prove in tests that "the same day always shows the same line."
Designing so the time never drifts across DST or time-zone moves
This is where people get burned. There are two ways to express "8 o'clock," and they behave completely differently.
One is TIME_INTERVAL (after N seconds). Because it's an absolute elapsed time, it shifts by an hour at a DST change. If you refill by scheduling "8 hours from now," the boundary day drags your reminder to 7 or 9.
The other is the CALENDAR / DATE (wall-clock) approach used above. It binds to "8:00 local," so even across DST the time the user sees stays 8. For a daily reminder, where a fixed time is the feature, always choose wall-clock.
The rolling reschedule is also naturally resilient to time-zone moves (travel, relocation). Because it rebuilds the window in the current local time on every launch, opening the app at the destination rebuilds it for the new zone's 8am. A design that banks 365 days ahead can't follow along. Not over-banking the future turns out to be what keeps the time accurate.
A morning-and-evening pair means budgeting the 64 slots
Run more than one reminder type and the 64 slots become a shared resource. If you do both a morning line and an evening reflection, budget them so neither starves the other. Here's the rule of thumb I use across several apps.
Purpose
Method
Slots (approx.)
Notes
Fixed-text scheduled reminder
Repeating calendar
1 per kind
Top priority; push work here first
Daily content (morning)
Rolling 16 days
Up to 16
Shrink the window to use fewer
Daily content (evening)
Rolling 12 days
Up to 12
Allocate shorter than morning
Event-driven one-offs
Individual DATE
From remaining slots
Check free slots before banking
Safety margin
—
Keep 10+ free
Always reserved for sudden notices
The numbers don't need to be exact, but the constraint "the total never exceeds 64" should be expressed as a constant in code. Lock the window length into a constant like WINDOW_DAYS, and revisit the total when you add a kind. I verify that sum in a unit test, so adding a new reminder automatically flags me if it would breach the cap.
Keep exactly one "refill" sync function for app launch
The heart of the rolling approach is the launch-time sync. Have one function that rebuilds the window on every foreground resume, and funnel all entry through it. Call scheduleNotificationAsync from scattered places and you'll get duplicates and slot mismatches immediately.
import { useEffect } from 'react';import { AppState, AppStateStatus } from 'react-native';import * as Notifications from 'expo-notifications';const POOL = [ 'Even a small step changes the view — but only for those who keep going.', 'Quietly focus on what you can do today.', // ...in production, prepare dozens to hundreds and cycle through them];// The single entry point for sync. Don't touch scheduling anywhere else.export async function syncReminders(): Promise<void> { const granted = await ensurePermission(); if (!granted) return; // bank nothing without permission await scheduleFixedMorning(); // fixed slot (1) await rescheduleDailyWindow(POOL); // daily window (up to 16) if (__DEV__) await inspectPendingNotifications();}async function ensurePermission(): Promise<boolean> { const { status } = await Notifications.getPermissionsAsync(); return status === 'granted';}// Hook that syncs at launch and on foreground resume.export function useReminderSync(): void { useEffect(() => { syncReminders(); const sub = AppState.addEventListener('change', (s: AppStateStatus) => { if (s === 'active') syncReminders(); }); return () => sub.remove(); }, []);}
Keep syncReminders idempotent — calling it repeatedly yields the same result. Because it cancels by tag and rebuilds each time, even a double call converges to a constant pending count. Routing the user to the right screen after they tap a notification is a separate concern, which I split into routing after a notification tap.
Easy-to-miss pitfalls
Android behaves differently. The 64-slot cap is iOS-specific. Android has no equivalent hard limit, but vendor power-saving (Doze, force-stop) can drop scheduled notifications. Because the rolling approach refills on every launch, it ends up resilient to Android's losses too. Covering both OSes with the same sync function is a quiet advantage of this design.
Don't bank without permission. Schedule without going through ensurePermission and, from the user's side, simply nothing happens. When you ask for notification permission directly affects opt-in rates, so don't prompt cold on first launch — I split the build-context-then-ask approach into soft-asking for notification permission.
Watch for differences that only appear in production. Scheduling itself works in Expo Go, but some display behavior differs between dev and production builds. Before release, always verify both the 64-slot pinning and the fire time on a real device via TestFlight / internal testing.
Your next step
Start by logging the length of getAllScheduledNotificationsAsync at launch in your own app. That one line tells you how much headroom your reminders have against the cap. Once the number is visible, sorting things into fixed slots and rolling windows comes naturally.
Daily notification design is an area I've refined slowly over long-term operation, and I still tune the window length against real data. If this trims a little of the detour for anyone trying to build notifications that keep firing, I'll be glad.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.