Tutorials

MQTT from Edge Functions (Cloudflare Workers, Vercel)

Publish MQTT messages from Cloudflare Workers and edge functions over secure WebSockets: a dependency-free client, waitUntil, platform limits and testing.

Updated · 9 min read

MQTT from Edge Functions (Cloudflare Workers, Vercel)

Edge functions have no raw TCP sockets in most runtimes and no Node.js network stack, so the usual MQTT client libraries do not run there. What they do have is the WebSocket API — and MQTT runs perfectly well over WebSockets. A Cloudflare Worker can open wss://broker.connectmq.com:8084/mqtt, send a CONNECT, publish and close, all inside one request. On Vercel, the Node.js runtime runs a normal MQTT client, so the edge trick is only needed for edge-only runtimes.

This tutorial builds a dependency-free MQTT publisher for Cloudflare Workers, explains ctx.waitUntil, lists the platform limits that matter and shows how to verify every message in your browser.

What edge runtimes can and cannot do

NeedCloudflare WorkersVercel (Node.js runtime)Vercel Edge runtime
Outbound WebSocketYes, via fetch with Upgrade: websocketYes, via a libraryLimited — check current docs
Outbound TCP / TLSYes, via connect() from cloudflare:socketsYes (Node net / tls)No
Run MQTT.js unchangedNot supportedYesNo
Keep a subscription aliveOnly within a request (Durable Objects for longer)Only within the function's durationOnly within a request

The practical rule: edge functions are publishers, not subscribers. They react to an HTTP request, emit one or a few MQTT messages and finish. Anything that must listen continuously belongs in a long-running service.

If you are on Vercel, the simplest answer is to use the Node.js runtime for the route that talks MQTT and treat it like any other serverless function — the connection-reuse patterns in publishing from AWS Lambda apply directly. Vercel itself steers most workloads towards the Node.js runtime today.

A minimal MQTT client over WebSocket

Publishing at QoS 0 needs only three packets: CONNECT, PUBLISH and DISCONNECT, plus reading the CONNACK. The byte layout is simple — a fixed header, a variable-length "remaining length" field and length-prefixed UTF-8 strings (see MQTT packet structure for the details).

src/mqtt.js
const enc = new TextEncoder();

// UTF-8 string with a 2-byte big-endian length prefix
const str = (s) => { const b = enc.encode(s); return [b.length >> 8, b.length & 0xff, ...b]; };

// MQTT variable-length "remaining length"
function remLen(n) {
  const out = [];
  do {
    let digit = n % 128;
    n = Math.floor(n / 128);
    if (n > 0) digit |= 0x80;
    out.push(digit);
  } while (n > 0);
  return out;
}

const packet = (header, body) => new Uint8Array([header, ...remLen(body.length), ...body]);

function connectPacket(clientId, username, password, keepAlive = 30) {
  let flags = 0x02;                       // clean session
  if (username) flags |= 0x80;
  if (password) flags |= 0x40;
  const body = [...str('MQTT'), 4, flags, keepAlive >> 8, keepAlive & 0xff, ...str(clientId)];
  if (username) body.push(...str(username));
  if (password) body.push(...str(password));
  return packet(0x10, body);              // MQTT 3.1.1 CONNECT
}

const publishPacket = (topic, payload) => packet(0x30, [...str(topic), ...enc.encode(payload)]);
const DISCONNECT = new Uint8Array([0xe0, 0x00]);

export async function publishMqtt(env, topic, payload) {
  const resp = await fetch(env.MQTT_WS_URL, {
    headers: { Upgrade: 'websocket', 'Sec-WebSocket-Protocol': 'mqtt' },
  });
  const ws = resp.webSocket;
  if (!ws) throw new Error(`WebSocket upgrade failed: HTTP ${resp.status}`);
  ws.accept();

  const connack = new Promise((resolve, reject) => {
    const timer = setTimeout(() => reject(new Error('CONNACK timeout')), 5000);
    ws.addEventListener('message', (ev) => {
      const b = new Uint8Array(ev.data);
      if (b[0] !== 0x20) return;
      clearTimeout(timer);
      b[3] === 0 ? resolve() : reject(new Error(`CONNACK return code ${b[3]}`));
    });
    ws.addEventListener('close', () => reject(new Error('socket closed before CONNACK')));
  });

  ws.send(connectPacket(`edge-${crypto.randomUUID().slice(0, 8)}`, env.MQTT_USER, env.MQTT_PASS));
  await connack;
  ws.send(publishPacket(topic, payload));
  ws.send(DISCONNECT);
  ws.close(1000, 'done');
}

A few details are easy to get wrong:

  • The subprotocol header. Most brokers only accept the upgrade if the client offers Sec-WebSocket-Protocol: mqtt. Without it you typically get an HTTP 400 or the socket closes immediately.
  • The path. The WebSocket listener lives at /mqtt on ConnectMQ; a wrong path returns HTTP 404 (WebSocket path 404).
  • Random client IDs. Hundreds of edge locations can run your Worker at the same moment; a fixed ID makes them disconnect each other.
  • Binary frames. MQTT over WebSockets uses binary messages, which is why the code sends Uint8Arrays.

If you need delivery confirmation, extend publishPacket to QoS 1 (header 0x32 plus a two-byte packet identifier after the topic) and wait for the PUBACK (0x40) before closing. Keep payloads small: the packet size calculator shows the exact bytes on the wire.

The Worker

src/index.js
import { publishMqtt } from './mqtt.js';

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    const device = url.searchParams.get('device') ?? 'demo';
    const message = JSON.stringify({ device, path: url.pathname, colo: request.cf?.colo, ts: Date.now() });

    // Respond immediately; finish the MQTT publish in the background
    ctx.waitUntil(
      publishMqtt(env, `testmqtt/edge/${device}/hits`, message)
        .catch((err) => console.error('mqtt publish failed:', err.message)),
    );
    return new Response('queued\n');
  },
};
wrangler.toml
name = "edge-mqtt"
main = "src/index.js"
compatibility_date = "2026-10-01"

[vars]
MQTT_WS_URL = "https://broker.connectmq.com:8084/mqtt"

Store the credentials as secrets, never in wrangler.toml: npx wrangler secret put MQTT_USER and npx wrangler secret put MQTT_PASS. Note that the URL uses https:// — the Workers fetch performs the WebSocket upgrade when it sees the Upgrade header.

ctx.waitUntil keeps the Worker alive after the response is sent until the promise settles, so the visitor does not wait for the broker handshake. If the caller must know whether the publish succeeded, await publishMqtt(...) before responding instead and return a 502 on failure.

Raw TCP with cloudflare:sockets

Cloudflare Workers can also open outbound TCP connections with connect() from cloudflare:sockets, including TLS via secureTransport: "on". The packet functions above work unchanged — write them to socket.writable instead of ws.send and read the CONNACK from socket.readable. That lets you use port 8883 instead of WebSockets. WebSockets remain the more portable choice because the same code runs on other edge platforms and in browsers (MQTT over WebSockets).

Limits to design around

Exact numbers vary by platform and plan and change over time, so check the current documentation. The categories that matter for MQTT are:

  • CPU and wall-clock time per request. A TLS + WebSocket + MQTT handshake costs network round trips; publish a batch per request rather than one connection per message.
  • No connection reuse you can rely on. Isolates may be reused, but treat every request as a fresh connection.
  • Outbound connection and subrequest limits. Each WebSocket counts against them.
  • Outbound ports. Some platforms restrict which ports outbound requests may use. If 8084 is not allowed, use a broker listener on 443 (MQTT on port 443).
  • Connection rate at the broker. A traffic spike on your site becomes a spike of MQTT connections. For high-volume events, consider sending them to one aggregating service that keeps a single connection open.

Common errors

ErrorCauseFix
WebSocket upgrade failed: HTTP 404Wrong pathUse /mqtt
WebSocket upgrade failed: HTTP 400 or immediate closeMissing mqtt subprotocol or plain http:// to a TLS portSend Sec-WebSocket-Protocol: mqtt, use https:// with 8084
CONNACK return code 4Bad username or passwordCheck the Worker secrets
CONNACK return code 5Not authorized (ACL or account)Allow the client to connect and publish
CONNACK timeoutPort blocked or broker unreachableTest the endpoint from the online client first

More WebSocket-specific failures are covered in WebSocket connection failed.

Testing it

  1. Open the online MQTT client, connect to broker.connectmq.com over secure WebSockets and subscribe to testmqtt/edge/#.
  2. Run the Worker locally with npx wrangler dev, then call it: curl "http://localhost:8787/checkout?device=kiosk-1".
  3. A message with the device name and timestamp should appear in the browser. If it does not, check the wrangler dev console for the error message from publishMqtt.
  4. Deploy with npx wrangler deploy and repeat against the workers.dev URL; the colo field now shows which edge location published the message.

Watch edge messages arrive in your browser

Edge functions and MQTT meet at the WebSocket: about fifty lines of packet encoding turn any edge runtime into an MQTT publisher with no dependencies. Keep the functions as short-lived publishers, protect their credentials with tight ACLs, and leave long-lived subscriptions to services that can stay connected.

Frequently asked questions

Can I use MQTT.js inside a Cloudflare Worker?

It is not a supported target. MQTT.js expects Node.js streams and sockets; with Node compatibility enabled parts may work, but behaviour can change between runtime versions. A small MQTT encoder over the Workers WebSocket API is more predictable for publishing.

Can an edge function subscribe to MQTT topics?

Only for the length of a request. Edge functions are request-scoped and cannot keep a connection open indefinitely. For long-lived subscriptions use an always-on service, or a platform primitive designed for it such as Cloudflare Durable Objects, and check its limits.

Which broker port should an edge function use?

Secure WebSockets (wss, port 8084 on ConnectMQ, path /mqtt) is the most portable option because every edge runtime supports WebSockets. If your platform restricts outbound ports, use a broker listener on 443.