Skip to content

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:

PrioridadTipoJustificación
0 (más alta)Recordatorios configurados por el usuarioEl usuario los configuró explícitamente — eliminarlos rompe la confianza
1Críticos para retención (onboarding Día 1)Sensibles al tiempo y directamente ligados a métricas de retención
2Engagement (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):

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    // 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:

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
}

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:

  1. Primario: Fecha de disparo ascendente — conservar las notificaciones más próximas
  2. 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:

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))
    }
}

La estructura de soporte envuelve cada notificación con sus metadatos de prioridad:

swift
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          EVICTAR

Las 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:

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() van aquí
}

Por qué actor sobre las alternativas:

EnfoqueCompromiso
actorAislamiento enforced por compilador, nativo con async/await, cero boilerplate
DispatchQueue serialDisciplina manual, fácil olvidar .sync/.async, sin ayuda del compilador
NSLock / os_unfair_lockNivel 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:

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() 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:

swift
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:

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)
    }
}

Ahora podemos testear el algoritmo de evicción con control total sobre el input. Dos tests representativos usando Swift Testing:

swift
@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:

swift
// 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:

swift
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:

  1. 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.
  2. Ordenamiento determinístico de dos niveles — Fecha de disparo primero, prioridad después. El resultado es predecible y testeable.
  3. Aislamiento con actor de Swift — El compilador impone thread safety. Sin locks, sin queues, sin sorpresas en tiempo de ejecución.
  4. Testeabilidad basada en protocolos — Envuelve UNUserNotificationCenter detrá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.