Backend & APIs • 9 min de leitura

Como criar um sistema de notificações em tempo real

A capacidade de entregar informações instantaneamente define a qualidade de uma aplicação moderna. Seja um chat, um painel de monitoramento ou uma plataforma colaborativa, usuários esperam ver mudanças acontecendo — não descobri-las na próxima vez que recarregarem a página.

Este artigo apresenta uma abordagem prática para projetar e implementar um sistema de notificações em tempo real, com exemplos em JavaScript, Python e Rust. Ao final, você terá clareza sobre os princípios fundamentais, os padrões recomendados e as armadilhas mais comuns.

Push vs. Pull: a diferença que importa

Um Push Notification System é uma arquitetura em que o servidor envia mensagens diretamente aos clientes conectados, sem que esses clientes precisem realizar requisições periódicas (polling).

"Em vez de o cliente perguntar 'tem novidade?' a cada segundo, o servidor avisa quando há algo novo."

As principais tecnologias para estabelecer esse canal são:

TecnologiaComunicaçãoMelhor usoLimitação
WebSocketsFull-duplexApps bidirecionaisConexão persistente por cliente
Socket.IOFull-duplexCompatibilidade amplaOverhead de abstração
SSEServidor → clienteFeeds, dashboardsUnidirecional
Long PollingRequisição/respostaFallback legadoAlta latência

O problema com polling

Polling — fazer requisições periódicas ao servidor — é simples de implementar, mas tem custos reais:

  • Latência artificial: o usuário só vê a atualização no próximo ciclo de polling, que pode ser segundos depois do evento.
  • Carga desnecessária: requisições acontecem mesmo quando não há nada novo.
  • Escalabilidade limitada: 10.000 clientes fazendo polling a cada 2 segundos geram 300.000 requisições por minuto, a maioria respondendo "nenhuma novidade".

WebSockets resolvem esses três problemas de uma só vez.

Como o sistema se organiza

Um sistema bem estruturado passa por cinco etapas:

  1. Estabelecimento da conexão: o cliente abre uma conexão persistente com o servidor via WebSocket.
  2. Autenticação: o servidor valida a identidade do cliente e associa a conexão a um usuário ou sessão.
  3. Agrupamento em canais: conexões são organizadas por contexto (usuário, sala, tópico) para envios seletivos.
  4. Publicação de eventos: quando algo relevante ocorre, o servidor emite notificações para os clientes do canal correspondente.
  5. Reconexão automática: em caso de queda, o cliente tenta reconectar sem perder o contexto.

Atenção: armazene o mapeamento de socketId → userId em uma estrutura centralizada (Redis em ambientes distribuídos). Depender apenas da memória do processo impede escalabilidade horizontal.

Implementação

JavaScript — servidor com Node.js e Socket.IO

Socket.IO é a escolha mais comum no ecossistema Node.js: abstrai WebSockets, oferece fallbacks automáticos e tem suporte nativo a salas.

import express from 'express';
import { createServer } from 'http';
import { Server } from 'socket.io';

const app    = express();
const server = createServer(app);
const io     = new Server(server, {
  cors: { origin: '*' },
  pingTimeout: 10_000,
  pingInterval: 5_000,
});

// userId → Set de socketIds
const userSockets = new Map();

// Middleware de autenticação
io.use((socket, next) => {
  const token = socket.handshake.auth?.token;
  if (!token) return next(new Error('Não autorizado'));
  socket.userId = verifyToken(token); // implemente conforme seu sistema
  next();
});

io.on('connection', (socket) => {
  const { userId } = socket;

  // Registra conexão
  if (!userSockets.has(userId)) userSockets.set(userId, new Set());
  userSockets.get(userId).add(socket.id);
  socket.join(`user:${userId}`);

  // Entrega notificações pendentes ao reconectar
  socket.emit('pending', getPendingNotifications(userId));

  socket.on('disconnect', () => {
    const sockets = userSockets.get(userId);
    sockets?.delete(socket.id);
    if (sockets?.size === 0) userSockets.delete(userId);
  });
});

// Notifica um usuário específico a partir de qualquer lugar da aplicação
export function notifyUser(userId, event, payload) {
  io.to(`user:${userId}`).emit(event, payload);
}

server.listen(3000, () => console.log('Servidor rodando na porta 3000'));

JavaScript — cliente

import { io } from 'socket.io-client';

const socket = io('https://seu-servidor.com', {
  auth: { token: localStorage.getItem('token') },
  reconnectionAttempts: 5,
  reconnectionDelay: 2000,
});

socket.on('connect', () => console.log('Conectado:', socket.id));

socket.on('pending', (notifications) => {
  notifications.forEach(render);
});

socket.on('nova-mensagem', (data) => render(data));

socket.on('connect_error', (err) => {
  console.error('Falha na conexão:', err.message);
});

function render(notification) {
  // renderiza a notificação na UI
}

Python — servidor com FastAPI e WebSockets

Para backends Python, FastAPI oferece suporte nativo a WebSockets sem dependências adicionais.

from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from collections import defaultdict
from typing import DefaultDict, Set
import json

app = FastAPI()

# userId → conjunto de conexões ativas
connections: DefaultDict[str, Set[WebSocket]] = defaultdict(set)


def get_user_id(token: str) -> str:
    # Implemente sua lógica de autenticação
    return verify_token(token)


@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket, token: str):
    await websocket.accept()

    try:
        user_id = get_user_id(token)
    except Exception:
        await websocket.close(code=4001)
        return

    connections[user_id].add(websocket)

    # Entrega notificações pendentes
    pending = get_pending_notifications(user_id)
    if pending:
        await websocket.send_text(json.dumps({"type": "pending", "data": pending}))

    try:
        while True:
            # Mantém a conexão viva; mensagens do cliente podem ser processadas aqui
            await websocket.receive_text()
    except WebSocketDisconnect:
        connections[user_id].discard(websocket)
        if not connections[user_id]:
            del connections[user_id]


async def notify_user(user_id: str, event: str, payload: dict):
    """Envia uma notificação a todas as conexões ativas de um usuário."""
    message = json.dumps({"type": event, "data": payload})
    dead = set()

    for ws in connections.get(user_id, set()):
        try:
            await ws.send_text(message)
        except Exception:
            dead.add(ws)

    connections[user_id] -= dead

Rust — servidor com Axum e Tokio

Para sistemas onde performance e consumo de memória são críticos, Rust com Axum oferece WebSockets com overhead mínimo.

use axum::{
    extract::ws::{Message, WebSocket, WebSocketUpgrade},
    extract::State,
    response::Response,
    routing::get,
    Router,
};
use dashmap::DashMap;
use std::sync::Arc;
use tokio::sync::broadcast;

#[derive(Clone)]
struct AppState {
    // userId → canal de broadcast
    channels: Arc<DashMap<String, broadcast::Sender<String>>>,
}

async fn ws_handler(
    ws: WebSocketUpgrade,
    State(state): State<AppState>,
    // token viria via query param ou header
) -> Response {
    ws.on_upgrade(move |socket| handle_socket(socket, state))
}

async fn handle_socket(mut socket: WebSocket, state: AppState) {
    // Em produção, extraia e valide o token aqui
    let user_id = "user_123".to_string();

    let sender = state
        .channels
        .entry(user_id.clone())
        .or_insert_with(|| {
            let (tx, _) = broadcast::channel(32);
            tx
        })
        .clone();

    let mut receiver = sender.subscribe();

    loop {
        tokio::select! {
            // Mensagem chegando do servidor para o cliente
            Ok(msg) = receiver.recv() => {
                if socket.send(Message::Text(msg)).await.is_err() {
                    break; // cliente desconectou
                }
            }
            // Mensagem chegando do cliente (ping, ack, etc.)
            Some(Ok(msg)) = socket.recv() => {
                if let Message::Close(_) = msg {
                    break;
                }
            }
            else => break,
        }
    }
}

// Notifica um usuário a partir de qualquer parte da aplicação
fn notify_user(state: &AppState, user_id: &str, payload: &str) {
    if let Some(sender) = state.channels.get(user_id) {
        let _ = sender.send(payload.to_string());
    }
}

#[tokio::main]
async fn main() {
    let state = AppState {
        channels: Arc::new(DashMap::new()),
    };

    let app = Router::new()
        .route("/ws", get(ws_handler))
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Dependências (Cargo.toml): axum, tokio (features: full), dashmap

O que faz diferença no dia a dia

Canais em vez de broadcast global: emitir eventos para todos os clientes conectados é o caminho mais rápido para desperdiçar banda e CPU. Agrupe conexões por contexto — usuário, organização, sala — e envie apenas para quem precisa receber.

Notificações persistentes: nem todo usuário estará online quando um evento ocorrer. Persista notificações não entregues e envie-as ao reconectar, como demonstrado nos exemplos com pending. Sem isso, mensagens simplesmente somem.

Heartbeats calibrados: pingTimeout e pingInterval detectam conexões zumbis. Valores muito baixos causam reconexões desnecessárias em redes instáveis; muito altos atrasam a detecção de clientes mortos. Ajuste conforme o ambiente.

Logging de entrega: registre quando uma notificação é enviada, para qual usuário e se chegou. Sem isso, depurar comportamentos inconsistentes entre ambientes vira adivinhação.

O que costuma dar errado

Broadcast global: além do desperdício de recursos, expõe dados de um usuário para outro. Sempre filtre o destino.

Estado só em memória: em múltiplos processos ou containers, conexões do mesmo usuário podem estar em instâncias diferentes. Use Redis com o adapter correspondente — Socket.IO Redis Adapter no Node, pub/sub nativo no Python e Rust — para que eventos cruzem instâncias.

Tokens expirados sem desconexão: sessões inativas ou tokens vencidos que não são desconectados acumulam conexões fantasma. O servidor continua mantendo estado para clientes que, na prática, já foram embora.

Reconexão sem backoff: clientes que reconectam imediatamente após uma falha criam picos de carga exatamente quando o servidor está mais vulnerável. Exponential backoff com jitter distribui as tentativas no tempo.

Quando o sistema precisa crescer

Com múltiplas instâncias, dois componentes se tornam indispensáveis:

  • Pub/Sub centralizado (Redis, NATS): sincroniza eventos entre processos distintos. Um evento publicado em uma instância chega a clientes conectados em outra.
  • Load balancer com sticky sessions ou transporte WebSocket direto: garante que a conexão do cliente seja roteada consistentemente.
// Socket.IO + Redis Adapter
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'ioredis';

const pub = createClient({ host: 'redis', port: 6379 });
const sub = pub.duplicate();

io.adapter(createAdapter(pub, sub));

Para ir além

Notificações em tempo real são mais do que WebSockets. O WebSocket é só o transporte — a complexidade real está em autenticação, persistência de mensagens não entregues, estado distribuído e reconexão sem perda de contexto.

Os padrões deste artigo cobrem bem a maioria dos casos. Quando o sistema crescer além deles, o próximo passo natural são arquiteturas orientadas a eventos com filas de mensagens (RabbitMQ, Kafka) — onde o WebSocket vira apenas a camada de entrega final, e a lógica de roteamento fica em outro lugar.

Referências

  • Socket.IO. Client Initialization. https://socket.io/docs/v4/client-initialization/
  • Socket.IO. Redis Adapter. https://socket.io/docs/v4/redis-adapter/
  • FastAPI. WebSockets. https://fastapi.tiangolo.com/advanced/websockets/
  • Axum. WebSocket example. https://github.com/tokio-rs/axum/tree/main/examples/websockets
  • MDN Web Docs. WebSockets API. https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API