IoT-Tutorials

Echtzeit-MQTT-Chat-App mit JavaScript bauen: Präsenz und No Local

Bauen Sie eine Browser-Chat-App auf MQTT über sicheren WebSocket – mit Räumen, retained Präsenz, die dank Last Will auch Abstürze übersteht, und der MQTT-5-Option No Local, betrieben auf broker.connectmq.com.

Zwei Browserfenster mit einer MQTT-Chat-App, einer Liste der Online-Nutzer und Live-Nachrichten
Probieren Sie es auf Ihrem eigenen MQTT-Broker ausHolen Sie sich im Free-Tarif einen privaten, verwalteten MQTT-Broker und starten Sie mit fertigen Codebeispielen.
Kostenlos testen Codebeispiele Keine Kreditkarte erforderlich

Eine MQTT Chat App ist einer der besten Wege, Publish/Subscribe praxisnah zu lernen: Räume entsprechen Topics, Präsenz entspricht Retained Messages, und Absturzerkennung gibt es mit Last Will gratis dazu. In diesem Tutorial bauen Sie einen vollständigen Browser-Chat in einer einzigen HTML-Datei mit MQTT.js über sicheren WebSocket, verbunden mit broker.connectmq.com. Dabei nutzen Sie die MQTT-5-Option No Local, damit Ihre eigenen Nachrichten nicht als Echo zu Ihnen zurückkommen.

Warum MQTT gut für Chats geeignet ist

Chat sieht aus wie ein Messaging-Problem, und MQTT ist ein Messaging-Protokoll – die Passung ist naheliegend. Jedes Konzept einer Chat-App hat eine direkte Entsprechung in MQTT:

Chat-Funktion MQTT-Mechanismus
Chatraum Ein Topic, zum Beispiel .../room/general/messages
Einem Raum beitreten Dieses Topic abonnieren
Eine Nachricht senden Auf dieses Topic publizieren
Liste „Wer ist online" Retained Message pro Nutzer auf einem Präsenz-Topic
Geschlossenen Tab oder Netzverlust erkennen Last Will and Testament, vom Broker publiziert
Die eigene Nachricht nicht doppelt sehen Subscription-Option No Local von MQTT 5

Was MQTT Ihnen nicht liefert, ist ein Nachrichtenverlauf. Der Broker stellt Nachrichten an alle zu, die gerade abonniert haben; er ist keine Datenbank. Das ist ein bewusster Kompromiss, und wir besprechen am Ende, wie Sie einen Verlauf ergänzen.

Sind Ihnen einige dieser Begriffe neu, sind unsere Übersicht darüber, was ein MQTT-Broker tut, und der Leitfaden zu MQTT-5-Features gute Einstiege.

Topic-Design für die MQTT Chat App

Gutes Topic-Design hält den Code einfach. Jedes Topic beginnt mit einem zufälligen Präfix, damit Ihr Test auf dem öffentlichen MQTT-Broker nicht mit anderen kollidiert, gefolgt vom Raumnamen:

connectmq/demo/<prefix>/room/<room>/messages             Chat-Nachrichten (nicht retained)
connectmq/demo/<prefix>/room/<room>/presence/<clientId>  Präsenz (retained JSON)

Ein Client abonniert .../messages und .../presence/+. Die einstufige Wildcard + passt auf das Präsenz-Topic jedes Nutzers, und weil Präsenznachrichten retained sind, erhält ein Neuankömmling sofort den aktuellen Status aller im Raum. Das bleibt innerhalb der Limits des öffentlichen MQTT-Brokers von 8 Topic-Ebenen und 10 Subscriptions pro Client.

Die vollständige Chat-Seite

Speichern Sie dies als chat.html. Öffnen Sie die Datei in zwei Browserfenstern (oder schicken Sie sie einem Freund), geben Sie unterschiedliche Namen und denselben Raum ein und legen Sie los.

<!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>
    // Durch eine eigene Zufallszeichenfolge ersetzen, die alle teilen, die den Chat sehen sollen.
    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;                       // nie innerHTML: Payloads sind nicht vertrauenswürdig
      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
        }
        // Privater Broker: username: "YOUR_USERNAME", password: "YOUR_PASSWORD"
      });

      client.on("connect", () => {
        client.subscribe({
          [topics.messages]: { qos: 1, nl: true },     // No Local: eigene Nachrichten überspringen
          [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 Präsenz wurde gelöscht
          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);             // sofort lokal anzeigen
      input.value = "";
    };

    window.addEventListener("beforeunload", () => {
      if (client && client.connected) {
        // Sauberes Verlassen: eigene retained Präsenz löschen, statt "offline" zu hinterlassen.
        client.publish(topics.presenceMe, "", { qos: 0, retain: true });
        client.end(true);
      }
    });
  </script>
</body>
</html>

Wie es funktioniert

Präsenz mit Retained Messages

Tritt ein Nutzer bei, publiziert die Seite {"status":"online"} mit Retain-Flag auf ihr eigenes Präsenz-Topic. Der Broker speichert pro Topic die letzte Retained Message und übergibt sie jedem neuen Subscriber. Tritt also ein zweiter Nutzer bei und abonniert presence/+, sendet der Broker sofort eine Retained Message pro bereits anwesendem Nutzer, und die Nutzerliste füllt sich, ohne dass sich jemand erneut ankündigen muss.

Absturzerkennung mit Last Will

Wird der Tab normal geschlossen, läuft der beforeunload-Handler und publiziert einen leeren Retained-Payload. Eine leere Retained Message weist den Broker an, die gespeicherte Nachricht für dieses Topic zu löschen – der Nutzer verschwindet sauber.

Doch Tabs stürzen ab, Laptops gehen in den Ruhezustand und WLAN bricht weg. Hier kommt der Last Will ins Spiel. Beim CONNECT registriert der Client eine Will-Nachricht: eine retained „offline"-Präsenz. Hört der Broker etwa das Anderthalbfache des Keepalive-Intervalls (hier rund 30 Sekunden) nichts vom Client, publiziert er den Will in dessen Namen. Alle anderen Nutzer erhalten ihn, und die Liste aktualisiert sich. Ein kurzes Keepalive macht die Präsenz genauer, kostet aber ein paar Ping-Pakete mehr.

No Local: kein Echo der eigenen Nachrichten

Ohne zusätzliche Optionen erhält ein Client, der auf ein Topic publiziert, das er abonniert hat, seine eigene Nachricht zurück. In einem Chat heißt das: Jede Zeile, die Sie tippen, erscheint doppelt, oder Sie müssen nach Absender deduplizieren. MQTT 5 führt die Subscription-Option No Local ein, in MQTT.js als nl: true verfügbar. Der Broker stellt eine Nachricht dann nicht an dieselbe Client-Verbindung zu, die sie publiziert hat.

Die Seite zeigt Ihre Nachricht lokal an, sobald Sie auf Send drücken, sodass sich die Oberfläche auch in einem langsamen Netzwerk sofort anfühlt. No Local erfordert protocolVersion: 5; unter MQTT 3.1.1 gibt es die Option nicht, und Sie würden stattdessen im Message-Handler nach Client ID filtern.

Sicheres Rendering

Jeder Payload gelangt per textContent ins DOM. Auf einem öffentlichen MQTT-Broker kann jeder, der Ihr Präfix kennt, in Ihren Raum publizieren, behandeln Sie also jede Nachricht als feindliche Eingabe. Mit innerHTML würde Ihr Chat zum Spielplatz für Cross-Site-Scripting.

Test-Checkliste

  1. Öffnen Sie chat.html in zwei Fenstern, treten Sie mit unterschiedlichen Namen demselben Raum bei und prüfen Sie, dass beide Namen in jeder Nutzerliste erscheinen.
  2. Senden Sie aus jedem Fenster eine Nachricht und prüfen Sie, dass sie auf beiden Seiten genau einmal erscheint.
  3. Schließen Sie ein Fenster normal und prüfen Sie, dass der Name sofort verschwindet.
  4. Trennen Sie auf einem Rechner das Netzwerk und prüfen Sie, dass der Nutzer nach etwa 30 Sekunden als offline erscheint.
  5. Beobachten Sie den rohen Datenverkehr in einem Terminal:
mosquitto_sub -h broker.connectmq.com -p 1883 -i chat-watch-9c1e -t 'connectmq/demo/chat-k7f3q2/#' -v

Sie sehen zuerst die retained Präsenznachrichten und dann die Chat-Nachrichten, sobald sie eintreffen.

Grenzen und Erweiterungsmöglichkeiten

Verlauf. Wer später beitritt, sieht frühere Nachrichten nicht. Für einen Verlauf betreiben Sie einen kleinen Subscriber, der Nachrichten in einer Datenbank speichert, zum Beispiel mit dem Ansatz aus dem Tutorial zu MQTT und SQLite, und stellen einen Lese-Endpunkt bereit, den die Seite beim Beitritt aufruft.

Ratenlimits. Der öffentliche MQTT-Broker erlaubt 10 Nachrichten pro Sekunde und Client sowie 64 KB pro Nachricht. Ergänzen Sie in der Seite eine einfache Sendedrosselung, wenn Sie mit Spitzen rechnen, etwa eingefügtem Text, der in viele Zeilen aufgeteilt wird.

Privatsphäre. Der öffentliche MQTT-Broker unter broker.connectmq.com ist für Tests und Prototypen gedacht und für alle lesbar. Für alles, was über eine Demo hinausgeht, erstellen Sie einen privaten ConnectMQ-Broker, verwenden dessen generierte Zugangsdaten und überlegen, wo diese liegen, da Browser-Code für seine Nutzer sichtbar ist. Der Artikel zu MQTT-Sicherheit: Best Practices behandelt Strategien für Zugangsdaten.

Frameworks. Dieselbe Logik lässt sich direkt in Komponenten übertragen. Das React-MQTT-Tutorial und der Leitfaden zu Next.js-MQTT-Mustern zeigen, wie Sie den Lebenszyklus des Clients in einem Framework verwalten, und der minimale MQTT-Client im Browser erklärt die WebSocket-Verbindung ausführlicher. Für andere Sprachen siehe den Leitfaden zu MQTT-Client-Bibliotheken.

Betreiben Sie Ihren Chat auf einem privaten ConnectMQ-Broker

Wächst Ihr Chat über eine Demo hinaus, erspart Ihnen ein verwalteter MQTT-Broker den Betrieb und das Patchen eines eigenen Servers. ConnectMQ unterstützt MQTT 5 mit Retained Messages, Last Will und sicherem WebSocket auf Port 8084 direkt ab Werk, und der Live-Topic-Explorer im Portal zeigt Präsenz- und Chat-Topics, während sie sich ändern. Beginnen Sie mit dem kostenlosen öffentlichen MQTT-Broker und erstellen Sie dann kostenlos Ihren eigenen Broker im Serverless-Plan, ohne Kreditkarte.

Häufig gestellte Fragen

Ist MQTT eine gute Wahl für eine Chat-App?

Ja, für Echtzeitzustellung, Präsenz und schlanke Clients – besonders, wenn der Chat neben IoT-Daten existiert, etwa als Schichtübergabe- oder Instandhaltungskanal neben den Maschinendaten einer Fertigungslinie. MQTT speichert keinen Verlauf, daher ergänzen Sie einen Subscriber, der in eine Datenbank schreibt, wenn Nutzer zurückblättern sollen.

Was bewirkt die Option No Local?

No Local ist eine Subscription-Option von MQTT 5, die verhindert, dass der Broker eine Nachricht an die Verbindung zurücksendet, die sie publiziert hat. In MQTT.js aktivieren Sie sie mit nl: true in den Subscribe-Optionen.

Woher weiß der Chat, dass ein Nutzer unerwartet offline gegangen ist?

Der Client registriert beim Verbinden eine Last-Will-Nachricht. Bleiben die Keepalive-Pings aus, publiziert der Broker diesen Will – eine retained „offline"-Präsenz – an alle Subscriber.

Kann ich diese MQTT Chat App mit MQTT 3.1.1 verwenden?

Größtenteils ja. Retained Präsenz und Last Will funktionieren genauso, aber No Local gibt es nur in MQTT 5. Sie müssen Ihre eigenen Nachrichten daher im Message-Handler anhand der Absender-ID herausfiltern.

Verwandte Artikel

Die besten MQTT-CLI-Tools zum Testen, Skripten und Debuggen

Ein praxisnaher Vergleich von mosquitto_pub/sub, MQTTX CLI, HiveMQ MQTT CLI, der MQTT.js-CLI, curl und mqttui – mit demselben Test gegen broker.connectmq.com, damit Sie das richtige Tool zum Skripten und Debuggen wählen.

8 Min. Lesezeit

Alle Artikel →

Ihr privater MQTT-Broker ist nur wenige Klicks entfernt

Konto anlegen, Broker-Zugangsdaten erhalten und das erste Gerät verbinden — ohne Server, die installiert, gepatcht oder skaliert werden müssen.