DEV Community

Cover image for How I built TestMQTT: a browser MQTT client that can still reach TCP brokers
TestMQTT
TestMQTT

Posted on

How I built TestMQTT: a browser MQTT client that can still reach TCP brokers

Most of the time, testing an MQTT broker is a 30-second job: is it up, do these credentials work, what is this device actually publishing? Yet the usual answer was "install a desktop client" or "remember the mosquitto_sub flags again".

So I built TestMQTT, an MQTT client that runs in a browser tab. You open the page, pick a broker and you're connected, with no account and no install.

This post covers the parts that turned out to be interesting:

  1. Why "MQTT in the browser" is really two problems
  2. Connecting over WebSockets with MQTT.js
  3. Reaching plain TCP brokers with a small bridge built on Server-Sent Events and REST
  4. The payload inspector, topic tree and shareable links
  5. Privacy-friendly usage analytics

The stack

  • Next.js (built with Turbopack): static marketing, learn and tool pages, in 6 languages with localized URLs
  • MQTT.js: the actual MQTT client for ws:// and wss://, loaded only when the client page needs it
  • A small HTTP API on the same origin: the TCP/TLS bridge and the usage log
  • Windows / IIS hosting

Problem 1: browsers don't speak TCP

MQTT brokers usually listen on:

Port Transport
1883 MQTT over TCP
8883 MQTT over TLS
8083 / 80 MQTT over WebSocket
8084 / 443 MQTT over secure WebSocket

A web page can only use the last two. There is no browser API for opening a raw TCP socket, so anything that wants to talk to broker.connectmq.com:1883 from a browser needs help from a server.

This splits TestMQTT into two connection paths:

                       ┌──────────────── ws:// / wss:// ───────────────▶  Broker
 Browser (TestMQTT) ───┤
                       └── HTTPS ──▶ TestMQTT bridge ── TCP / TLS ──────▶  Broker
                          (REST + SSE)
Enter fullscreen mode Exit fullscreen mode

The URL scheme picks the path:

const needsBridge = (url) => /^mqtts?:\/\//i.test(url);
Enter fullscreen mode Exit fullscreen mode

Path 1: WebSockets with MQTT.js

For ws:// and wss://, the browser talks to the broker directly. MQTT.js handles this very well, so this path is mostly configuration:

async function createClient(opts) {
  // lazy-load MQTT.js so the landing and learn pages stay light
  const { default: mqtt } = await import("mqtt");

  return mqtt.connect(opts.url, {
    clientId: opts.clientId,
    username: opts.username || undefined,
    password: opts.password || undefined,
    keepalive: opts.keepalive,
    clean: opts.clean,
    protocolVersion: opts.protocolVersion, // 4 = MQTT 3.1.1, 5 = MQTT 5
    connectTimeout: 10_000,
    reconnectPeriod: 4_000,
    will: opts.will,
  });
}
Enter fullscreen mode Exit fullscreen mode

Most of the work on this path goes into the UI around it: connection states, reconnects and turning MQTT errors into messages a human can understand.

Path 2: a bridge for mqtt:// and mqtts://

The obvious approach: a byte pipe

The simplest bridge is a WebSocket server that opens a TCP socket to the broker and copies bytes in both directions. The browser runs MQTT.js over that WebSocket, and the broker never knows a browser is involved:

// browser WS  <->  raw TCP to the broker
wss.on("connection", (ws, req) => {
  const { host, port } = parseTarget(req.url);
  const tcp = net.createConnection({ host, port });

  ws.on("message", (msg) => tcp.write(msg));
  tcp.on("data", (chunk) => ws.send(chunk, { binary: true }));
  ws.on("close", () => tcp.destroy());
  tcp.on("close", () => ws.close());
});
Enter fullscreen mode Exit fullscreen mode

It's about 30 lines and it works. But a public service has to consider a few more things:

  • It's an open TCP relay. Anyone can make the server connect anywhere and send arbitrary bytes. That's a gift for abusers.
  • The server can't see MQTT. It only sees bytes, so it can't enforce rules like "only MQTT", rate limits or sensible timeouts.
  • WebSocket upgrades need to work through every proxy and hosting layer in front of the API.

What TestMQTT does instead: the server is the MQTT client

In TestMQTT the server runs the MQTT session itself, and the browser controls it with plain HTTP:

Step Request
Open a session POST /api/bridge with the connection options, returns { id }
Receive events GET /api/bridge/{id} as an EventSource (Server-Sent Events)
Subscribe / publish / unsubscribe / disconnect POST /api/bridge/{id} with { op: "…" }

The browser side is a small class that exposes the same interface as an MQTT.js client (on, subscribeAsync, publishAsync, end). The rest of the app doesn't know which path it's using. Slightly simplified:

class BridgeClient {
  constructor(options) {
    this.listeners = new Map();
    this.connected = false;
    this.open(options);
  }

  async open(options) {
    const res = await fetch(`/api/bridge`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(options), // protocol: "tcp" | "tls", host, port, clientId, will, validateCert…
    });
    const { id, error } = await res.json();
    if (!res.ok) return this.fail(error ?? "Bridge unavailable");
    this.id = id;

    this.source = new EventSource(`/api/bridge/${id}`);
    this.source.addEventListener("status", (e) => {
      const { status, error } = JSON.parse(e.data);
      if (status === "connected") { this.connected = true; this.emit("connect"); }
      else if (status === "reconnecting") this.emit("reconnect");
      else if (status === "disconnected") { this.connected = false; this.emit("close"); }
      else if (status === "error") this.emit("error", new Error(error));
    });
    // "message" events are handled the same way
  }

  op(body) {
    return fetch(`/api/bridge/${this.id}`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
      keepalive: body.op === "disconnect", // still sent if the tab is closing
    }).then((r) => r.json());
  }

  subscribeAsync(topic, { qos }) {
    return this.op({ op: "subscribe", topic, qos }).then((r) => r.granted);
  }

  publishAsync(topic, payload, { qos, retain }) {
    // payloads travel as base64 so binary data survives JSON
    return this.op({ op: "publish", topic, payload: toBase64(payload), qos, retain });
  }

  end() {
    this.op({ op: "disconnect" }).catch(() => {});
    this.source?.close();
  }
}
Enter fullscreen mode Exit fullscreen mode

Why this design worked well:

  • SSE is just HTTP. It works through proxies and hosting setups that make WebSocket upgrades painful, and EventSource reconnects on its own.
  • The server understands MQTT. It only opens MQTT sessions, so it can apply limits, timeouts and abuse protection per session instead of relaying arbitrary bytes.
  • The UI didn't change. Because BridgeClient matches the MQTT.js interface, the topic tree, inspector and publish form work the same for both paths.
  • keepalive: true on disconnect. If the user closes the tab, the browser still sends the disconnect request, so the server can close the broker session immediately instead of waiting for a timeout.

Being honest about the trade-off

With the bridge, the username, password and messages pass through our server. That's unavoidable, because something has to hold the TCP connection. TestMQTT keeps them only in memory while the connection is open. They are never written to disk, a database or log files. The privacy policy says this in plain words, and WebSocket connections skip the server entirely.

If you don't want any server in the middle, use wss://.

The payload inspector

MQTT payloads are just bytes. The inspector guesses what they are and renders each one accordingly. A simplified version:

function inspect(bytes) {
  const text = tryDecodeUtf8(bytes);          // null if not valid UTF-8
  if (text === null) return { kind: "binary", hex: toHex(bytes) };

  const trimmed = text.trim();
  if (trimmed !== "" && !isNaN(Number(trimmed))) return { kind: "number", value: Number(trimmed) };

  if (/^[\[{]/.test(trimmed)) {
    try { return { kind: "json", value: JSON.parse(trimmed) }; } catch {}
  }
  return { kind: "text", value: text };
}
Enter fullscreen mode Exit fullscreen mode

Numeric topics keep a short history and get a sparkline, so you can see whether a sensor is drifting at a glance.

The live topic tree

Every incoming message is split on / and inserted into a tree. Each node keeps its last payload and a message count. With a # subscription on a busy broker the tree changes constantly, so message rates are computed on a timer instead of per message.

Shareable links

Debugging is often a two-person job: "Can you see what I'm publishing?" So the client state can be encoded in the URL:

https://testmqtt.com/client?broker=emqx&topic=demo/sensors/%23
Enter fullscreen mode Exit fullscreen mode

Opening that link connects to the same public broker preset and subscribes to the same topic.

Analytics without storing secrets

I wanted to know which brokers people try, which ones fail to connect and which tools get used. I didn't want to store anyone's credentials or messages. TestMQTT has a small first-party log endpoint:

  • Random IDs. The session and visitor IDs are random values (crypto.getRandomValues), not fingerprints.
  • Bot filtering. A user-agent regex skips crawlers, headless browsers and uptime monitors, so the numbers mean something.
  • navigator.sendBeacon. The last events still arrive when a tab closes.
  • Only events. It records page views and connection events (broker, success or failure, time to connect), never usernames, passwords or payloads.

Try it

  • Client: testmqtt.com/client
  • Tools: code generator (Python, JS, Node.js, ESP32, Go, mosquitto CLI), mosquitto.conf generator, reason code lookup, wildcard tester

Disclosure: I also have collaboration with ConnectMQ, a managed MQTT broker, and its public broker is one of TestMQTT's presets. TestMQTT works with any broker and is free.

I'd love feedback, especially on the bridge design. How would you have handled TCP from the browser?

Top comments (0)