Tutoriales de IoT

Crea una app de chat MQTT en tiempo real con JavaScript, presencia y No Local

Crea una app de chat en el navegador sobre MQTT con WebSocket seguro, con salas, una presencia retenida que sobrevive a los cierres inesperados gracias al Last Will y la opción No Local de MQTT 5, funcionando en broker.connectmq.com.

Dos ventanas del navegador ejecutando una app de chat MQTT con una lista de usuarios conectados y mensajes en vivo
Pruébalo en tu propio broker MQTTConsigue un broker MQTT privado y gestionado con el plan Free y empieza con ejemplos de código listos para usar.
Pruébalo gratis Ejemplos de código Sin tarjeta

Una app de chat MQTT es una de las mejores formas de aprender de verdad el modelo publicación/suscripción: las salas corresponden a topics, la presencia a retained messages y la detección de caídas viene gratis con el Last Will. En este tutorial construirás un chat completo para el navegador en un solo archivo HTML usando MQTT.js sobre WebSocket seguro, conectado a broker.connectmq.com. Por el camino usarás la opción No Local de MQTT 5 para que tus propios mensajes no te lleguen de vuelta.

Por qué MQTT funciona bien para un chat

Un chat parece un problema de mensajería, y MQTT es un protocolo de mensajería, así que encajan de forma natural. Cada concepto de una app de chat tiene un equivalente directo en MQTT:

Función del chat Mecanismo MQTT
Sala de chat Un topic, por ejemplo .../room/general/messages
Entrar en una sala Suscribirse a ese topic
Enviar un mensaje Publicar en ese topic
Lista de "quién está conectado" Un retained message por usuario en un topic de presencia
Detectar una pestaña cerrada o una red perdida Last Will and Testament, publicado por el broker
No ver tu propio mensaje dos veces Opción de suscripción No Local de MQTT 5

Lo que MQTT no te da es un historial de mensajes. El broker entrega los mensajes a quien esté suscrito en ese momento; no es una base de datos. Es un compromiso deliberado, y al final veremos cómo añadir historial.

Si alguno de estos términos es nuevo para ti, nuestra introducción a qué hace un broker MQTT y la guía de funciones de MQTT 5 son buenos puntos de partida.

Diseño de topics para la app de chat MQTT

Un buen diseño de topics mantiene el código sencillo. Cada topic empieza con un prefijo aleatorio para que tu prueba no choque con otras personas en el servidor MQTT público gratuito, seguido del nombre de la sala:

connectmq/demo/<prefix>/room/<room>/messages             mensajes de chat (no retenidos)
connectmq/demo/<prefix>/room/<room>/presence/<clientId>  presencia (JSON retenido)

Un cliente se suscribe a .../messages y a .../presence/+. El wildcard de un nivel + coincide con el topic de presencia de cada usuario y, como los mensajes de presencia son retenidos, quien llega recibe de inmediato el estado actual de todos los que están en la sala. Así se respetan los límites del servidor público de 8 niveles de topic y 10 suscripciones por cliente.

La página de chat completa

Guarda esto como chat.html. Ábrelo en dos ventanas del navegador (o envíale el archivo a un amigo), introduce nombres distintos y la misma sala, y empieza a chatear.

<!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>
    // Cámbialo por tu propia cadena aleatoria, compartida por todos los que deban ver el 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: los payloads no son fiables
      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: omitir mis propios mensajes
          [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());       // se borró la presencia retenida
          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);             // mostrar localmente al instante
      input.value = "";
    };

    window.addEventListener("beforeunload", () => {
      if (client && client.connected) {
        // Salida limpia: borrar mi presencia retenida en lugar de dejar "offline".
        client.publish(topics.presenceMe, "", { qos: 0, retain: true });
        client.end(true);
      }
    });
  </script>
</body>
</html>

Cómo funciona

Presencia con retained messages

Cuando un usuario entra, la página publica {"status":"online"} en su propio topic de presencia con el flag retain. El broker guarda el último retained message de cada topic y se lo entrega a cada nuevo suscriptor. Así, cuando un segundo usuario entra y se suscribe a presence/+, el broker le envía de inmediato un retained message por cada usuario ya conectado, y la lista de usuarios se rellena sin que nadie tenga que volver a anunciarse.

Detección de caídas con Last Will

Cerrar la pestaña con normalidad ejecuta el handler beforeunload, que publica un payload retenido vacío. Un retained message vacío indica al broker que borre el mensaje guardado para ese topic, así que el usuario desaparece limpiamente.

Pero las pestañas fallan, los portátiles se suspenden y el wifi se cae. Ahí entra el Last Will. Durante el CONNECT, el cliente registra un mensaje de testamento: una presencia "offline", retenida. Si el broker deja de tener noticias del cliente durante aproximadamente vez y media el intervalo de keepalive (aquí unos 30 segundos), publica el testamento en nombre del cliente. Todos los demás usuarios lo reciben y la lista se actualiza. Un keepalive corto hace que la presencia sea más precisa a cambio de unos pocos paquetes de ping más.

No Local: sin eco de tus propios mensajes

Sin opciones adicionales, un cliente que publica en un topic al que está suscrito recibe de vuelta su propio mensaje. En un chat eso significa que cada línea que escribes aparece dos veces, o que tienes que eliminar duplicados por remitente. MQTT 5 añade la opción de suscripción No Local, que MQTT.js expone como nl: true. Así el broker no entrega un mensaje a la misma conexión de cliente que lo publicó.

La página muestra tu mensaje localmente en cuanto pulsas Send, así que la interfaz parece instantánea incluso en una red lenta. No Local requiere protocolVersion: 5; en MQTT 3.1.1 la opción no existe y tendrías que filtrar por client ID en el handler de mensajes.

Renderizado seguro

Cada payload entra en el DOM con textContent. En un servidor público, cualquiera que conozca tu prefijo puede publicar en tu sala, así que trata cada mensaje como una entrada hostil. Usar innerHTML aquí convertiría tu chat en un parque de juegos para el cross-site scripting.

Lista de comprobación de pruebas

  1. Abre chat.html en dos ventanas, entra en la misma sala con nombres distintos y confirma que ambos nombres aparecen en cada lista de usuarios.
  2. Envía un mensaje desde cada ventana y confirma que aparece exactamente una vez en ambos lados.
  3. Cierra una ventana con normalidad y confirma que el nombre desaparece de inmediato.
  4. Desconecta la red en una máquina y confirma que su usuario aparece como offline tras unos 30 segundos.
  5. Observa el tráfico en bruto desde un terminal:
mosquitto_sub -h broker.connectmq.com -p 1883 -i chat-watch-9c1e -t 'connectmq/demo/chat-k7f3q2/#' -v

Verás primero los mensajes de presencia retenidos y después los mensajes de chat a medida que llegan.

Limitaciones y cómo ampliar el chat

Historial. Quienes llegan tarde no ven los mensajes anteriores. Para añadir historial, ejecuta un pequeño suscriptor que guarde los mensajes en una base de datos, por ejemplo con el enfoque del tutorial de MQTT a SQLite, y expón un endpoint de lectura al que la página llame al entrar.

Límites de frecuencia. El servidor público permite 10 mensajes por segundo por cliente y 64 KB por mensaje. Añade un limitador de envío sencillo en la página si esperas ráfagas, como un texto pegado que se divide en muchas líneas.

Privacidad. El área pública de broker.connectmq.com la puede leer cualquiera. Para cualquier cosa que vaya más allá de una demo, crea un broker privado de ConnectMQ, usa sus credenciales generadas y piensa dónde viven esas credenciales, ya que el código del navegador es visible para sus usuarios. El artículo de buenas prácticas de seguridad en MQTT cubre la estrategia de credenciales.

Frameworks. La misma lógica se traslada directamente a componentes. El tutorial de React con MQTT y la guía de patrones de MQTT con Next.js muestran cómo gestionar el ciclo de vida del cliente dentro de un framework, y el cliente MQTT mínimo para el navegador explica la conexión WebSocket con más detalle. Para otros lenguajes, consulta la guía de librerías cliente MQTT.

Aloja tu chat en un broker privado de ConnectMQ

Cuando tu chat vaya más allá de una demo, un broker MQTT gestionado te ahorra ejecutar y parchear tu propio servidor. ConnectMQ admite de serie MQTT 5 con retained messages, Last Will y WebSocket seguro en el puerto 8084, y el explorador de topics en vivo de su portal muestra los topics de presencia y de chat a medida que cambian. Empieza en el servidor MQTT público gratuito, solo para pruebas, y después crea tu propio broker gratis en el plan Serverless sin tarjeta de crédito.

Preguntas frecuentes

¿Es MQTT una buena opción para una app de chat?

Sí para la entrega en tiempo real, la presencia y los clientes ligeros, sobre todo cuando el chat convive con datos IoT. MQTT no guarda historial, así que añades un suscriptor que escriba en una base de datos si los usuarios necesitan desplazarse hacia atrás.

¿Qué hace la opción No Local?

No Local es una opción de suscripción de MQTT 5 que impide que el broker devuelva un mensaje a la conexión que lo publicó. En MQTT.js se activa con nl: true en las opciones de suscripción.

¿Cómo sabe el chat que un usuario se ha desconectado de forma inesperada?

El cliente registra un mensaje Last Will al conectarse. Si el broker deja de recibir los pings de keepalive, publica ese testamento, una presencia "offline" retenida, para todos los suscritos.

¿Puedo usar esta app de chat MQTT con MQTT 3.1.1?

En su mayor parte, sí. La presencia retenida y el Last Will funcionan igual, pero No Local es exclusivo de MQTT 5, así que debes filtrar tus propios mensajes por ID de remitente en el handler de mensajes.

Artículos relacionados

Las mejores herramientas CLI de MQTT para pruebas, scripts y depuración

Una comparativa práctica de mosquitto_pub/sub, MQTTX CLI, HiveMQ MQTT CLI, la CLI de MQTT.js, curl y mqttui, con la misma prueba ejecutada contra broker.connectmq.com para que elijas la herramienta adecuada para scripts y depuración.

9 min de lectura

Todos los artículos →

Tu broker MQTT privado, a unos pocos clics

Crea una cuenta, obtén las credenciales de tu broker y conecta tu primer dispositivo, sin servidores que instalar, parchear ni escalar.