Table of Contents
- The problem
- Design decisions and alternatives
- Reach the system only through a protocol
- A queue in front of the 64 slots
- Calls run one at a time, not just on one actor
- Policy applies to the fire date
- Validation with a typed error
- Separate app and server libraries
- A bug the CI found
- Usage
- Limits
The problem
UserNotifications has a few behaviours that fail without telling you. iOS keeps only the 64 pending local notifications of an app that fire soonest and discards the rest without an error. An app that schedules a one-time reminder for each day of the next three months hands the system more than 64 and silently loses the later ones. Some invalid values do not throw either: a repeating UNTimeIntervalNotificationTrigger under 60 seconds, or a zero, negative or NaN interval, raises an Objective-C exception, which ends the process instead of reaching a catch. And the UNUserNotificationCenter delegate is a set of callbacks that does not fit Swift 6 concurrency or unit tests, because the real center needs a host app.
NotificationKit, the Swift package in the iOS-Notification-Framework repository, is a layer over UserNotifications that addresses these four points. Version 2.0.0 was released on 15 October 2026. It has two libraries: NotificationKit for apps (iOS 17, macOS 14, watchOS 10, visionOS 1) and NotificationKitAPNs for servers and command-line tools that send pushes. Neither has dependencies outside Apple's SDK.
The 2.0 rewrite has a history behind it. The repository opened in August 2025 as a prototype, 1.0.0 was released in February 2026, and small types were added in June 2026. The commit that starts the rewrite, 7aa405b, removes 6,173 lines and lists why: every type reached for UNUserNotificationCenter.current(), UserDefaults.standard and Date() directly, so nothing could run in a unit test; and "the tests only read back properties that the builders had set". The rest of this article is about the design that replaced it.
Design decisions and alternatives
Reach the system only through a protocol
NotificationCenterClient lists what the package needs from the system: permission, settings, adding and removing requests, categories and the badge. UserNotificationCenterClient maps it to UNUserNotificationCenter; InMemoryNotificationCenter stands in for it in tests and SwiftUI previews. The in-memory center copies the system where scheduling depends on it: it keeps only the 64 soonest pending notifications, records the ones it dropped, delivers immediate notifications at once and rejects the same invalid values.
The alternatives were mocking UNUserNotificationCenter itself or leaving scheduling untested. A test double can drift from the real center; the repository narrows that gap two ways: SystemMappingTests check how values become UNNotificationRequests, and three UI tests in the NotificationLab example app run against the real center on a simulator.
A queue in front of the 64 slots
NotificationScheduler is an actor that keeps every notification it schedules in a saved state. It gives the system the soonest ones that fit next to notifications added by other code, and queues the rest. refresh() forgets delivered notifications, moves queued ones into free slots and moves a later notification back to the queue when an earlier one needs its slot.
The decision itself lives in two pure types. SlotPlanner decides which notifications the system holds, and FireDateResolver applies quiet hours and the frequency cap. Both are tested without a device. When the plan is applied, removals happen before additions, so the system never holds more than 64 and never drops one on its own.
The state goes to a NotificationStore. FileNotificationStore writes JSON atomically in Application Support and refuses files over 8 MB; InMemoryNotificationStore is for tests.
Calls run one at a time, not just on one actor
An actor alone is not enough here. Actors are reentrant at every await, and scheduling awaits the center several times, so two concurrent schedule calls could interleave and hand out the same slot. A private serialized helper chains each operation behind the one started before it. A test schedules 100 notifications concurrently and checks the result. The cost is that a slow call makes the next one wait.
Policy applies to the fire date
When a local notification fires, the app's code does not run, so quiet hours cannot be decided at delivery time. FireDateResolver decides when the notification is scheduled, but it looks at the date the notification will fire, not the current time. A one-time reminder set at 21:00 for 23:00 inside a 22:00–07:30 window moves to 07:30. Repeating notifications keep the time the user chose, and time-sensitive and critical notifications pass quiet hours by default.
FrequencyCap allows at most N notifications in any window of a given length, counting both earlier and later notifications. Only the requested date and dates one interval after an existing notification can be the answer, so the search checks those candidates with binary search and always ends. A parameterised test compares it with a brute-force search over 40 random schedules.
Validation with a typed error
LocalNotification.validate() and NotificationTrigger.validate() throw a NotificationValidationError for what the system crashes on or ignores: short or non-finite intervals, out-of-range date components, past one-time dates, relevance scores and volumes outside 0...1, invalid regions. Custom data is a JSONValue instead of [AnyHashable: Any], so it is Sendable and Codable and can be saved with the queue; conversion stops at a nesting depth of 32 and 10,000 values. Both centers call the same validate(), so the test double rejects what the system would.
Separate app and server libraries
NotificationKitAPNs holds the APNs client: HTTP/2 through URLSession, ES256 provider tokens signed with CryptoKit and reused for 50 minutes, one retry on ExpiredProviderToken. Keeping it in its own library keeps signing code out of apps, while PushPayload and DeviceToken are shared so the server encodes the same type the app reads. Using only Apple frameworks avoids a dependency tree, at the cost of Linux support, which the README lists on the roadmap.
A bug the CI found
The rewrite was opened as PR #31, and its first run was red. One of the failures is a good lesson about toolchains.
MediaAttachmentService, the base class for a Notification Service Extension, started its media download in a Task whose capture list held the original notification content, the downloader and the delivery object. It compiled locally with Swift 6.4. On the CI runner, Swift 6.3 (Xcode 26.6) stopped with "pattern that the region-based isolation checker does not understand".
The fix in 93efabc moves the inputs into one private MediaJob value, so the task captures a single Sendable value. The conformance is @unchecked, and the comment states why that holds: UNNotificationContent is only read and copied with mutableCopy, never mutated. Behaviour did not change: a locked OneTimeDelivery still calls the content handler exactly once, either after the download or when the system calls serviceExtensionTimeWillExpire.
The README now says it plainly: CI builds with the default Xcode of the macos-26 runner, while the package is developed with Xcode 27.
The same PR fixed UI test launch timeouts on the hosted runner (90887f3) and a link check that failed on 331 addresses existing only after the merge (5149dfb). None of this would have surfaced before: commit d715e25 notes that the old one ran the tests with continue-on-error, so failures never turned it red.
Usage
The quick start from the README is a complete ContentView.swift for a new iOS app. It is copied from ReadmeSamples.swift, which both the example app and swift test compile:
1import NotificationKit2import SwiftUI3 4let scheduler = NotificationScheduler() // one for the whole app5 6struct ContentView: View {7 var body: some View {8 Button("Remind me in 10 seconds") {9 Task {10 do {11 try await scheduler.requestAuthorization()12 try await scheduler.schedule(LocalNotification(13 id: "water",14 title: "Time for a glass of water",15 body: "You asked for this reminder 10 seconds ago.",16 trigger: .after(seconds: 10)17 ))18 } catch {19 print("Could not schedule the reminder:", error)20 }21 }22 }23 }24}Quiet hours and the frequency cap are a policy passed to the scheduler:
1func makeScheduler() throws -> NotificationScheduler {2 NotificationScheduler(policy: SchedulingPolicy(3 // One-time notifications that would fire between 22:00 and 07:30 arrive at 07:30.4 quietHours: try QuietHours(from: "22:00", to: "07:30"),5 // At most three notifications in any hour; later ones move back.6 frequencyCap: try FrequencyCap(limit: 3, per: 3600)7 ))8}Because the in-memory center has the system's limit, the 64-slot behaviour can be checked in a plain unit test:
1func scheduledCount() async throws -> Int {2 let center = InMemoryNotificationCenter(authorizationStatus: .authorized)3 let scheduler = NotificationScheduler(center: center, store: InMemoryNotificationStore())4 let reminders = (1...70).map { LocalNotification(id: "r\($0)", title: "Reminder \($0)", trigger: .after(minutes: Double($0))) }5 try await scheduler.schedule(reminders)6 return await center.pending.count // 64: the other 6 wait in the scheduler's queue7}At tag v2.0.0, swift test runs 109 Swift Testing tests: 95 in 15 suites for NotificationKit and 14 in 4 suites for NotificationKitAPNs.
Limits
- The queue moves only while the app runs. The DocC article on scheduling says to call
refresh()at launch and when the app returns to the foreground. If the user does not open the app, queued notifications do not reach the system; one whose date passes is dropped and reported as expired, or delivered late ifoverdueBehavioris.deliverNow. - Delivery is inferred. A one-time notification the system no longer holds and whose date has passed is counted as delivered.
- Policy changes are not retroactive.
updatePolicy(_:)documents that notifications already scheduled keep their dates. - Event streams are bounded.
NotificationEventHandlerkeeps the newest 32 unread events per stream by default, so events nobody reads are dropped. - Platforms differ. Location triggers work on iOS and watchOS only; watchOS has no attachments and plays the default sound for named sounds.
NotificationKitAPNsneeds CryptoKit and does not build on Linux. - Tests stop at the network. The APNs tests run against an
APNsTransportstub, not Apple's servers. The UI tests cover a banner tap, the queue past 64 and a simulator push.
The README roadmap lists a Swift Package Index listing, broadcast push channels for Live Activities and a Linux build of NotificationKitAPNs on swift-crypto.
Source code: github.com/muhittincamdali/iOS-Notification-Framework
Tags
iOS Development News
Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.
Your subscription starts when you open the link in the confirmation email and press “Confirm my subscription”. The newsletter keeps open/click statistics; you can unsubscribe anytime with one click. Privacy

