Notificações em tempo real com SignalR no ASP.NET Core que não se perdem
Grave a notificação no banco, avise pelo SignalR o usuário certo e recarregue a lista ao reconectar. Inclui hub, serviço, cliente JavaScript e proxy.
O gerente aprovou o reembolso às 14h02. A analista estava com o notebook fechado, numa reunião. Quando abriu a tela às 14h40, o sino estava zerado e ela só soube da aprovação por e-mail no dia seguinte. A resposta curta: o SignalR avisa que algo aconteceu; quem guarda o que aconteceu é o banco. Grave a notificação primeiro, envie pelo hub para o usuário certo e, toda vez que a conexão voltar, busque a lista pelo HTTP normal.
Este guia monta esse fluxo completo num ASP.NET Core: hub autenticado, serviço que grava e publica, cliente JavaScript com reconexão e a configuração de proxy que costuma derrubar tudo em produção.
Por que só empurrar a mensagem não basta
Uma conexão SignalR existe enquanto a aba está aberta e a rede colabora. Notebook fechado, celular no túnel, deploy reiniciando o servidor: em todos esses casos, a mensagem enviada naquele instante não tem para onde ir. O SignalR não guarda fila de mensagens para quem está desconectado.
Por isso a notificação tem duas vidas. Uma é o registro, uma linha na tabela com usuário, texto, data e se foi lida. A outra é o aviso, um empurrão pela conexão que faz o sino piscar sem recarregar a página. Se o aviso se perde, o registro continua lá. O post notificação em tempo real: o aviso é extra, o registro é que fica aprofunda esse princípio; aqui o foco é a implementação.
Como implementar passo a passo
- Crie a tabela de notificações e o endpoint HTTP que lista as do usuário logado.
- Registre o SignalR e mapeie o hub num caminho fixo.
- Exija autenticação no hub, para que o servidor saiba quem é cada conexão.
- No ponto em que o fato acontece, grave a notificação e só depois envie para o usuário.
- No cliente, conecte com reconexão automática e recarregue a lista ao reconectar.
- Configure o proxy para repassar WebSockets.
Hub e registro no Program.cs
// Hubs/NotificacoesHub.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
[Authorize]
public class NotificacoesHub : Hub
{
}
// Program.cs (trechos)
builder.Services.AddSignalR();
builder.Services.AddScoped<NotificacaoService>();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapHub<NotificacoesHub>("/hubs/notificacoes");
app.MapGet("/api/notificacoes", async (ClaimsPrincipal user, AppDbContext db) =>
{
var id = user.FindFirstValue(ClaimTypes.NameIdentifier);
return await db.Notificacoes
.Where(n => n.UsuarioId == id)
.OrderByDescending(n => n.CriadaEm)
.Take(50)
.ToListAsync();
}).RequireAuthorization();
O hub está vazio de propósito. Ele não calcula nada, não consulta pedido, não aplica regra. Sua única função aqui é aceitar conexões autenticadas. Por padrão, o SignalR identifica o usuário pela claim ClaimTypes.NameIdentifier, e é esse valor que Clients.User(...) usa para achar todas as conexões daquela pessoa, em todas as abas.
O serviço que grava e avisa
// Services/NotificacaoService.cs
using Microsoft.AspNetCore.SignalR;
public class NotificacaoService(AppDbContext db, IHubContext<NotificacoesHub> hub)
{
public async Task NotificarAsync(string usuarioId, string texto)
{
var n = new Notificacao
{
UsuarioId = usuarioId,
Texto = texto,
CriadaEm = DateTime.UtcNow
};
db.Notificacoes.Add(n);
await db.SaveChangesAsync();
await hub.Clients.User(usuarioId)
.SendAsync("novaNotificacao", new { n.Id, n.Texto, n.CriadaEm });
}
}
A ordem importa: primeiro SaveChangesAsync, depois SendAsync. Se o envio falhar, o registro existe. Se fosse o contrário, a tela poderia mostrar um aviso que nunca foi gravado. No exemplo do reembolso, o código que aprova chama NotificarAsync(analistaId, "Reembolso #4821 aprovado") depois de salvar a aprovação.
Para avisar um setor inteiro, use grupos: no OnConnectedAsync do hub, chame Groups.AddToGroupAsync(Context.ConnectionId, "financeiro") com base nas claims do usuário, e publique com Clients.Group("financeiro"). Grupos não são persistentes; a conexão entra de novo a cada reconexão, e é justamente por isso que essa lógica fica no OnConnectedAsync.
O cliente JavaScript
import * as signalR from '@microsoft/signalr';
const conexao = new signalR.HubConnectionBuilder()
.withUrl('/hubs/notificacoes')
.withAutomaticReconnect()
.build();
async function carregarLista() {
const resp = await fetch('/api/notificacoes');
if (resp.ok) desenharSino(await resp.json());
}
conexao.on('novaNotificacao', (n) => {
adicionarNoTopo(n);
});
conexao.onreconnected(() => carregarLista());
conexao.onclose(() => {
setTimeout(() => conexao.start().then(carregarLista).catch(() => {}), 30000);
});
await carregarLista();
await conexao.start();
desenharSino e adicionarNoTopo são as funções da sua interface. O detalhe que resolve o caso da analista é o onreconnected: a reconexão automática tenta de novo algumas vezes (por padrão, depois de 0, 2, 10 e 30 segundos) e, quando consegue, a lista é buscada no HTTP. Se todas as tentativas falharem, o onclose dispara e você decide quando tentar outra vez.
Quando a conexão não sobe em produção
No seu computador funciona; atrás do Nginx, a tela fica muda. Três causas respondem pela maior parte dos casos:
- Caminho diferente. O cliente chama
/hubs/notificacoes, mas a aplicação roda sob um subcaminho ou o proxy reescreve a URL. A negociação devolve 404. O post SignalR que devolve 404 na negociação mostra como diagnosticar. - Proxy sem WebSocket. O Nginx precisa repassar os cabeçalhos de upgrade, senão a conexão cai para transportes mais lentos ou falha.
- Vários servidores sem backplane. Com duas instâncias, o usuário está conectado na A e a notificação é enviada pela B. Use o backplane Redis do SignalR ou o Azure SignalR Service, e sticky sessions no balanceador quando houver negociação.
location /hubs/ {
proxy_pass http://127.0.0.1:5000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 120s;
}
Se a autenticação for por JWT em vez de cookie, lembre que o navegador não manda cabeçalho Authorization no WebSocket. O cliente usa accessTokenFactory e o token vai na query string; no servidor, você lê esse valor no evento OnMessageReceived do JwtBearer para os caminhos do hub.
Erros comuns
- Enviar pelo hub sem gravar no banco. Quem estava offline perde o aviso para sempre.
- Colocar regra de negócio dentro do hub. O hub vira um segundo controlador, difícil de testar e chamado por qualquer cliente conectado.
- Usar
Clients.Allpara avisos pessoais. Todo mundo recebe a notificação de reembolso da analista. - Não recarregar a lista ao reconectar. O minuto em que a rede caiu some da tela.
- Proxy sem cabeçalhos de upgrade. A conexão falha ou fica presa num transporte lento, e o sintoma parece bug no código.
- Escalar para duas instâncias sem backplane. Metade das notificações não chega, de forma aleatória.
Checklist
- Toda notificação é gravada antes de ser enviada pelo hub.
- Existe um endpoint HTTP que lista as notificações do usuário logado.
- O hub exige autenticação e não contém regra de negócio.
- Avisos pessoais usam
Clients.User; avisos de setor usam grupos. - O cliente usa
withAutomaticReconnecte recarrega a lista noonreconnected. - O proxy repassa
UpgradeeConnectionno caminho do hub. - Com mais de uma instância, há backplane configurado.
Faça o teste agora: abra o sistema, desligue o Wi-Fi, dispare uma notificação para você mesmo por outro dispositivo e ligue a rede de novo. O sino mostra o aviso sem você apertar F5?
Referência: https://learn.microsoft.com/pt-br/aspnet/core/tutorials/signalr?view=aspnetcore-7.0&tabs=visual-studio