Skip to content

Managing iOS Notification Budgets: Priority-Based Eviction for the 64-Notification Limit

Introduction

Your app schedules notifications for habits, reminders, streaks, and inactivity nudges. Everything works in development. Then in production, users start reporting: "I stopped getting my reminders." No crash, no error — notifications just vanish.

The cause: iOS silently drops local notifications beyond 64 pending requests. No error callback, no delegate method, no console warning. Your app has no way to know notifications were discarded unless you explicitly query UNUserNotificationCenter and count what survived.

The naive approach — schedule everything and hope for the best — works until your app grows a second notification type. The intentional approach is a budget manager: a system that knows about every notification type, assigns priorities, and deterministically chooses which notifications survive when the budget overflows.

This article walks through a production-tested implementation using Swift actors and priority-based eviction. By the end, you'll have a complete, testable NotificationBudgetManager that you can drop into any iOS project.

The 64-Notification Ceiling

iOS enforces a hard 64 pending notification limit per app. This applies to all local notifications scheduled via UNUserNotificationCenter — the modern framework introduced in iOS 10.

Apple's archived Local and Push Notification Programming Guide states:

"An app can have only a limited number of scheduled notifications; the system keeps the soonest-firing 64 notifications (with automatically rescheduled notifications counting as a single notification) and discards the rest."

The modern UNUserNotificationCenter documentation doesn't prominently restate this limit, which has caused widespread confusion. But the limit is still enforced.

What happens when you exceed it:

  • The system keeps the soonest-firing 64 and silently discards the rest
  • There is no error, no completion handler failure, no delegate callback
  • Repeating notifications count as a single notification toward the limit
  • Remote push notifications are not affected — only local/scheduled notifications count

Apps with multiple notification types hit this limit fast. Consider: 7 daily habit reminders × 7 days ahead = 49. Add weekly streak notifications, depletion warnings, and onboarding nudges — you're past 64 before you realize it.

TIP

You can verify your current count by calling UNUserNotificationCenter.current().getPendingNotificationRequests() and checking the array length. Do this during development to catch budget overflows early.

Designing the Priority System

Not all notifications are equal. A reminder the user explicitly configured matters more than a system-generated re-engagement nudge. Our priority system encodes this hierarchy:

PriorityTypeRationale
0 (highest)User-configured remindersUser explicitly set these — dropping them breaks trust
1Retention-critical (Day 1 onboarding)Time-sensitive and directly tied to retention metrics
2Engagement (depletion warnings)System-generated but actionable
3 (lowest)Re-engagement (inactivity nudges)Ambient — user won't notice if one is dropped

The principle: user intent > system-generated, and time-sensitive > ambient.

We encode this as a Swift enum with a separate priority computed property. This lets multiple raw values map to the same priority level (e.g., habit and entry reminders share priority 0):

swift
private enum NotificationType: Int {
    case habit = 0
    case entry = 1
    case day1 = 2
    case depletion = 3
    case inactivity = 4
    case unknown = 99

    var priority: Int {
        switch self {
        case .habit, .entry: 0    // User-configured
        case .day1: 1             // Retention-critical
        case .depletion: 2        // Engagement
        case .inactivity: 3       // Re-engagement
        case .unknown: 99         // Unrecognized → evict first
        }
    }
}

To classify a notification into its type, we use identifier prefixes. Each scheduler in the app uses a unique prefix when creating notification identifiers:

swift
private let habitPrefix = "com.myapp.habitReminder"
private let entryPrefix = "com.myapp.entryReminder"
private let day1Prefix = "com.myapp.day1Reminder"
private let depletionPrefix = "com.myapp.depletionReminder"
private let inactivityPrefix = "com.myapp.inactivityReminder"

private func determineType(from identifier: String) -> NotificationType {
    if identifier.hasPrefix(habitPrefix) { return .habit }
    if identifier.hasPrefix(entryPrefix) { return .entry }
    if identifier.hasPrefix(day1Prefix) { return .day1 }
    if identifier.hasPrefix(depletionPrefix) { return .depletion }
    if identifier.hasPrefix(inactivityPrefix) { return .inactivity }
    return .unknown
}

Important

Any notification with an unrecognized prefix gets unknown (priority 99) — it will be evicted first. This is intentional: if a new notification type is added without updating the budget manager, it degrades gracefully rather than evicting important notifications.

The Eviction Algorithm

The core algorithm uses a two-tier deterministic sort:

  1. Primary: Fire date ascending — keep the soonest notifications
  2. Secondary: Type priority ascending — among notifications with the same fire date, keep higher-priority types

This means the manager always preserves the nearest, most important notifications and evicts the farthest, least important ones.

Here's the complete enforceLimit() method:

swift
func enforceLimit() async {
    let requests = await center.pendingNotificationRequests()

    guard requests.count > maxNotifications else { return }

    let prioritized = requests.compactMap { request -> PrioritizedNotification? in
        guard let fireDate = extractFireDate(from: request) else { return nil }
        let type = determineType(from: request.identifier)
        return PrioritizedNotification(
            identifier: request.identifier,
            fireDate: fireDate,
            type: type
        )
    }

    let sorted = prioritized.sorted { lhs, rhs in
        if lhs.fireDate != rhs.fireDate {
            return lhs.fireDate < rhs.fireDate
        }
        return lhs.type.priority < rhs.type.priority
    }

    let toRemove = sorted.dropFirst(maxNotifications).map(\.identifier)

    if !toRemove.isEmpty {
        center.removePendingNotificationRequests(withIdentifiers: Array(toRemove))
    }
}

The supporting struct wraps each notification with its priority metadata:

swift
private struct PrioritizedNotification {
    let identifier: String
    let fireDate: Date
    let type: NotificationType
}

Visual Example: 66 Notifications Sorted

To make the algorithm concrete, imagine 66 pending notifications. After the two-tier sort, the list looks like this:

#   Fire Date    Type          Priority   Action
─── ──────────── ───────────── ────────── ──────
 1  Mar 4 08:00  habit         0          KEEP
 2  Mar 4 08:00  entry         0          KEEP
 3  Mar 4 09:00  day1          1          KEEP
 4  Mar 4 10:00  habit         0          KEEP
 5  Mar 4 10:00  depletion     2          KEEP
 ·  ·            ·             ·          KEEP
 ·  ·            ·             ·          KEEP
62  Mar 10 18:00 habit         0          KEEP
63  Mar 10 20:00 depletion     2          KEEP
64  Mar 10 21:00 inactivity    3          KEEP ← budget limit
───────────────────────────────────────────────
65  Mar 11 08:00 inactivity    3          EVICT
66  Mar 11 09:00 inactivity    3          EVICT

Rows 1–64 survive. Rows 65–66 get removed. Notice how at position 5, the depletion notification at Mar 4 10:00 is kept because it's within budget — the sort only breaks ties, it doesn't reshuffle earlier entries. The inactivity notifications at the bottom are evicted because they're both the farthest out and the lowest priority.

Important

Always call enforceLimit() after batch scheduling operations, not after each individual notification. Calling it per-notification wastes cycles and creates unnecessary churn in the notification center.

Thread Safety with Swift Actors

Multiple schedulers may trigger concurrently — the habit scheduler, the onboarding scheduler, and the depletion scheduler might all fire during app launch. Without isolation, two concurrent calls to enforceLimit() could read the same pending list and issue conflicting removals.

Swift actor solves this. The compiler guarantees that only one task accesses the actor's mutable state at a time:

swift
protocol NotificationBudgetManaging: Actor {
    func enforceLimit() async
}

actor NotificationBudgetManager: NotificationBudgetManaging {
    static let shared = NotificationBudgetManager()

    private let center: any NotificationCenterProtocol
    private let maxNotifications: Int

    init(
        center: any NotificationCenterProtocol = UNUserNotificationCenter.current(),
        maxNotifications: Int = 64
    ) {
        self.center = center
        self.maxNotifications = maxNotifications
    }

    // enforceLimit(), determineType(), extractFireDate() live here
}

Why actor over alternatives:

ApproachTradeoff
actorCompiler-enforced isolation, async/await native, zero boilerplate
Serial DispatchQueueManual discipline, easy to forget .sync/.async, no compiler help
NSLock / os_unfair_lockLowest-level, error-prone, blocks threads instead of suspending

The actor approach is the only one where the compiler catches concurrency bugs at build time. If you try to access the actor's internals without await, the code won't compile.

Fire Date Extraction

Each notification's fire date determines its sort position. We extract it from the trigger:

swift
private func extractFireDate(from request: UNNotificationRequest) -> Date? {
    guard let trigger = request.trigger as? UNCalendarNotificationTrigger else {
        return nil
    }
    if let nextDate = trigger.nextTriggerDate() {
        return nextDate
    }
    return Calendar.current.date(from: trigger.dateComponents)
}

nextTriggerDate() is preferred over manually constructing a Date from dateComponents because it accounts for repeating triggers, time zone transitions, and calendar edge cases. The fallback to Calendar.current.date(from:) handles the rare case where nextTriggerDate() returns nil — for example, if the trigger date has already passed.

Notifications without a UNCalendarNotificationTrigger (e.g., UNTimeIntervalNotificationTrigger) return nil and are excluded from budget management via compactMap. If your app uses interval-based triggers, you'd extend this method accordingly.

Making It Testable

UNUserNotificationCenter is a singleton with no public initializer — you can't create instances for testing. The solution is a protocol wrapper:

swift
protocol NotificationCenterProtocol: Sendable {
    func pendingNotificationRequests() async -> [UNNotificationRequest]
    func removePendingNotificationRequests(withIdentifiers identifiers: [String])
}

extension UNUserNotificationCenter: NotificationCenterProtocol {}

The real UNUserNotificationCenter conforms for free — both methods already exist with matching signatures. For tests, we build a mock:

swift
final class MockNotificationCenter: NotificationCenterProtocol, @unchecked Sendable {
    private var pendingRequests: [UNNotificationRequest] = []
    private(set) var removedIdentifiers: [String] = []

    func setPendingRequests(_ requests: [UNNotificationRequest]) {
        pendingRequests = requests
    }

    func pendingNotificationRequests() async -> [UNNotificationRequest] {
        pendingRequests
    }

    func removePendingNotificationRequests(withIdentifiers identifiers: [String]) {
        removedIdentifiers.append(contentsOf: identifiers)
    }
}

Now we can test the eviction algorithm with full control over the input. Two representative tests using Swift Testing:

swift
@Test func overBudget_removesLowestPriorityNotifications() async {
    let center = MockNotificationCenter()
    let now = Date()

    // 5 notifications, budget of 3, all same fire date
    let requests = [
        makeRequest(id: "com.myapp.inactivityReminder.0", fireDate: now),
        makeRequest(id: "com.myapp.habitReminder.0", fireDate: now),
        makeRequest(id: "com.myapp.depletionReminder.0", fireDate: now),
        makeRequest(id: "com.myapp.inactivityReminder.1", fireDate: now),
        makeRequest(id: "com.myapp.habitReminder.1", fireDate: now),
    ]
    center.setPendingRequests(requests)

    let manager = NotificationBudgetManager(center: center, maxNotifications: 3)
    await manager.enforceLimit()

    // Sorted by priority: habit.0, habit.1, depletion.0, inactivity.0, inactivity.1
    // Keep first 3 → remove both inactivity notifications
    #expect(center.removedIdentifiers.count == 2)
    #expect(center.removedIdentifiers.contains("com.myapp.inactivityReminder.0"))
    #expect(center.removedIdentifiers.contains("com.myapp.inactivityReminder.1"))
}

@Test func underBudget_noRemovals() async {
    let center = MockNotificationCenter()
    let now = Date()

    let requests = (0..<30).map { i in
        makeRequest(
            id: "com.myapp.habitReminder.\(i)",
            fireDate: now.addingTimeInterval(Double(i * 60))
        )
    }
    center.setPendingRequests(requests)

    let manager = NotificationBudgetManager(center: center, maxNotifications: 64)
    await manager.enforceLimit()

    #expect(center.removedIdentifiers.isEmpty)
}

The makeRequest helper creates a UNNotificationRequest with a UNCalendarNotificationTrigger for a given date — the same pattern the real schedulers use.

Integration: When to Call enforceLimit()

The budget manager is the final step after any scheduling operation:

swift
// In your app's startup sequence
func syncAllNotifications() async {
    await habitScheduler.scheduleAll()
    await depletionScheduler.scheduleAll()
    await onboardingScheduler.scheduleDay1Reminders()

    // Always enforce after all schedulers finish
    await NotificationBudgetManager.shared.enforceLimit()
}

Call it:

  • After batch scheduling — app launch, settings changes, bulk reschedules
  • After each scheduler completes — when a single scheduler runs independently (e.g., after a user enables a new habit)
  • NOT after every single .add() call — this would query and sort the pending list repeatedly for no benefit

The pattern for individual schedulers:

swift
func scheduleHabitReminders(for habit: Habit) async {
    // Schedule 7 days of reminders
    for day in 0..<7 {
        let request = buildRequest(for: habit, dayOffset: day)
        try? await UNUserNotificationCenter.current().add(request)
    }
    // Enforce limit once, after all 7 are added
    await NotificationBudgetManager.shared.enforceLimit()
}

TIP

If multiple schedulers fire in rapid succession, the actor serialization already handles this — each enforceLimit() call waits its turn. No explicit debouncing needed.

Conclusion

The 64 pending notification limit is real, silent, and affects every iOS app that schedules more than a handful of local notifications. Without a budget manager, your app will silently lose notifications — and users will blame your app, not iOS.

The key design decisions in this implementation:

  1. Priority hierarchy — Encode which notification types matter most. User-configured reminders survive; ambient nudges get evicted.
  2. Deterministic two-tier sort — Fire date first, priority second. The result is predictable and testable.
  3. Swift actor isolation — The compiler enforces thread safety. No locks, no queues, no runtime surprises.
  4. Protocol-based testability — Wrap UNUserNotificationCenter behind a protocol. Test the algorithm with mock data, not real notification APIs.

Where to go from here:

  • Analytics: Track eviction counts per type to understand if your notification budget allocation needs rebalancing
  • Dynamic budgets: Allocate a percentage of the 64 slots per type (e.g., 40 for habits, 10 for depletion, 14 for everything else)
  • Adaptive priority: Boost priority for notification types with higher tap rates based on user engagement data

For more on iOS notification scheduling, see Apple's UserNotifications framework documentation and the WWDC 2017 session on notification best practices.