O Inbox Pattern no Node.js protege consumidores contra mensagens duplicadas. Em sistemas com Kafka, RabbitMQ, SQS ou outros brokers, a entrega costuma ser pelo menos uma vez. Isso significa que a mesma mensagem pode chegar novamente após timeout, falha de rede, reinício do consumidor ou confirmação perdida.
Se o handler executa uma operação não idempotente, a duplicata pode cobrar duas vezes, reservar estoque novamente ou enviar notificações repetidas. O Inbox Pattern resolve esse problema registrando, no banco do consumidor, quais mensagens já foram processadas. A gravação do ID e a alteração de negócio acontecem na mesma transação.
Neste guia, você aprenderá a modelar uma tabela inbox, processar mensagens atomicamente, lidar com concorrência, limpar registros antigos, aplicar o padrão com PostgreSQL e integrar com Kafka, RabbitMQ e SQS.
O problema da entrega pelo menos uma vez
Brokers priorizam não perder mensagens. Quando não sabem se o consumidor terminou, reenviam. Isso pode acontecer quando:
- o processamento conclui, mas o ACK falha;
- o processo encerra antes de confirmar;
- o visibility timeout expira;
- o consumer group rebalanceia;
- o broker redeliver uma mensagem;
- o produtor envia novamente após timeout.
O padrão Idempotent Consumer recomenda registrar IDs processados para detectar duplicatas.
Estrutura da tabela inbox
CREATE TABLE consumer_inbox (
consumer_name text NOT NULL,
message_id text NOT NULL,
message_type text NOT NULL,
processed_at timestamptz NOT NULL DEFAULT now(),
payload_hash text,
PRIMARY KEY (consumer_name, message_id)
);A chave inclui o nome lógico do consumidor. A mesma mensagem pode ser processada por serviços diferentes sem conflito.
Fluxo correto
- Receba a mensagem.
- Inicie uma transação no banco.
- Insira o ID na inbox.
- Se a chave já existir, trate como duplicata.
- Execute a alteração de negócio.
- Commit.
- Confirme a mensagem no broker.
O ponto essencial é colocar inbox e negócio na mesma transação.
Exemplo com PostgreSQL
async function handleOrderCreated(client, event) {
await client.query('BEGIN');
try {
const inserted = await client.query(
`INSERT INTO consumer_inbox
(consumer_name, message_id, message_type)
VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING
RETURNING message_id`,
['billing-order-created', event.messageId, event.type]
);
if (inserted.rowCount === 0) {
await client.query('ROLLBACK');
return { duplicate: true };
}
await client.query(
`INSERT INTO invoices (order_id, total_cents)
VALUES ($1, $2)`,
[event.orderId, event.totalCents]
);
await client.query('COMMIT');
return { duplicate: false };
} catch (error) {
await client.query('ROLLBACK');
throw error;
}
}O ON CONFLICT DO NOTHING transforma duplicatas em resultado controlado.
Por que não consultar antes?
Este fluxo é inseguro:
if (!(await inbox.exists(messageId))) {
await processMessage();
await inbox.insert(messageId);
}Duas instâncias podem consultar ao mesmo tempo, não encontrar o ID e executar duplicado. Use uma restrição única como árbitro.
ACK após o commit
Confirme no broker somente depois do commit. Se confirmar antes e o banco falhar, a mensagem pode ser perdida. Se o commit concluir e o ACK falhar, a mensagem volta, mas a inbox detecta a duplicata.
RabbitMQ
channel.consume(queue, async message => {
try {
const event = parseAndValidate(message.content);
await withInbox(event);
channel.ack(message);
} catch (error) {
if (isPermanent(error)) {
channel.nack(message, false, false);
} else {
channel.nack(message, false, true);
}
}
});Veja RabbitMQ com Node.js.
Kafka
No Kafka, confirme o offset depois do processamento. O Inbox Pattern continua necessário quando efeitos externos podem ser duplicados.
await consumer.run({
eachMessage: async ({ topic, partition, message }) => {
const event = parseAndValidate(message.value);
await withInbox(event);
}
});O cliente gerencia offsets conforme sua configuração. Para controle manual, confirme somente após o commit.
Consulte Kafka com Node.js.
SQS
No SQS, exclua a mensagem depois do commit:
const result = await withInbox(event);
await sqs.send(new DeleteMessageCommand({
QueueUrl,
ReceiptHandle: message.ReceiptHandle
}));Veja Amazon SQS no Node.js.
ID da mensagem
O ID precisa ser estável durante retries. Use um identificador criado no produtor, não um valor gerado pelo consumidor:
{
"messageId": "evt-01JABC...",
"type": "order.created",
"occurredAt": "2026-09-16T12:00:00Z",
"data": {}
}Em CloudEvents, use source + id como identidade.
Consulte CloudEvents no Node.js.
Payload hash
Uma mesma chave com payload diferente pode indicar bug ou ataque. Armazene um hash:
const hash = createHash('sha256')
.update(canonicalJson(event))
.digest('hex');Quando a chave já existe, compare hashes. Se divergem, envie alerta e não processe silenciosamente.
Idempotência natural
Algumas operações já são idempotentes:
UPDATE orders
SET status = 'paid'
WHERE id = $1;Mesmo assim, a inbox ajuda a evitar efeitos colaterais como e-mails e chamadas externas.
Chaves únicas de negócio
Também use constraints de negócio:
CREATE UNIQUE INDEX invoices_order_id_unique
ON invoices(order_id);A inbox e a constraint se complementam. A primeira identifica a mensagem; a segunda protege a regra de domínio.
Mensagens sem ID
Se o produtor não envia ID, você pode criar um hash determinístico, mas isso tem riscos. Dois eventos legítimos com o mesmo conteúdo podem colidir semanticamente. O melhor é corrigir o contrato.
Integração com Outbox
O produtor usa Outbox para publicar com segurança; o consumidor usa Inbox para deduplicar. Juntos:
- serviço A grava negócio e outbox;
- relay publica;
- broker entrega;
- serviço B grava inbox e negócio;
- B confirma mensagem.
Veja Outbox Pattern no Node.js.
Inbox e Saga
Em Sagas, cada passo e compensação devem ser idempotentes. A inbox impede que o mesmo evento avance a saga duas vezes.
Consulte Saga Pattern no Node.js.
Chamadas externas
Uma transação local não inclui um gateway externo. Se o consumidor chama uma API, use uma chave idempotente aceita pelo provedor:
await payments.charge({
idempotencyKey: event.messageId,
amount: event.totalCents
});Depois grave o resultado na mesma estrutura de workflow. Sem suporte externo, use uma máquina de estados e reconciliação.
Marcar antes ou depois?
A inserção na inbox acontece no início da transação, mas só é confirmada junto com o negócio. Se ocorrer erro, o rollback remove a marca. Assim, a mensagem pode ser tentada novamente.
Status na inbox
Uma tabela mais rica pode registrar status:
CREATE TABLE consumer_inbox (
consumer_name text NOT NULL,
message_id text NOT NULL,
status text NOT NULL,
attempts integer NOT NULL DEFAULT 1,
first_seen_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz,
last_error text,
PRIMARY KEY (consumer_name, message_id)
);Não use isso para substituir a fila. O broker continua responsável por entrega e retries.
Limpeza
A inbox cresce continuamente. Defina retenção maior que a janela máxima de redelivery e replay:
DELETE FROM consumer_inbox
WHERE processed_at < now() - interval '90 days';Execute em lotes para evitar locks longos.
Particionamento
Em alto volume, particione por mês ou data de processamento. Remover uma partição antiga é mais eficiente que milhões de deletes.
Replay
Kafka permite replay histórico. Se a inbox mantém IDs, o replay pode ser ignorado. Para reprocessar intencionalmente, use um novo consumer_name, uma versão de processamento ou limpe registros selecionados.
Schema
Valide antes da transação quando possível:
const event = EventSchema.parse(JSON.parse(body));Mensagens estruturalmente inválidas devem ir para quarentena ou DLQ, não ocupar retries infinitos.
Concorrência
Duas instâncias podem receber a mesma mensagem. A chave primária resolve a disputa. Uma insere; a outra recebe conflito. Não trate isso como erro operacional.
Deadlocks
Mantenha ordem consistente de updates. A inbox deve ser inserida antes das tabelas de negócio em todos os handlers. Em caso de deadlock, faça retry limitado da transação com jitter.
Isolation level
READ COMMITTED com constraint única é suficiente em muitos casos. Níveis mais fortes aumentam custo. A garantia principal vem da chave única e da transação.
Observabilidade
Meça:
- mensagens processadas;
- duplicatas descartadas;
- conflitos de hash;
- latência de transação;
- falhas permanentes;
- retries;
- tamanho da inbox;
- idade do registro mais antigo.
Logs
logger.info({
messageId: event.messageId,
consumer: 'billing-order-created',
duplicate: result.duplicate
}, 'Mensagem processada');Não registre payload completo por padrão.
Testes
Teste explicitamente:
- primeiro processamento;
- duplicata sequencial;
- duplicata concorrente;
- rollback após inserir inbox;
- commit concluído e ACK perdido;
- payload diferente com mesmo ID;
- limpeza e replay.
Erros comuns
- Consultar antes de inserir: race condition.
- Inbox fora da transação: mensagem pode ser marcada sem negócio.
- ACK antes do commit: perda de mensagem.
- ID criado no consumidor: retry recebe chave nova.
- Sem constraint única: concorrência duplica.
- Limpeza cedo demais: replay reexecuta efeitos.
- Ignorar chamadas externas: operação pode duplicar.
- Sem monitoramento: conflitos passam despercebidos.
Conclusão
O Inbox Pattern no Node.js transforma entrega pelo menos uma vez em processamento idempotente. A tabela registra IDs e usa uma constraint única para arbitrar duplicatas concorrentes.
Grave inbox e negócio na mesma transação, confirme no broker somente após o commit e combine com constraints de domínio. Com Outbox no produtor, Inbox no consumidor e observabilidade, mensagens podem ser reenviadas sem repetir efeitos críticos.



