Tutorials

Build a Real-Time Chat App with MQTT

Build a browser chat app on MQTT: rooms as topics, online presence with retained messages and Last Will, sender identity enforced by ACLs, all in one HTML file.

Updated · 9 min read

Build a Real-Time Chat App with MQTT

MQTT already does the hard part of a chat app: it pushes every message to every subscriber in real time. A chat app on MQTT comes down to three decisions: how rooms map onto topics, how presence works, and how you stop people from posting under someone else's name. This tutorial builds a working multi-room chat in a single HTML file with MQTT.js, connected over secure WebSockets, and then shows how to test and harden it.

Topic design

TopicPayloadQoSRetainedPurpose
testmqtt/chat/<room>/messages/<user>{"text":"…","ts":…}1NoChat messages
testmqtt/chat/<room>/presence/<user>{"status":"online"} or {"status":"offline"}1YesWho is in the room

Two choices in this layout do most of the work:

  • The sender is part of the topic. A client subscribes to testmqtt/chat/<room>/messages/+ and reads the username from the last topic level. With an ACL that lets each user write only under their own name (covered below), the sender can no longer be faked. A "user" field inside the JSON could be.
  • Presence is one retained message per user. Retained messages are delivered right when a client subscribes, so a newcomer sees the full member list immediately, without asking anyone.

Rooms are simply different values of <room>, so you get as many rooms as you want without any broker configuration. The topic design best practices explain why stable, identifier-at-the-end hierarchies like this one scale well.

Presence with retained messages and Last Will

Presence uses two MQTT features together:

  1. After connecting, the client publishes {"status":"online"} retained to its own presence topic.
  2. In the CONNECT packet, the same client registers a Last Will: {"status":"offline"}, also retained, on the same topic.

If the connection ends without a clean MQTT DISCONNECT (tab closed, laptop asleep, network gone), the broker publishes the will. Because it is retained, it replaces the "online" message and every member sees the user go offline. When the user clicks Leave, the client clears its presence on purpose by publishing an empty retained payload, which deletes the retained message.

The complete chat page

Save this as chat.html and open it twice: chat.html?user=alice and chat.html?user=bob. Add &room=yourteam to pick a room. On the shared test endpoint, choose an unusual room name so you are not chatting with strangers.

chat.html
<!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: 15px/1.5 system-ui, sans-serif; max-width: 760px; margin: 1.5rem auto; padding: 0 1rem;
           display: grid; grid-template-columns: 1fr 160px; gap: 1rem; }
    #messages { height: 360px; overflow: auto; border: 1px solid #ccc; padding: .5rem; }
    #people li.offline { opacity: .4; }
    form { grid-column: 1 / -1; display: flex; gap: .5rem; }
    #text { flex: 1; }
  </style>
</head>
<body>
  <div id="messages"></div>
  <ul id="people"></ul>
  <form id="send">
    <input id="text" placeholder="Say something" autocomplete="off" required>
    <button>Send</button>
    <button type="button" id="leave">Leave</button>
  </form>

  <script>
    const clean = (s) => (s || '').replace(/[^a-z0-9_-]/gi, '');   // no / + # in topic levels
    const params = new URLSearchParams(location.search);
    const ROOM = clean(params.get('room')) || 'lobby';
    const USER = clean(params.get('user')) || 'guest-' + Math.random().toString(36).slice(2, 6);
    const BASE = `testmqtt/chat/${ROOM}`;
    const PRESENCE = `${BASE}/presence/${USER}`;

    const client = mqtt.connect('wss://broker.connectmq.com:8084/mqtt', {
      clientId: `chat-${USER}-${Math.random().toString(16).slice(2, 6)}`,
      // username: 'username', password: 'password',
      protocolVersion: 5,
      keepalive: 20,
      will: { topic: PRESENCE, payload: JSON.stringify({ status: 'offline' }), qos: 1, retain: true },
    });

    client.on('connect', () => {
      client.subscribe([`${BASE}/messages/+`, `${BASE}/presence/+`], { qos: 1 });
      client.publish(PRESENCE, JSON.stringify({ status: 'online' }), { qos: 1, retain: true });
    });

    const people = new Map();
    const $ = (id) => document.getElementById(id);

    function renderPeople() {
      $('people').replaceChildren(...[...people].map(([name, status]) => {
        const li = document.createElement('li');
        li.textContent = name + (name === USER ? ' (you)' : '');
        li.className = status;
        return li;
      }));
    }

    function addMessage(from, text, ts) {
      const line = document.createElement('div');
      line.textContent = `${new Date(ts).toLocaleTimeString()}  ${from}: ${text}`;   // textContent, never innerHTML
      $('messages').append(line);
      $('messages').scrollTop = $('messages').scrollHeight;
    }

    client.on('message', (topic, payload) => {
      const [, , , kind, from] = topic.split('/');   // testmqtt/chat/<room>/<kind>/<user>
      if (kind === 'presence' && payload.length === 0) { people.delete(from); renderPeople(); return; }
      let data;
      try { data = JSON.parse(payload.toString()); } catch { return; }   // ignore malformed messages
      if (kind === 'presence') { people.set(from, data.status); renderPeople(); }
      if (kind === 'messages') addMessage(from, String(data.text ?? ''), data.ts ?? Date.now());
    });

    $('send').addEventListener('submit', (e) => {
      e.preventDefault();
      const body = JSON.stringify({ text: $('text').value, ts: Date.now() });
      client.publish(`${BASE}/messages/${USER}`, body, { qos: 1 });
      $('text').value = '';
    });

    $('leave').addEventListener('click', () => {
      // Empty retained payload deletes our presence entry, then disconnect cleanly (no will).
      client.publish(PRESENCE, '', { qos: 1, retain: true }, () => client.end());
    });
  </script>
</body>
</html>

A few details worth noting:

  • textContent, never innerHTML. Chat messages are untrusted input from other users. Rendering them as HTML is a cross-site scripting hole.
  • Room and user names are sanitised so a name containing /, + or # cannot break out of its topic level.
  • Your own messages come back through the broker. That is your delivery confirmation. If you would rather render them locally, MQTT 5 lets you subscribe with { qos: 1, nl: true } (No Local) so the broker does not echo them.
  • Malformed payloads are ignored instead of crashing the handler, because anyone with publish rights can send anything.

If the MQTT.js setup itself is new to you, start with MQTT in the browser with plain JavaScript, which explains the connection options and the debugging workflow.

Test it

Open two tabs (alice and bob) and run through these checks:

  1. Messages flow both ways. Each message appears in both tabs, with the sender taken from the topic.
  2. Late joiners see presence. Open a third tab: the member list fills in immediately from retained messages.
  3. Will fires. Close bob's tab. Alice should see bob greyed out within seconds.
  4. Leave cleans up. Click Leave in a tab: the user disappears from the list instead of showing as offline.

To watch the raw traffic, subscribe to testmqtt/chat/lobby/# in the online MQTT client, or from a terminal:

Watch a room and post as a bot
mosquitto_sub -h broker.connectmq.com -t 'testmqtt/chat/lobby/#' -v

mosquitto_pub -h broker.connectmq.com -q 1 \
  -t testmqtt/chat/lobby/messages/deploy-bot \
  -m "{\"text\":\"Build 412 deployed\",\"ts\":$(date +%s)000}"

The second command shows that any service (CI, monitoring, a backend) can post into a room with a single publish. For more ways to exercise the will, see testing Last Will.

Watch the lobby in the online client

Enforce identity with ACLs

On an open test endpoint, anyone can publish to testmqtt/chat/lobby/messages/alice. In production, give every user their own broker credentials and an ACL that ties topics to the username. With Mosquitto, pattern rules substitute %u with the authenticated username:

acl_file (Mosquitto)
# Everyone can read every room
pattern read testmqtt/chat/+/#

# ...but may only write under their own name
pattern write testmqtt/chat/+/messages/%u
pattern write testmqtt/chat/+/presence/%u

The broker checks the will against the same rules, so a client cannot register a will on someone else's presence topic either. Then use ACL permission tests to verify that a publish to another user's topic is really rejected. With MQTT 3.1.1 such a publish is usually dropped silently, so check that it never arrives rather than expecting an error.

What MQTT does not give you

NeedMQTT aloneTypical solution
Message history and scrollbackNo (only the last retained message per topic)Backend subscriber that writes to a database, plus an HTTP history endpoint
Offline delivery to a user who was away for hoursPersistent sessions queue messages, but within broker limitsDatabase plus "since last seen" fetch on reconnect
Read receipts and typing indicatorsNot built inExtra topics, e.g. .../typing/<user> at QoS 0
Moderation, message deletionNoBackend service with publish rights to a system topic

Persistent sessions also need a broker that keeps session state. The shared test endpoint does not, so test catch-up behaviour on your own instance.

A real-time chat on MQTT is mostly topic design: rooms as topic levels, the sender in the topic, retained presence with a Last Will, and ACLs that keep each user in their own lane. Build it in one HTML file, check every behaviour with two tabs and a subscriber watching #, and add a database only when users need history.

Frequently asked questions

Is MQTT a good protocol for chat?

For live delivery, yes: publish/subscribe maps naturally onto rooms, messages are small, and Last Will gives you presence for free. MQTT does not store chat history, though, so pair it with a database if users need to scroll back or catch up after being offline.

How do I show who is online with MQTT?

Each client publishes a retained 'online' message on its own presence topic and registers a retained 'offline' Last Will on the same topic. New clients get every retained presence message on subscribe, and the broker publishes the will when a client vanishes.

How do I stop users from impersonating each other?

Put the sender's username in the topic, for example chat/<room>/messages/<user>, and use a broker ACL that only lets each user publish under their own name. Receivers then trust the topic, not a name field inside the payload.