Push não chega no iOS: as regras da Apple para PWA instalada
No Safari móvel a API de notificação nem existe até o site ser adicionado à tela de início. As cinco condições, e como detectar cada uma.
O erro tratado aqui
ReferenceError: Can't find variable: NotificationAmbiente testado
- iOS 18
- Safari 18
- Next.js 15.3
- web-push 3.6
Contexto: o que estava rodando
Web Push implementado e funcionando: Chrome no desktop, Chrome no Android, Firefox. A funcionalidade estava pronta.
Aí veio o relato: “no iPhone o botão não faz nada”.
O erro
Conectando o iPhone ao Safari do Mac para inspecionar, a resposta apareceu na primeira linha:
> Notification.permission
ReferenceError: Can't find variable: Notification
> 'PushManager' in window
false
> 'serviceWorker' in navigator
trueO Service Worker existe. Notification e PushManager não. E o código, que
verificava só 'serviceWorker' in navigator antes de prosseguir, seguia adiante
e quebrava no meio.
Diagnóstico
No Safari móvel, a API só existe dentro do aplicativo instalado
Esta é a regra que explica tudo. A Apple liberou Web Push no iOS a partir do
16.4, mas apenas para sites adicionados à tela de início. Numa aba comum do
Safari, os objetos Notification e PushManager simplesmente não são definidos.
Não é permissão negada. Não é bloqueio silencioso. A API não está lá.
Isso muda o desenho da funcionalidade: no Android, você pede permissão. No iOS, você precisa primeiro convencer o usuário a instalar o aplicativo — e a Apple não oferece nenhuma API para pedir isso.
As cinco condições
Todas precisam ser verdadeiras. Uma falhando, o push não existe.
| # | Condição | Como falha na prática |
|---|---|---|
| 1 | iOS 16.4 ou superior | Aparelho antigo; nada a fazer |
| 2 | Site adicionado à tela de início | O caso mais comum, e invisível para quem testa no Android |
| 3 | Manifest com display: standalone ou fullscreen |
browser faz o app abrir no Safari e a API não aparece |
| 4 | Permissão pedida dentro de um gesto do usuário | Qualquer await antes do requestPermission invalida o gesto |
| 5 | Notificação exibida pelo Service Worker | O construtor new Notification() não funciona |
O gesto perdido
A condição 4 é a mais traiçoeira, porque o código funciona no Chrome e falha no Safari:
// Funciona no Chrome. No Safari o await consome o gesto, e requestPermission
// devolve 'denied' sem mostrar nada.
onClick={async () => {
const registro = await navigator.serviceWorker.register('/sw.js');
await navigator.serviceWorker.ready;
const permissao = await Notification.requestPermission(); // tarde demais
}}
O Safari é rígido: a chamada precisa ser a primeira coisa do manipulador de clique. Qualquer operação assíncrona antes dela quebra a associação com o gesto.
Sem beforeinstallprompt
No Android, o navegador oferece o evento que permite mostrar um botão “instalar”. No iOS ele não existe. A instalação é manual — Compartilhar, Adicionar à Tela de Início — e a única coisa que você pode fazer é ensinar o caminho.
A solução
-
Detectar o ambiente antes de oferecer qualquer coisa.
src/lib/ambiente-push.tsexport type Situacao = | 'suportado' // pode pedir permissão agora | 'precisa-instalar' // iOS em aba: instruir a instalação | 'indisponivel'; // navegador sem suporte export function situacaoPush(): Situacao { if (typeof window === 'undefined') return 'indisponivel'; const temApi = 'serviceWorker' in navigator && 'PushManager' in window; if (temApi) return 'suportado'; // A API some no Safari móvel fora do app instalado. Se for iOS e ainda // não estiver instalado, o caminho não é pedir permissão — é instalar. const ehIos = /iPad|iPhone|iPod/.test(navigator.userAgent); const instalado = window.matchMedia('(display-mode: standalone)').matches || // Safari usa uma propriedade própria, anterior ao padrão. (navigator as unknown as { standalone?: boolean }).standalone === true; if (ehIos && !instalado) return 'precisa-instalar'; return 'indisponivel'; } -
Ensinar a instalação em vez de mostrar um botão morto.
src/components/InstruirInstalacao.tsxexport function InstruirInstalacao() { return ( <div className="rounded-lg border border-neutral-200 p-4 text-sm"> <p className="font-medium">Para receber avisos no iPhone</p> <ol className="mt-2 list-decimal space-y-1 pl-5 text-neutral-600"> <li>Toque no botão Compartilhar, na barra do Safari.</li> <li>Escolha <strong>Adicionar à Tela de Início</strong>.</li> <li>Abra o aplicativo pelo ícone e volte aqui.</li> </ol> <p className="mt-2 text-neutral-500"> A Apple só permite notificações em sites instalados dessa forma. </p> </div> ); }Explicar o motivo importa: sem a última frase, o pedido parece capricho.
-
Manifest com o modo de exibição certo.
public/manifest.json{ "name": "Entregas", "short_name": "Entregas", "start_url": "/", "display": "standalone", "scope": "/", "background_color": "#ffffff", "theme_color": "#0d0f13", "icons": [ { "src": "/icons/192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/icons/512.png", "sizes": "512x512", "type": "image/png" }, { "src": "/icons/512-mask.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } ] }src/app/layout.tsxexport const metadata = { manifest: '/manifest.json', // O iOS usa este ícone na tela de início, e não o do manifest. appleWebApp: { capable: true, statusBarStyle: 'default', title: 'Entregas' }, };display: "browser"faz o ícone abrir uma aba comum do Safari. Visualmente quase igual, e a API de push continua ausente. -
Pedir permissão como primeira instrução do clique.
src/components/AtivarAvisosIos.tsx'use client'; export function AtivarAvisos() { return ( <button type="button" onClick={(evento) => { // Primeira linha, sem await antes. No Safari o gesto do usuário // não sobrevive a uma operação assíncrona. const promessa = Notification.requestPermission(); evento.currentTarget.disabled = true; promessa.then(async (permissao) => { if (permissao !== 'granted') return; const registro = await navigator.serviceWorker.register('/sw.js'); await navigator.serviceWorker.ready; await assinar(registro); }); }} > Ativar avisos de entrega </button> ); }O registro do worker acontece depois, dentro do
then. A ordem parece estranha e é exatamente o que o Safari exige. -
Exibir sempre pelo Service Worker.
public/sw.jsself.addEventListener('push', (event) => { const dados = event.data?.json() ?? {}; event.waitUntil( (async () => { await self.registration.showNotification(dados.titulo ?? 'Entregas', { body: dados.corpo, icon: '/icons/192.png', data: { url: dados.url ?? '/' }, }); // Contador no ícone do app. Funciona em PWA instalada e é ignorado // silenciosamente onde não houver suporte. if ('setAppBadge' in self.navigator && typeof dados.badge === 'number') { await self.navigator.setAppBadge(dados.badge); } })(), ); });new Notification(...)na página não funciona no iOS. Sóregistration.showNotification.
Como confirmar que resolveu
Inspecione o app instalado, não a aba. No Safari do Mac, Desenvolvedor →
[nome do iPhone] lista os contextos. O aplicativo instalado aparece separado das
abas do Safari — inspecionar a aba errada mostra PushManager ausente e leva a
conclusão errada.
Com o iPhone conectado e o app aberto pelo ícone:
> 'PushManager' in window
true
> Notification.permission
"granted"
> (await navigator.serviceWorker.ready).pushManager.getSubscription()
PushSubscription {endpoint: "https://web.push.apple.com/QP8..."}O endpoint em web.push.apple.com confirma que a assinatura foi criada pela
infraestrutura da Apple.
Envie com o app fechado. Fechar de verdade — deslizar para cima na multitarefa — e disparar do servidor. É o único teste que vale.
Teste a reinstalação. Remova o app da tela de início e adicione de novo: a
assinatura anterior morre. O servidor deve receber 410 no próximo envio e
apagar a linha, e o usuário precisa ativar novamente.
Confirme o display. Abra pelo ícone: se a barra de endereço do Safari
aparecer, o display não está como standalone e nada mais funciona.
E quando o problema é no Android
O sintoma “não chega” no Android tem causas diferentes:
Economia de bateria. Fabricantes agressivos com gerenciamento de energia suspendem o Service Worker. O push chega quando o aparelho acorda, às vezes com minutos de atraso. Pouco a fazer do lado do código.
TTL curto demais. Com o aparelho offline, o servidor de push guarda a
mensagem pelo TTL enviado. TTL: 0 significa “entregue agora ou descarte”.
Carga acima do limite. O payload cifrado tem teto na casa de poucos
kilobytes. Mande identificadores e busque o conteúdo no push, em vez de
mandar o objeto inteiro.
tag reaproveitada. Notificações com a mesma tag se substituem. Se o
usuário deveria ver três avisos e vê um, é isso.
Armadilhas que sobram depois disso
Não existe API para saber se o usuário instalou. display-mode: standalone
só responde quando a página já está rodando dentro do app. Numa aba, não há como
saber se ele instalou em outro momento.
Modo de navegação privada não tem Service Worker. O botão some sem explicação.
iPad se comporta como desktop no user agent. A detecção por /iPad/ falha em
iPadOS recente, que se identifica como Mac. Prefira testar a ausência da API a
testar o nome do aparelho.
Assinatura da Apple também expira. Trate pushsubscriptionchange no worker e
reassine, ou o usuário para de receber sem ter mudado nada.
A permissão continua sendo tentativa única. Todo o cuidado sobre o momento de pedir vale aqui também — com o agravante de que, no iOS, o usuário já gastou esforço instalando o app antes de chegar nesse ponto.
Continue por aqui
Performance Web
Web Push do zero: Service Worker, VAPID e o pedido de permissão
A implementação inteira, do registro do worker ao envio. E o erro de UX que queima a permissão do usuário para sempre, sem possibilidade de desfazer.
Performance Web
Streaming e Suspense: eliminando o waterfall que trava a página
Um await depois do outro num Server Component soma latências. Como paralelizar e, melhor ainda, entregar a página antes dos dados chegarem.
Performance Web
Bundle analyzer: as dependências que sempre estouram o JavaScript
Quando o peso está no pacote compartilhado, o problema não é uma rota — é uma biblioteca. As seis que mais aparecem e o que colocar no lugar.