Gestión del presupuesto de notificaciones en iOS: Evicción por prioridad para el límite de 64 notificaciones
Introducción
Tu app programa notificaciones para hábitos, recordatorios, rachas y nudges de inactividad. Todo funciona en desarrollo. Luego en producción, los usuarios empiezan a reportar: "Dejé de recibir mis recordatorios." Sin crash, sin error — las notificaciones simplemente desaparecen.
La causa: iOS descarta silenciosamente las notificaciones locales que excedan las 64 solicitudes pendientes. Sin callback de error, sin método de delegado, sin advertencia en consola. Tu app no tiene forma de saber que las notificaciones fueron descartadas a menos que consultes explícitamente UNUserNotificationCenter y cuentes lo que sobrevivió.
El enfoque ingenuo — programar todo y esperar lo mejor — funciona hasta que tu app crece un segundo tipo de notificación. El enfoque intencional es un gestor de presupuesto: un sistema que conoce cada tipo de notificación, asigna prioridades y elige determinísticamente cuáles notificaciones sobreviven cuando el presupuesto se desborda.
Este artículo recorre una implementación probada en producción usando actors de Swift y evicción basada en prioridades. Al final, tendrás un NotificationBudgetManager completo y testeable que puedes integrar en cualquier proyecto iOS.
El techo de 64 notificaciones
iOS impone un límite estricto de 64 notificaciones pendientes por app. Esto aplica a todas las notificaciones locales programadas vía UNUserNotificationCenter — el framework moderno introducido en iOS 10.
La guía archivada de Apple Local and Push Notification Programming Guide establece:
"Una app solo puede tener un número limitado de notificaciones programadas; el sistema conserva las 64 notificaciones más próximas a dispararse (contando las notificaciones reprogramadas automáticamente como una sola) y descarta el resto."
La documentación moderna de UNUserNotificationCenter no reafirma este límite de manera prominente, lo que ha causado confusión generalizada. Pero el límite sigue vigente.
Qué pasa cuando lo excedes:
- El sistema conserva las 64 más próximas a dispararse y descarta silenciosamente el resto
- No hay error, ni falla en el completion handler, ni callback del delegado
- Las notificaciones repetitivas cuentan como una sola notificación para el límite
- Las notificaciones push remotas no se ven afectadas — solo cuentan las notificaciones locales/programadas
Las apps con múltiples tipos de notificación alcanzan este límite rápidamente. Considera: 7 recordatorios diarios de hábitos × 7 días por adelantado = 49. Agrega notificaciones semanales de racha, avisos de depleción y nudges de onboarding — ya pasaste de 64 sin darte cuenta.
TIP
Puedes verificar tu conteo actual llamando UNUserNotificationCenter.current().getPendingNotificationRequests() y revisando la longitud del array. Haz esto durante el desarrollo para detectar desbordamientos del presupuesto temprano.
Diseño del sistema de prioridades
No todas las notificaciones son iguales. Un recordatorio que el usuario configuró explícitamente importa más que un nudge de re-engagement generado por el sistema. Nuestro sistema de prioridades codifica esta jerarquía:
| Prioridad | Tipo | Justificación |
|---|---|---|
| 0 (más alta) | Recordatorios configurados por el usuario | El usuario los configuró explícitamente — eliminarlos rompe la confianza |
| 1 | Críticos para retención (onboarding Día 1) | Sensibles al tiempo y directamente ligados a métricas de retención |
| 2 | Engagement (avisos de depleción) | Generados por el sistema pero accionables |
| 3 (más baja) | Re-engagement (nudges de inactividad) | Ambientales — el usuario no notará si se elimina uno |
El principio: intención del usuario > generadas por el sistema, y sensibles al tiempo > ambientales.
Codificamos esto como un enum de Swift con una propiedad computada priority separada. Esto permite que múltiples raw values mapeen al mismo nivel de prioridad (por ejemplo, recordatorios de hábitos y de entradas comparten prioridad 0):
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 // Configuradas por usuario
case .day1: 1 // Críticas para retención
case .depletion: 2 // Engagement
case .inactivity: 3 // Re-engagement
case .unknown: 99 // No reconocidas → evictar primero
}
}
}Para clasificar una notificación en su tipo, usamos prefijos de identificador. Cada scheduler en la app usa un prefijo único al crear identificadores de notificación:
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
}Importante
Cualquier notificación con un prefijo no reconocido recibe unknown (prioridad 99) — será evictada primero. Esto es intencional: si se agrega un nuevo tipo de notificación sin actualizar el gestor de presupuesto, degrada graciosamente en vez de evictar notificaciones importantes.
El algoritmo de evicción
El algoritmo central usa un ordenamiento determinístico de dos niveles:
- Primario: Fecha de disparo ascendente — conservar las notificaciones más próximas
- Secundario: Prioridad de tipo ascendente — entre notificaciones con la misma fecha de disparo, conservar los tipos de mayor prioridad
Esto significa que el gestor siempre preserva las notificaciones más cercanas y más importantes, y evicta las más lejanas y menos importantes.
Aquí está el método enforceLimit() completo:
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))
}
}La estructura de soporte envuelve cada notificación con sus metadatos de prioridad:
private struct PrioritizedNotification {
let identifier: String
let fireDate: Date
let type: NotificationType
}Ejemplo visual: 66 notificaciones ordenadas
Para hacer el algoritmo concreto, imagina 66 notificaciones pendientes. Después del ordenamiento de dos niveles, la lista se ve así:
# Fecha disparo Tipo Prioridad Acción
─── ────────────── ───────────── ────────── ──────
1 Mar 4 08:00 habit 0 CONSERVAR
2 Mar 4 08:00 entry 0 CONSERVAR
3 Mar 4 09:00 day1 1 CONSERVAR
4 Mar 4 10:00 habit 0 CONSERVAR
5 Mar 4 10:00 depletion 2 CONSERVAR
· · · · CONSERVAR
· · · · CONSERVAR
62 Mar 10 18:00 habit 0 CONSERVAR
63 Mar 10 20:00 depletion 2 CONSERVAR
64 Mar 10 21:00 inactivity 3 CONSERVAR ← límite
───────────────────────────────────────────────────────
65 Mar 11 08:00 inactivity 3 EVICTAR
66 Mar 11 09:00 inactivity 3 EVICTARLas filas 1–64 sobreviven. Las filas 65–66 se eliminan. Observa cómo en la posición 5, la notificación de depletion en Mar 4 10:00 se conserva porque está dentro del presupuesto — el ordenamiento solo desempata, no reorganiza las entradas anteriores. Las notificaciones de inactividad al final son evictadas porque son tanto las más lejanas como las de menor prioridad.
Importante
Siempre llama enforceLimit() después de operaciones de programación en lote, no después de cada notificación individual. Llamarlo por notificación desperdicia ciclos y crea churn innecesario en el centro de notificaciones.
Thread safety con actors de Swift
Múltiples schedulers pueden dispararse concurrentemente — el scheduler de hábitos, el de onboarding y el de depleción podrían todos ejecutarse durante el lanzamiento de la app. Sin aislamiento, dos llamadas concurrentes a enforceLimit() podrían leer la misma lista de pendientes y emitir eliminaciones conflictivas.
El actor de Swift resuelve esto. El compilador garantiza que solo una tarea accede al estado mutable del actor a la vez:
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() van aquí
}Por qué actor sobre las alternativas:
| Enfoque | Compromiso |
|---|---|
actor | Aislamiento enforced por compilador, nativo con async/await, cero boilerplate |
DispatchQueue serial | Disciplina manual, fácil olvidar .sync/.async, sin ayuda del compilador |
NSLock / os_unfair_lock | Nivel más bajo, propenso a errores, bloquea threads en vez de suspender |
El enfoque actor es el único donde el compilador detecta bugs de concurrencia en tiempo de compilación. Si intentas acceder a los internos del actor sin await, el código no compilará.
Extracción de la fecha de disparo
La fecha de disparo de cada notificación determina su posición en el ordenamiento. La extraemos del trigger:
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() es preferido sobre construir manualmente un Date a partir de dateComponents porque contempla triggers repetitivos, transiciones de zona horaria y casos borde del calendario. El fallback a Calendar.current.date(from:) maneja el caso raro donde nextTriggerDate() retorna nil — por ejemplo, si la fecha del trigger ya pasó.
Las notificaciones sin UNCalendarNotificationTrigger (por ejemplo, UNTimeIntervalNotificationTrigger) retornan nil y se excluyen de la gestión de presupuesto vía compactMap. Si tu app usa triggers basados en intervalos, extenderías este método de manera acorde.
Haciéndolo testeable
UNUserNotificationCenter es un singleton sin inicializador público — no puedes crear instancias para testing. La solución es un wrapper de protocolo:
protocol NotificationCenterProtocol: Sendable {
func pendingNotificationRequests() async -> [UNNotificationRequest]
func removePendingNotificationRequests(withIdentifiers identifiers: [String])
}
extension UNUserNotificationCenter: NotificationCenterProtocol {}El UNUserNotificationCenter real conforma gratis — ambos métodos ya existen con firmas coincidentes. Para tests, construimos un mock:
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)
}
}Ahora podemos testear el algoritmo de evicción con control total sobre el input. Dos tests representativos usando Swift Testing:
@Test func sobrePresupuesto_eliminaNotificacionesDeMenorPrioridad() async {
let center = MockNotificationCenter()
let now = Date()
// 5 notificaciones, presupuesto de 3, todas misma fecha de disparo
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()
// Ordenado por prioridad: habit.0, habit.1, depletion.0, inactivity.0, inactivity.1
// Conservar primeros 3 → eliminar ambas notificaciones de inactividad
#expect(center.removedIdentifiers.count == 2)
#expect(center.removedIdentifiers.contains("com.myapp.inactivityReminder.0"))
#expect(center.removedIdentifiers.contains("com.myapp.inactivityReminder.1"))
}
@Test func bajoPresupuesto_sinEliminaciones() 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)
}El helper makeRequest crea un UNNotificationRequest con un UNCalendarNotificationTrigger para una fecha dada — el mismo patrón que usan los schedulers reales.
Integración: Cuándo llamar enforceLimit()
El gestor de presupuesto es el paso final después de cualquier operación de programación:
// En la secuencia de arranque de tu app
func syncAllNotifications() async {
await habitScheduler.scheduleAll()
await depletionScheduler.scheduleAll()
await onboardingScheduler.scheduleDay1Reminders()
// Siempre enforce después de que todos los schedulers terminen
await NotificationBudgetManager.shared.enforceLimit()
}Llámalo:
- Después de programación en lote — lanzamiento de la app, cambios de configuración, reprogramaciones masivas
- Después de que cada scheduler completa — cuando un scheduler individual corre independientemente (por ejemplo, después de que un usuario activa un nuevo hábito)
- NO después de cada llamada
.add()individual — esto consultaría y ordenaría la lista de pendientes repetidamente sin beneficio
El patrón para schedulers individuales:
func scheduleHabitReminders(for habit: Habit) async {
// Programar 7 días de recordatorios
for day in 0..<7 {
let request = buildRequest(for: habit, dayOffset: day)
try? await UNUserNotificationCenter.current().add(request)
}
// Enforce el límite una vez, después de agregar los 7
await NotificationBudgetManager.shared.enforceLimit()
}TIP
Si múltiples schedulers se disparan en rápida sucesión, la serialización del actor ya maneja esto — cada llamada a enforceLimit() espera su turno. No se necesita debouncing explícito.
Conclusión
El límite de 64 notificaciones pendientes es real, silencioso y afecta a toda app iOS que programa más de un puñado de notificaciones locales. Sin un gestor de presupuesto, tu app perderá notificaciones silenciosamente — y los usuarios culparán a tu app, no a iOS.
Las decisiones clave de diseño en esta implementación:
- Jerarquía de prioridades — Codifica cuáles tipos de notificación importan más. Los recordatorios configurados por el usuario sobreviven; los nudges ambientales son evictados.
- Ordenamiento determinístico de dos niveles — Fecha de disparo primero, prioridad después. El resultado es predecible y testeable.
- Aislamiento con actor de Swift — El compilador impone thread safety. Sin locks, sin queues, sin sorpresas en tiempo de ejecución.
- Testeabilidad basada en protocolos — Envuelve
UNUserNotificationCenterdetrás de un protocolo. Testea el algoritmo con datos mock, no con las APIs reales de notificaciones.
Para seguir explorando:
- Analytics: Rastrea conteos de evicción por tipo para entender si la asignación de presupuesto de notificaciones necesita rebalanceo
- Presupuestos dinámicos: Asigna un porcentaje de los 64 slots por tipo (por ejemplo, 40 para hábitos, 10 para depleción, 14 para todo lo demás)
- Prioridad adaptativa: Aumenta la prioridad para tipos de notificación con mayores tasas de tap basadas en datos de engagement del usuario
Para más sobre programación de notificaciones en iOS, consulta la documentación del framework UserNotifications de Apple y la sesión WWDC 2017 sobre mejores prácticas de notificaciones.
