Skip to content

Gerenciando o orçamento de notificações no iOS: Evicção por prioridade para o limite de 64 notificações

Introdução

Seu app agenda notificações para hábitos, lembretes, sequências e nudges de inatividade. Tudo funciona no desenvolvimento. Então em produção, os usuários começam a reportar: "Parei de receber meus lembretes." Sem crash, sem erro — as notificações simplesmente desaparecem.

A causa: o iOS descarta silenciosamente notificações locais que excedam 64 solicitações pendentes. Sem callback de erro, sem método de delegate, sem aviso no console. Seu app não tem como saber que notificações foram descartadas a menos que você consulte explicitamente o UNUserNotificationCenter e conte o que sobreviveu.

A abordagem ingênua — agendar tudo e esperar pelo melhor — funciona até seu app ter um segundo tipo de notificação. A abordagem intencional é um gerenciador de orçamento: um sistema que conhece cada tipo de notificação, atribui prioridades e escolhe deterministicamente quais notificações sobrevivem quando o orçamento estoura.

Este artigo percorre uma implementação testada em produção usando actors do Swift e evicção baseada em prioridades. No final, você terá um NotificationBudgetManager completo e testável que pode integrar em qualquer projeto iOS.

O teto de 64 notificações

O iOS impõe um limite rígido de 64 notificações pendentes por app. Isso se aplica a todas as notificações locais agendadas via UNUserNotificationCenter — o framework moderno introduzido no iOS 10.

O guia arquivado da Apple Local and Push Notification Programming Guide declara:

"Um app pode ter apenas um número limitado de notificações agendadas; o sistema mantém as 64 notificações mais próximas de disparar (contando notificações automaticamente reagendadas como uma única notificação) e descarta o restante."

A documentação moderna do UNUserNotificationCenter não reafirma esse limite de forma proeminente, o que causou confusão generalizada. Mas o limite ainda é aplicado.

O que acontece quando você ultrapassa:

  • O sistema mantém as 64 mais próximas de disparar e descarta silenciosamente o restante
  • Não há erro, nem falha no completion handler, nem callback do delegate
  • Notificações repetitivas contam como uma única notificação para o limite
  • Notificações push remotas não são afetadas — apenas notificações locais/agendadas contam

Apps com múltiplos tipos de notificação atingem esse limite rapidamente. Considere: 7 lembretes diários de hábitos × 7 dias adiante = 49. Adicione notificações semanais de sequência, avisos de depleção e nudges de onboarding — você já passou de 64 sem perceber.

TIP

Você pode verificar sua contagem atual chamando UNUserNotificationCenter.current().getPendingNotificationRequests() e conferindo o tamanho do array. Faça isso durante o desenvolvimento para detectar estouros do orçamento cedo.

Projetando o sistema de prioridades

Nem todas as notificações são iguais. Um lembrete que o usuário configurou explicitamente importa mais que um nudge de re-engajamento gerado pelo sistema. Nosso sistema de prioridades codifica essa hierarquia:

PrioridadeTipoJustificativa
0 (mais alta)Lembretes configurados pelo usuárioO usuário os configurou explicitamente — removê-los quebra a confiança
1Críticos para retenção (onboarding Dia 1)Sensíveis ao tempo e diretamente ligados a métricas de retenção
2Engajamento (avisos de depleção)Gerados pelo sistema mas acionáveis
3 (mais baixa)Re-engajamento (nudges de inatividade)Ambientais — o usuário não notará se um for removido

O princípio: intenção do usuário > geradas pelo sistema, e sensíveis ao tempo > ambientais.

Codificamos isso como um enum do Swift com uma propriedade computada priority separada. Isso permite que múltiplos raw values mapeiem para o mesmo nível de prioridade (por exemplo, lembretes de hábitos e de entradas compartilham prioridade 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 pelo usuário
        case .day1: 1             // Críticas para retenção
        case .depletion: 2        // Engajamento
        case .inactivity: 3       // Re-engajamento
        case .unknown: 99         // Não reconhecidas → remover primeiro
        }
    }
}

Para classificar uma notificação em seu tipo, usamos prefixos de identificador. Cada scheduler no app usa um prefixo único ao criar identificadores de notificação:

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

Qualquer notificação com um prefixo não reconhecido recebe unknown (prioridade 99) — será removida primeiro. Isso é intencional: se um novo tipo de notificação for adicionado sem atualizar o gerenciador de orçamento, ele degrada graciosamente em vez de remover notificações importantes.

O algoritmo de evicção

O algoritmo central usa uma ordenação determinística de dois níveis:

  1. Primário: Data de disparo ascendente — manter as notificações mais próximas
  2. Secundário: Prioridade do tipo ascendente — entre notificações com a mesma data de disparo, manter os tipos de maior prioridade

Isso significa que o gerenciador sempre preserva as notificações mais próximas e mais importantes, e remove as mais distantes e menos importantes.

Aqui está o 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))
    }
}

A struct de suporte envolve cada notificação com seus metadados de prioridade:

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

Exemplo visual: 66 notificações ordenadas

Para tornar o algoritmo concreto, imagine 66 notificações pendentes. Após a ordenação de dois níveis, a lista fica assim:

#   Data disparo   Tipo          Prioridade Ação
─── ────────────── ───────────── ────────── ──────
 1  Mar 4 08:00    habit         0          MANTER
 2  Mar 4 08:00    entry         0          MANTER
 3  Mar 4 09:00    day1          1          MANTER
 4  Mar 4 10:00    habit         0          MANTER
 5  Mar 4 10:00    depletion     2          MANTER
 ·  ·              ·             ·          MANTER
 ·  ·              ·             ·          MANTER
62  Mar 10 18:00   habit         0          MANTER
63  Mar 10 20:00   depletion     2          MANTER
64  Mar 10 21:00   inactivity    3          MANTER ← limite
───────────────────────────────────────────────────────
65  Mar 11 08:00   inactivity    3          REMOVER
66  Mar 11 09:00   inactivity    3          REMOVER

As linhas 1–64 sobrevivem. As linhas 65–66 são removidas. Note como na posição 5, a notificação de depletion em Mar 4 10:00 é mantida porque está dentro do orçamento — a ordenação só desempata, não reorganiza entradas anteriores. As notificações de inatividade no final são removidas porque são tanto as mais distantes quanto as de menor prioridade.

Importante

Sempre chame enforceLimit() após operações de agendamento em lote, não após cada notificação individual. Chamá-lo por notificação desperdiça ciclos e cria churn desnecessário no centro de notificações.

Thread safety com actors do Swift

Múltiplos schedulers podem disparar concorrentemente — o scheduler de hábitos, o de onboarding e o de depleção podem todos executar durante o lançamento do app. Sem isolamento, duas chamadas concorrentes a enforceLimit() poderiam ler a mesma lista de pendentes e emitir remoções conflitantes.

O actor do Swift resolve isso. O compilador garante que apenas uma task acessa o estado mutável do actor por 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() ficam aqui
}

Por que actor sobre as alternativas:

AbordagemCompensação
actorIsolamento enforced pelo compilador, nativo com async/await, zero boilerplate
DispatchQueue serialDisciplina manual, fácil esquecer .sync/.async, sem ajuda do compilador
NSLock / os_unfair_lockNível mais baixo, propenso a erros, bloqueia threads em vez de suspender

A abordagem actor é a única onde o compilador detecta bugs de concorrência em tempo de compilação. Se você tentar acessar os internos do actor sem await, o código não compilará.

Extração da data de disparo

A data de disparo de cada notificação determina sua posição na ordenação. Extraímos do 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() é preferido sobre construir manualmente um Date a partir de dateComponents porque ele contempla triggers repetitivos, transições de fuso horário e casos extremos do calendário. O fallback para Calendar.current.date(from:) lida com o caso raro onde nextTriggerDate() retorna nil — por exemplo, se a data do trigger já passou.

Notificações sem UNCalendarNotificationTrigger (por exemplo, UNTimeIntervalNotificationTrigger) retornam nil e são excluídas do gerenciamento de orçamento via compactMap. Se seu app usa triggers baseados em intervalos, você estenderia esse método de acordo.

Tornando testável

UNUserNotificationCenter é um singleton sem inicializador público — você não pode criar instâncias para testing. A solução é um wrapper de protocolo:

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

extension UNUserNotificationCenter: NotificationCenterProtocol {}

O UNUserNotificationCenter real conforma de graça — ambos os métodos já existem com assinaturas correspondentes. Para testes, construímos um 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)
    }
}

Agora podemos testar o algoritmo de evicção com controle total sobre a entrada. Dois testes representativos usando Swift Testing:

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

    // 5 notificações, orçamento de 3, todas mesma data 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 prioridade: habit.0, habit.1, depletion.0, inactivity.0, inactivity.1
    // Manter primeiros 3 → remover ambas notificações de inatividade
    #expect(center.removedIdentifiers.count == 2)
    #expect(center.removedIdentifiers.contains("com.myapp.inactivityReminder.0"))
    #expect(center.removedIdentifiers.contains("com.myapp.inactivityReminder.1"))
}

@Test func abaixoDoOrcamento_semRemocoes() 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)
}

O helper makeRequest cria um UNNotificationRequest com um UNCalendarNotificationTrigger para uma data específica — o mesmo padrão que os schedulers reais usam.

Integração: Quando chamar enforceLimit()

O gerenciador de orçamento é o passo final após qualquer operação de agendamento:

swift
// Na sequência de inicialização do seu app
func syncAllNotifications() async {
    await habitScheduler.scheduleAll()
    await depletionScheduler.scheduleAll()
    await onboardingScheduler.scheduleDay1Reminders()

    // Sempre enforce após todos os schedulers terminarem
    await NotificationBudgetManager.shared.enforceLimit()
}

Chame-o:

  • Após agendamento em lote — lançamento do app, mudanças de configurações, reagendamentos em massa
  • Após cada scheduler completar — quando um scheduler individual roda independentemente (por exemplo, após um usuário ativar um novo hábito)
  • NÃO após cada chamada .add() individual — isso consultaria e ordenaria a lista de pendentes repetidamente sem benefício

O padrão para schedulers individuais:

swift
func scheduleHabitReminders(for habit: Habit) async {
    // Agendar 7 dias de lembretes
    for day in 0..<7 {
        let request = buildRequest(for: habit, dayOffset: day)
        try? await UNUserNotificationCenter.current().add(request)
    }
    // Enforce o limite uma vez, após adicionar todos os 7
    await NotificationBudgetManager.shared.enforceLimit()
}

TIP

Se múltiplos schedulers disparam em rápida sucessão, a serialização do actor já lida com isso — cada chamada a enforceLimit() espera sua vez. Nenhum debouncing explícito necessário.

Conclusão

O limite de 64 notificações pendentes é real, silencioso e afeta todo app iOS que agenda mais do que um punhado de notificações locais. Sem um gerenciador de orçamento, seu app perderá notificações silenciosamente — e os usuários culparão seu app, não o iOS.

As decisões-chave de design nesta implementação:

  1. Hierarquia de prioridades — Codifica quais tipos de notificação importam mais. Lembretes configurados pelo usuário sobrevivem; nudges ambientais são removidos.
  2. Ordenação determinística de dois níveis — Data de disparo primeiro, prioridade depois. O resultado é previsível e testável.
  3. Isolamento com actor do Swift — O compilador impõe thread safety. Sem locks, sem queues, sem surpresas em tempo de execução.
  4. Testabilidade baseada em protocolos — Envolva UNUserNotificationCenter atrás de um protocolo. Teste o algoritmo com dados mock, não com as APIs reais de notificações.

Para continuar explorando:

  • Analytics: Rastreie contagens de evicção por tipo para entender se a alocação do orçamento de notificações precisa de rebalanceamento
  • Orçamentos dinâmicos: Aloque uma porcentagem dos 64 slots por tipo (por exemplo, 40 para hábitos, 10 para depleção, 14 para todo o resto)
  • Prioridade adaptativa: Aumente a prioridade para tipos de notificação com maiores taxas de tap baseadas em dados de engajamento do usuário

Para mais sobre agendamento de notificações no iOS, consulte a documentação do framework UserNotifications da Apple e a sessão WWDC 2017 sobre melhores práticas de notificações.