Um app de chat MQTT é uma das melhores formas de aprender publish/subscribe na prática: salas viram topics, presença vira retained message e a detecção de quedas vem de graça com o Last Will. Neste tutorial você vai construir um chat completo para navegador em um único arquivo HTML, usando MQTT.js sobre WebSocket seguro e conectado ao servidor MQTT público broker.connectmq.com. No caminho, você vai usar a opção No Local do MQTT 5 para que as suas próprias mensagens não voltem para você.
Por que o MQTT funciona bem para chat
Chat parece um problema de mensageria, e MQTT é um protocolo de mensageria, então o encaixe é natural. Cada conceito de um app de chat tem um equivalente direto no MQTT:
| Recurso do chat | Mecanismo MQTT |
|---|---|
| Sala de chat | Um topic, por exemplo .../room/general/messages |
| Entrar em uma sala | Assinar esse topic |
| Enviar uma mensagem | Publicar nesse topic |
| Lista de "quem está online" | Retained message por usuário em um topic de presença |
| Detectar aba fechada ou rede perdida | Last Will and Testament, publicado pelo broker |
| Não ver a própria mensagem duas vezes | Opção de assinatura No Local do MQTT 5 |
O que o MQTT não oferece é histórico de mensagens. O broker entrega as mensagens a quem está assinado naquele momento; ele não é um banco de dados. É uma troca deliberada, e no final vamos ver como adicionar histórico.
Se algum desses termos for novo, nossa visão geral sobre o que um broker MQTT faz e o guia de recursos do MQTT 5 são boas introduções.
Design de topics para o app de chat MQTT
Um bom design de topics mantém o código simples. Todo topic começa com um prefixo aleatório, para que o seu teste não colida com o de outras pessoas no servidor MQTT público, seguido do nome da sala:
connectmq/demo/<prefix>/room/<room>/messages chat messages (not retained)
connectmq/demo/<prefix>/room/<room>/presence/<clientId> presence (retained JSON)
Um cliente assina .../messages e .../presence/+. O curinga de nível único + corresponde ao topic de presença de cada usuário e, como as mensagens de presença são retidas, quem chega recebe imediatamente o status atual de todos na sala. Isso fica dentro dos limites do servidor MQTT público: 8 níveis de topic e 10 assinaturas por cliente.
A página de chat completa
Salve isto como chat.html. Abra em duas janelas do navegador (ou envie o arquivo a um amigo), informe nomes diferentes e a mesma sala, e comece a conversar.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MQTT Chat</title>
<script src="https://unpkg.com/mqtt@5.10.1/dist/mqtt.min.js"></script>
<style>
body { font-family: system-ui, sans-serif; max-width: 860px; margin: 1.5rem auto; padding: 0 1rem; }
.layout { display: grid; grid-template-columns: 1fr 200px; gap: 1rem; }
#messages { border: 1px solid #ccc; height: 360px; overflow-y: auto; padding: .5rem; }
#users { border: 1px solid #ccc; padding: .5rem; list-style: none; margin: 0; }
.me { color: #0a58ca; }
.meta { color: #777; font-size: .8rem; }
form { display: flex; gap: .5rem; margin-top: .5rem; }
form input[type=text] { flex: 1; }
input, button { font: inherit; padding: .4rem .6rem; }
</style>
</head>
<body>
<h1>MQTT Chat</h1>
<form id="joinForm">
<input type="text" id="name" placeholder="Your name" required maxlength="24">
<input type="text" id="room" value="general" required maxlength="24">
<button>Join</button>
</form>
<div class="layout">
<div>
<div id="messages"></div>
<form id="sendForm">
<input type="text" id="text" placeholder="Type a message" maxlength="500" autocomplete="off">
<button>Send</button>
</form>
</div>
<ul id="users"></ul>
</div>
<script>
// Troque por uma string aleatória sua, compartilhada por todos que devem ver o chat.
const PREFIX = "connectmq/demo/chat-k7f3q2";
const URL = "wss://broker.connectmq.com:8084/mqtt";
const clientId = "chat-" + Math.random().toString(16).slice(2, 10);
const messagesEl = document.getElementById("messages");
const usersEl = document.getElementById("users");
const users = new Map();
let client = null, me = null, topics = null;
function addMessage(from, text, ts, mine) {
const div = document.createElement("div");
const who = document.createElement("strong");
who.textContent = from + ": ";
if (mine) who.className = "me";
const body = document.createElement("span");
body.textContent = text; // nunca innerHTML: payloads não são confiáveis
const meta = document.createElement("span");
meta.className = "meta";
meta.textContent = " " + new Date(ts).toLocaleTimeString();
div.append(who, body, meta);
messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function renderUsers() {
usersEl.replaceChildren();
for (const [id, u] of users) {
if (u.status !== "online") continue;
const li = document.createElement("li");
li.textContent = u.name + (id === clientId ? " (you)" : "");
usersEl.appendChild(li);
}
}
function presence(status) {
return JSON.stringify({ name: me, status, ts: Date.now() });
}
document.getElementById("joinForm").onsubmit = (e) => {
e.preventDefault();
if (client) return;
me = document.getElementById("name").value.trim();
const room = document.getElementById("room").value.trim().replace(/[#+\/]/g, "");
const base = `${PREFIX}/room/${room}`;
topics = {
messages: `${base}/messages`,
presenceAll: `${base}/presence/+`,
presenceMe: `${base}/presence/${clientId}`
};
client = mqtt.connect(URL, {
clientId,
protocolVersion: 5,
clean: true,
keepalive: 20,
reconnectPeriod: 3000,
will: {
topic: topics.presenceMe,
payload: presence("offline"),
qos: 1,
retain: true
}
// Broker privado: username: "YOUR_USERNAME", password: "YOUR_PASSWORD"
});
client.on("connect", () => {
client.subscribe({
[topics.messages]: { qos: 1, nl: true }, // No Local: ignora minhas próprias mensagens
[topics.presenceAll]: { qos: 1 }
});
client.publish(topics.presenceMe, presence("online"), { qos: 1, retain: true });
addMessage("system", `Joined room "${room}" as ${me}`, Date.now(), false);
});
client.on("message", (topic, payload) => {
const presencePrefix = topics.presenceAll.slice(0, -1);
if (topic.startsWith(presencePrefix) && payload.length === 0) {
users.delete(topic.split("/").pop()); // a presença retida foi apagada
return renderUsers();
}
let data;
try { data = JSON.parse(payload.toString()); } catch { return; }
if (topic === topics.messages) {
addMessage(String(data.from), String(data.text), data.ts, false);
} else if (topic.startsWith(presencePrefix)) {
const id = topic.split("/").pop();
users.set(id, { name: String(data.name), status: data.status });
renderUsers();
}
});
client.on("error", (err) => addMessage("system", "Error: " + err.message, Date.now(), false));
};
document.getElementById("sendForm").onsubmit = (e) => {
e.preventDefault();
const input = document.getElementById("text");
const text = input.value.trim();
if (!client || !client.connected || !text) return;
const msg = { from: me, text, ts: Date.now() };
client.publish(topics.messages, JSON.stringify(msg), { qos: 1 });
addMessage(me, text, msg.ts, true); // renderiza localmente na hora
input.value = "";
};
window.addEventListener("beforeunload", () => {
if (client && client.connected) {
// Saída limpa: apaga minha presença retida em vez de deixar "offline" para trás.
client.publish(topics.presenceMe, "", { qos: 0, retain: true });
client.end(true);
}
});
</script>
</body>
</html>
Como funciona
Presença com retained messages
Quando um usuário entra, a página publica {"status":"online"} no seu próprio topic de presença com a flag retain. O broker guarda a última retained message de cada topic e a entrega a todo novo assinante. Assim, quando um segundo usuário entra e assina presence/+, o broker envia imediatamente uma retained message para cada usuário já online, e a lista de usuários se preenche sem que ninguém precise se anunciar de novo.
Detecção de quedas com Last Will
Fechar a aba normalmente executa o handler beforeunload, que publica um payload retido vazio. Uma retained message vazia diz ao broker para apagar a mensagem armazenada naquele topic, e o usuário desaparece de forma limpa.
Mas abas travam, notebooks entram em suspensão e o Wi-Fi cai. É aí que entra o Last Will. Durante o CONNECT, o cliente registra uma mensagem de will: uma presença "offline", retida. Se o broker deixar de receber notícias do cliente por cerca de uma vez e meia o intervalo de keepalive (aqui, aproximadamente 30 segundos), ele publica o will em nome do cliente. Todos os outros usuários o recebem e a lista é atualizada. Um keepalive curto deixa a presença mais precisa, ao custo de alguns pacotes de ping a mais.
No Local: sem eco das suas próprias mensagens
Sem opções extras, um cliente que publica em um topic que ele mesmo assina recebe de volta a própria mensagem. Em um chat, isso significa que cada linha que você digita aparece duas vezes, ou você precisa deduplicar pelo remetente. O MQTT 5 adiciona a opção de assinatura No Local, exposta no MQTT.js como nl: true. Com ela, o broker deixa de entregar a mensagem à mesma conexão de cliente que a publicou.
A página renderiza a sua mensagem localmente assim que você clica em Send, então a interface parece instantânea mesmo em uma rede lenta. O No Local exige protocolVersion: 5; no MQTT 3.1.1 essa opção não existe, e você teria que filtrar pelo client ID no handler de mensagens.
Renderização segura
Todo payload entra no DOM via textContent. No servidor MQTT público, qualquer pessoa que conheça o seu prefixo pode publicar na sua sala, então trate cada mensagem como entrada hostil. Usar innerHTML aqui transformaria o seu chat em um parquinho de cross-site scripting.
Checklist de testes
- Abra
chat.htmlem duas janelas, entre na mesma sala com nomes diferentes e confirme que os dois nomes aparecem em cada lista de usuários. - Envie uma mensagem de cada janela e confirme que ela aparece exatamente uma vez dos dois lados.
- Feche uma janela normalmente e confirme que o nome desaparece na hora.
- Desconecte a rede em uma máquina e confirme que o usuário aparece como offline após cerca de 30 segundos.
- Acompanhe o tráfego bruto em um terminal:
mosquitto_sub -h broker.connectmq.com -p 1883 -i chat-watch-9c1e -t 'connectmq/demo/chat-k7f3q2/#' -v
Primeiro você verá as mensagens de presença retidas e, depois, as mensagens do chat conforme chegam.
Limitações e como estender o chat
Histórico. Quem entra depois não vê as mensagens anteriores. Para adicionar histórico, rode um pequeno assinante que grave as mensagens em um banco de dados, por exemplo com a abordagem do tutorial de MQTT para SQLite, e exponha um endpoint de leitura que a página chame ao entrar.
Limites de taxa. O servidor MQTT público permite 10 mensagens por segundo por cliente e 64 KB por mensagem. Adicione um controle simples de envio na página se você esperar rajadas, como um texto colado dividido em muitas linhas.
Privacidade. O servidor MQTT público (broker.connectmq.com) pode ser lido por todos e serve para testes e protótipos, não para dados de produção. Para qualquer coisa além de uma demonstração, crie um broker MQTT gerenciado privado na ConnectMQ, use as credenciais geradas e pense bem onde essas credenciais ficam, já que o código do navegador é visível para os usuários. O artigo de boas práticas de segurança MQTT aborda a estratégia de credenciais.
Frameworks. A mesma lógica pode ser levada diretamente para componentes. O tutorial de React com MQTT e o guia de padrões de MQTT com Next.js mostram como gerenciar o ciclo de vida do cliente dentro de um framework, e o cliente MQTT mínimo para navegador explica a conexão WebSocket em mais detalhes. Para outras linguagens, veja o guia de bibliotecas de cliente MQTT.
Hospede o seu chat em um broker ConnectMQ privado
Quando o seu chat for além de uma demonstração, um broker MQTT gerenciado poupa você de operar e aplicar patches no seu próprio servidor. O ConnectMQ suporta MQTT 5 com retained messages, Last Will e WebSocket seguro na porta 8084 desde o início, e o explorador de topics ao vivo do portal mostra os topics de presença e de chat conforme mudam. Comece pelo servidor MQTT público (gratuito) e depois crie o seu próprio broker gratuitamente no plano Serverless, sem cartão de crédito.
Perguntas frequentes
O MQTT é uma boa escolha para um app de chat?
Sim, para entrega em tempo real, presença e clientes leves, principalmente quando o chat convive com dados de IoT. O MQTT não armazena histórico, então você adiciona um assinante que grava em um banco de dados se os usuários precisarem rolar para trás.
O que a opção No Local faz?
No Local é uma opção de assinatura do MQTT 5 que impede o broker de enviar uma mensagem de volta à conexão que a publicou. No MQTT.js, você a ativa com nl: true nas opções de subscribe.
Como o chat sabe que um usuário ficou offline inesperadamente?
O cliente registra uma mensagem de Last Will ao se conectar. Se o broker deixar de receber os pings de keepalive, ele publica esse will, uma presença "offline" retida, para todos os assinantes.
Posso usar este app de chat MQTT com MQTT 3.1.1?
Em grande parte, sim. A presença retida e o Last Will funcionam da mesma forma, mas o No Local é exclusivo do MQTT 5, então você precisa filtrar as suas próprias mensagens pelo ID do remetente no handler de mensagens.



