Implementing a local notification scheduler in Todoist iOS

Reliably schedule large amounts of local notifications on iOS

By Nolan Warner

iOS restricts applications to a maximum of 20 location-based and 64 total locally scheduled notifications at any time. This restriction posed a substantial challenge for developing Todoist for iOS, as users often have more reminders than those allowed by the system.

The situation became particularly problematic with snoozed reminders, which allows Todoist users to snooze a reminder notification for a short duration When a user snoozes a notification, it isn’t persisted to disk, meaning that if the system exceeds the local notification limits, those reminders could be lost forever. This can lead to missed tasks and frustrated users, undermining our app’s core purpose of helping users stay organized.

Snooze reminder feature
Options to snooze a Task reminder.

Locally Scheduled Notifications

To address this challenge, we designed and implemented a new component: the LocalNotificationScheduler. This component intelligently manages the scheduling of local notifications while adhering to iOS constraints. Here’s a breakdown of our approach:

Dynamic Scheduling

The LocalNotificationScheduler accesses both currently scheduled and stored notifications. When we attempt to schedule more notifications than allowed, the scheduler evaluates which notifications should remain scheduled in the operating system, and which can be stored for later. It prioritizes time-based notifications that are imminent and location-based notifications most relevant to the user’s current location. This ensures that the most likely notifications to fire next are prioritized and remain accessible.

Storing Overflow Notifications

Notifications that cannot be scheduled immediately due to limits are stored on disk. The component allows the client to choose its preferred storage mechanism and in our application, we utilize UserDefaults to store UNNotificationRequests. This approach ensures that even if notifications cannot be scheduled temporarily, they are not lost. Stored reminders will be rescheduled later when slots become available, allowing for a seamless user experience.

Public API

A key feature of the LocalNotificationScheduler is its public API, which simplifies interactions for the client app. Upon initialization, the component is provided with save: ([UNNotificationRequest]) throws -> Void and load: () throws -> [UNNotificationRequest]]closures to delegate the responsibilities of persisting and retrieving overflowed notification requests. For updating scheduled notifications, the client calls an update method: func update(updates: (inout [UNNotificationRequest]) -> Void) async throws and receives a list of all notifications, modifies it based on the user’s current list of reminders, and returns the updated list to the component for scheduling.

Since the component is internally constructing the list of notifications to modify with access to the scheduled and stored notifications, the client doesn’t need to know where each notification is coming from or its current scheduled status. No details about the notification limits or scheduling prioritization are leaked to the client. This abstraction promotes cleaner code and enhances maintainability.

func reschedule(adding newRequests: [UNNotificationRequest]) async throws {
    try await localNotificationScheduler.update(updates: { requests in
        // Snoozed requests will be removed if they are no longer valid
        let snoozedRequestsToRemove = pendingSnoozedNotificationRequestsToRemove(requests: requests)


        // Remove existing reminder requests while preserving other requests (such as habit notification requests)
        let filteredRequests = requests.filter { !$0.isReminder && !snoozedRequestsToRemove.contains($0) }


        // Fetch current notification requests based on the valid reminders in storage
        let reminderRequests = uniqueReminderRequests()


        // Combine all existing valid requests and new requests to be scheduled by the scheduler
        requests = newRequests + filteredRequests + reminderRequests
    })
}

Avoiding Races During Rescheduling

The primary challenge in implementing this solution was guaranteeing that rescheduling blocks are executed in sequence, rather than in parallel, to ensure that there are no data races involved from the time the list of current reminders is read to the time they are scheduled and overflow notifications are persisted. Notification rescheduling happens whenever a change to relevant data is detected, which can be triggered by user input or by indeterministic updates from syncing with the backend causing multiple reschedule calls simultaneously. Since Swift’s async/await does not provide an out of the box solution for executing Tasks in sequence, we solved this issue by implementing a TaskQueue object which allows us to enqueue rescheduling blocks as Tasks, then guarantees that each block will be completed before the next one can begin.

Conclusion

By implementing the LocalNotificationScheduler, we have elegantly worked around iOS’s limitations while enhancing the reliability of Todoist. This solution enables users to schedule reminders seamlessly as we manage the complexity of notification management in the background.

Our goal is to empower users to stay organized, and reliable notifications are essential to achieving this. As we continue to refine Todoist, we’re excited about how this component will help maintain a robust reminder system.