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.
O erro tratado aqui
DOMException: Registration failed - permission deniedAmbiente testado
- Next.js 15.3
- web-push 3.6
- Chrome 140
- Firefox 142
Contexto: o que estava rodando
Um aplicativo de acompanhamento de entregas. A ideia era avisar o usuário quando o status do pedido mudasse, sem depender de ele manter a aba aberta.
Primeira versão: pedir permissão assim que a página carrega, e assinar.
O erro
DOMException: Registration failed - permission denied
at PushManager.subscribe
Notification.permission
> "denied"E, na tentativa de pedir de novo:
> await Notification.requestPermission()
< "denied" ← devolvido na hora, sem mostrar nada ao usuárioO navegador não perguntou nada. Ele devolveu denied imediatamente.
Diagnóstico
Permissão negada é definitiva
Este é o ponto que muda todo o desenho da funcionalidade. Uma vez que o usuário
clica em “Bloquear”, Notification.requestPermission() para de mostrar diálogo.
Ele passa a devolver denied sem perguntar nada.
Não existe API para reverter isso. O usuário precisa ir nas configurações do site, no navegador, e mudar na mão — algo que praticamente ninguém faz.
E os navegadores endureceram ainda mais: pedido disparado no carregamento, sem interação, pode ser negado automaticamente. O Chrome tem um modo que bloqueia sozinho quando detecta esse padrão, e o Firefox exige gesto do usuário.
As três peças e onde cada uma falha
| Peça | O que faz | Falha típica |
|---|---|---|
| Service Worker | Recebe o push com a aba fechada | Escopo errado; não registra fora de HTTPS |
| Chaves VAPID | Identificam o seu servidor | Pública no cliente diferente da privada no servidor |
| Subscription | Endereço para onde enviar | Não persistida, ou não removida quando expira |
localhost é exceção à regra de HTTPS. Em qualquer outro host, sem TLS o
navigator.serviceWorker nem existe — e o erro que aparece é undefined is not an object, que não sugere em nada o motivo real.
A solução
-
Gerar o par de chaves VAPID.
npx web-push generate-vapid-keys.envNEXT_PUBLIC_VAPID_PUBLIC_KEY="BEl6...Wc4" VAPID_PRIVATE_KEY="hK9...b2Q" VAPID_SUBJECT="mailto:contato@seudominio.com"A pública vai para o navegador; a privada nunca. Se elas não forem do mesmo par, o servidor de push devolve
403no envio — e a assinatura parecia perfeita. -
Registrar o Service Worker no escopo certo.
O arquivo precisa estar na raiz pública. Um worker servido de
/js/sw.jssó controla páginas dentro de/js/.public/sw.jsself.addEventListener('push', (event) => { const dados = event.data?.json() ?? {}; // waitUntil segura o worker vivo até a notificação aparecer. Sem ele, o // navegador pode encerrar o worker antes e o push some sem erro. event.waitUntil( self.registration.showNotification(dados.titulo ?? 'Atualização', { body: dados.corpo, icon: '/icons/192.png', badge: '/icons/badge.png', // Mesma tag substitui a notificação anterior em vez de empilhar. tag: dados.tag ?? 'geral', data: { url: dados.url ?? '/' }, }), ); }); self.addEventListener('notificationclick', (event) => { event.notification.close(); const destino = event.notification.data?.url ?? '/'; event.waitUntil( (async () => { const janelas = await self.clients.matchAll({ type: 'window', includeUncontrolled: true, }); // Reaproveita uma aba aberta em vez de abrir a quarta cópia do app. for (const janela of janelas) { if (janela.url.includes(destino) && 'focus' in janela) { return janela.focus(); } } return self.clients.openWindow(destino); })(), ); }); -
Pedir permissão só depois de o usuário demonstrar interesse.
O padrão que funciona tem duas etapas: uma explicação sua, que pode ser recusada sem custo, e só então o diálogo do navegador.
src/components/AtivarAvisos.tsx'use client'; import { useState, useEffect } from 'react'; type Estado = 'indisponivel' | 'pode-pedir' | 'ativo' | 'bloqueado'; export function AtivarAvisos({ pedidoId }: { pedidoId: string }) { const [estado, setEstado] = useState<Estado>('indisponivel'); useEffect(() => { if (!('serviceWorker' in navigator) || !('PushManager' in window)) return; setEstado( Notification.permission === 'granted' ? 'ativo' : Notification.permission === 'denied' ? 'bloqueado' : 'pode-pedir', ); }, []); if (estado === 'indisponivel') return null; if (estado === 'bloqueado') { // Não adianta oferecer o botão: requestPermission devolveria denied // na hora. A única saída é instruir sobre as configurações do navegador. return ( <p className="text-sm text-neutral-500"> Avisos bloqueados. Para reativar, abra as configurações deste site no seu navegador e permita notificações. </p> ); } if (estado === 'ativo') return <p className="text-sm">Avisos ativados.</p>; return ( <button type="button" // Sempre dentro de um clique: sem gesto do usuário, parte dos // navegadores nem mostra o diálogo. onClick={async () => { const permissao = await Notification.requestPermission(); if (permissao !== 'granted') { setEstado(permissao === 'denied' ? 'bloqueado' : 'pode-pedir'); return; } await assinar(pedidoId); setEstado('ativo'); }} className="rounded-md bg-neutral-900 px-4 py-2 text-sm text-white" > Avisar quando meu pedido sair para entrega </button> ); }Repare no texto do botão. Ele diz o que o usuário ganha, não “ativar notificações” — a diferença aparece direto na taxa de aceitação.
-
Assinar e guardar no servidor.
src/lib/push-cliente.ts/** A chave VAPID vem em base64url; o PushManager exige bytes. */ function base64UrlParaBytes(base64: string) { const preenchido = (base64 + '='.repeat((4 - (base64.length % 4)) % 4)) .replace(/-/g, '+') .replace(/_/g, '/'); const cru = atob(preenchido); return Uint8Array.from(cru, (c) => c.charCodeAt(0)); } export async function assinar(pedidoId: string) { const registro = await navigator.serviceWorker.register('/sw.js'); await navigator.serviceWorker.ready; const assinatura = (await registro.pushManager.getSubscription()) ?? (await registro.pushManager.subscribe({ // Obrigatório: sem isso o navegador recusa a assinatura. userVisibleOnly: true, applicationServerKey: base64UrlParaBytes( process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!, ), })); await fetch('/api/push/assinar', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ assinatura: assinatura.toJSON(), pedidoId }), }); }prisma/schema.prismamodel PushSubscription { id String @id @default(cuid()) endpoint String @unique // o endpoint identifica o dispositivo p256dh String auth String usuarioId String criadoEm DateTime @default(now()) @@index([usuarioId]) } -
Enviar e limpar assinaturas mortas.
src/lib/push-servidor.tsimport webpush from 'web-push'; webpush.setVapidDetails( process.env.VAPID_SUBJECT!, process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!, process.env.VAPID_PRIVATE_KEY!, ); export async function enviarPara(usuarioId: string, carga: object) { const assinaturas = await prisma.pushSubscription.findMany({ where: { usuarioId } }); await Promise.allSettled( assinaturas.map(async (s) => { try { await webpush.sendNotification( { endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } }, JSON.stringify(carga), { TTL: 3600 }, ); } catch (err) { const status = (err as { statusCode?: number }).statusCode; // 404 e 410 significam assinatura morta: o usuário desinstalou, // limpou os dados ou revogou. Guardar não serve para nada. if (status === 404 || status === 410) { await prisma.pushSubscription.delete({ where: { id: s.id } }); return; } throw err; } }), ); }Sem essa limpeza, a tabela acumula endpoints mortos e cada envio gasta tempo batendo em portas fechadas.
Como confirmar que resolveu
Estado inicial limpo. Em janela anônima, Notification.permission deve ser
"default" e nenhum diálogo pode aparecer sozinho.
O worker está no ar e no escopo certo:
await navigator.serviceWorker.getRegistrations();
// scope precisa ser a raiz: 'https://seu-site.com/'
Dispare um push de teste sem passar pelo seu backend. No DevTools, aba Application → Service Workers, o campo Push envia uma carga direto para o worker. Se a notificação aparecer, o worker está correto e o problema seria no envio.
Teste com a aba fechada. É o único teste que prova a funcionalidade — com a aba aberta, muita coisa funciona por acidente.
Revogue e confirme a limpeza. Remova a permissão nas configurações do site e
dispare um envio: o servidor deve receber 410 e apagar a linha.
Armadilhas que sobram depois disso
userVisibleOnly: true é obrigatório. A especificação prevê push silencioso;
os navegadores não permitem. Todo push precisa gerar notificação visível, e o
worker que recebe um push sem mostrar nada pode perder a permissão.
Service Worker antigo em cache atrapalha. O navegador segura a versão anterior até todas as abas fecharem. Durante o desenvolvimento, use Update on reload no DevTools — sem isso você depura código que não está mais rodando.
Assinatura expira sozinha. Navegadores rotacionam endpoints periodicamente.
Trate pushsubscriptionchange no worker e reassine, ou o usuário simplesmente
para de receber sem nunca ter desativado nada.
Cada navegador tem seu servidor de push. Chrome usa a infraestrutura do Google, Firefox a da Mozilla. Uma delas bloqueada na rede corporativa do usuário faz o push falhar só para ele.
iOS tem regras próprias, e bem mais restritivas. Nada do que está aqui funciona no Safari móvel enquanto o site não for instalado na tela de início — o que muda o desenho da funcionalidade inteira.
Continue por aqui
Performance Web
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.
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.