An MQTT chat app is one of the best ways to learn publish/subscribe for real: rooms map to topics, presence maps to retained messages, and crash detection comes free with Last Will. In this tutorial you will build a complete browser chat in a single HTML file using MQTT.js over secure WebSocket, connected to broker.connectmq.com. Along the way you will use the MQTT 5 No Local option so your own messages are not echoed back to you.
Why MQTT Works Well for Chat
Chat looks like a messaging problem, and MQTT is a messaging protocol, so the fit is natural. Each concept in a chat app has a direct MQTT equivalent:
| Chat feature | MQTT mechanism |
|---|---|
| Chat room | A topic, for example .../room/general/messages |
| Joining a room | Subscribing to that topic |
| Sending a message | Publishing to that topic |
| "Who is online" list | Retained message per user on a presence topic |
| Detecting a closed tab or lost network | Last Will and Testament, published by the broker |
| Not seeing your own message twice | MQTT 5 No Local subscription option |
What MQTT does not give you is message history. The broker delivers messages to whoever is subscribed right now; it is not a database. That is a deliberate trade-off, and we will discuss how to add history at the end.
If any of these terms are new, our overview of what an MQTT broker does and the MQTT 5 features guide are good primers.
Topic Design for the MQTT Chat App
Good topic design keeps the code simple. Every topic starts with a random prefix so your test does not collide with other people on the public broker, followed by the room name:
connectmq/demo/<prefix>/room/<room>/messages chat messages (not retained)
connectmq/demo/<prefix>/room/<room>/presence/<clientId> presence (retained JSON)
A client subscribes to .../messages and to .../presence/+. The single-level wildcard + matches every user's presence topic, and because presence messages are retained, a newcomer immediately receives the current status of everyone in the room. That stays within the public broker's limits of 8 topic levels and 10 subscriptions per client.
The Complete Chat Page
Save this as chat.html. Open it in two browser windows (or send the file to a friend), enter different names and the same room, and start chatting.
<!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>
// Change this to your own random string, shared by everyone who should see the 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; // never innerHTML: payloads are untrusted
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
}
// Private broker: username: "YOUR_USERNAME", password: "YOUR_PASSWORD"
});
client.on("connect", () => {
client.subscribe({
[topics.messages]: { qos: 1, nl: true }, // No Local: skip my own messages
[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()); // retained presence was cleared
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); // render locally right away
input.value = "";
};
window.addEventListener("beforeunload", () => {
if (client && client.connected) {
// Clean exit: clear my retained presence instead of leaving "offline" behind.
client.publish(topics.presenceMe, "", { qos: 0, retain: true });
client.end(true);
}
});
</script>
</body>
</html>
How It Works
Presence with retained messages
When a user joins, the page publishes {"status":"online"} to its own presence topic with the retain flag. The broker stores the last retained message per topic and hands it to every new subscriber. So when a second user joins and subscribes to presence/+, the broker immediately sends one retained message per user already online, and the user list fills in without anyone having to announce themselves again.
Crash detection with Last Will
Closing the tab normally runs the beforeunload handler, which publishes an empty retained payload. An empty retained message tells the broker to delete the stored message for that topic, so the user disappears cleanly.
But tabs crash, laptops sleep and Wi-Fi drops. That is where the Last Will comes in. During CONNECT the client registers a will message: an "offline" presence, retained. If the broker stops hearing from the client for about one and a half times the keepalive interval (here roughly 30 seconds), it publishes the will on the client's behalf. Every other user receives it and the list updates. A short keepalive makes presence more accurate at the cost of a few more ping packets.
No Local: no echo of your own messages
Without extra options, a client that publishes to a topic it is subscribed to receives its own message back. In a chat that means every line you type appears twice, or you have to deduplicate by sender. MQTT 5 adds the No Local subscription option, exposed in MQTT.js as nl: true. The broker then skips delivering a message to the same client connection that published it.
The page renders your message locally as soon as you press Send, so the UI feels instant even on a slow network. No Local requires protocolVersion: 5; on MQTT 3.1.1 the option does not exist and you would filter by client ID in the message handler instead.
Safe rendering
Every payload goes into the DOM with textContent. On a public broker anyone who knows your prefix can publish to your room, so treat each message as hostile input. Using innerHTML here would turn your chat into a cross-site scripting playground.
Testing Checklist
- Open
chat.htmlin two windows, join the same room with different names and confirm both names appear in each user list. - Send a message from each window and confirm it appears exactly once on both sides.
- Close one window normally and confirm the name disappears immediately.
- Disconnect the network on one machine and confirm its user shows offline after about 30 seconds.
- Watch the raw traffic from a terminal:
mosquitto_sub -h broker.connectmq.com -p 1883 -i chat-watch-9c1e -t 'connectmq/demo/chat-k7f3q2/#' -v
You will see the retained presence messages first, then chat messages as they arrive.
Limitations and How to Extend the Chat
History. Late joiners do not see earlier messages. To add history, run a small subscriber that stores messages in a database, for example with the approach in the MQTT to SQLite tutorial, and expose a read endpoint that the page calls on join.
Rate limits. The public broker allows 10 messages per second per client and 64 KB per message. Add a simple send throttle in the page if you expect bursts, such as pasted text split into many lines.
Privacy. The public area of broker.connectmq.com is readable by everyone. For anything beyond a demo, create a private ConnectMQ broker, use its generated credentials, and consider where those credentials live, since browser code is visible to its users. The MQTT security best practices article covers credential strategy.
Frameworks. The same logic ports directly to components. The React MQTT tutorial and the Next.js MQTT patterns guide show how to manage the client lifecycle inside a framework, and the minimal MQTT browser client explains the WebSocket connection in more detail. For other languages, see the MQTT client libraries guide.
Host Your Chat on a Private ConnectMQ Broker
When your chat moves beyond a demo, a hosted MQTT broker saves you from running and patching your own server. ConnectMQ supports MQTT 5 with retained messages, Last Will and secure WebSocket on port 8084 out of the box, and its portal's live topic explorer shows presence and chat topics as they change. Start on the free public MQTT broker, then create your own broker for free on the Serverless plan with no credit card.
Frequently Asked Questions
Is MQTT a good choice for a chat app?
Yes for real-time delivery, presence and lightweight clients, especially when the chat lives alongside IoT data. MQTT does not store history, so you add a subscriber that writes to a database if users need to scroll back.
What does the No Local option do?
No Local is an MQTT 5 subscription option that stops the broker from sending a message back to the connection that published it. In MQTT.js you enable it with nl: true in the subscribe options.
How does the chat know a user went offline unexpectedly?
The client registers a Last Will message when it connects. If the broker misses keepalive pings, it publishes that will, a retained "offline" presence, to everyone subscribed.
Can I use this MQTT chat app with MQTT 3.1.1?
Mostly yes. Retained presence and Last Will work the same way, but No Local is MQTT 5 only, so you must filter out your own messages by sender ID in the message handler.



