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
| Need | Cloudflare Workers | Vercel (Node.js runtime) | Vercel Edge runtime |
|---|---|---|---|
| Outbound WebSocket | Yes, via fetch with Upgrade: websocket | Yes, via a library | Limited — check current docs |
| Outbound TCP / TLS | Yes, via connect() from cloudflare:sockets | Yes (Node net / tls) | No |
| Run MQTT.js unchanged | Not supported | Yes | No |
| Keep a subscription alive | Only within a request (Durable Objects for longer) | Only within the function's duration | Only 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).
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
/mqtton 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
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');
},
};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
| Error | Cause | Fix |
|---|---|---|
WebSocket upgrade failed: HTTP 404 | Wrong path | Use /mqtt |
WebSocket upgrade failed: HTTP 400 or immediate close | Missing mqtt subprotocol or plain http:// to a TLS port | Send Sec-WebSocket-Protocol: mqtt, use https:// with 8084 |
CONNACK return code 4 | Bad username or password | Check the Worker secrets |
CONNACK return code 5 | Not authorized (ACL or account) | Allow the client to connect and publish |
CONNACK timeout | Port blocked or broker unreachable | Test the endpoint from the online client first |
More WebSocket-specific failures are covered in WebSocket connection failed.
Testing it
- Open the online MQTT client, connect to
broker.connectmq.comover secure WebSockets and subscribe totestmqtt/edge/#. - Run the Worker locally with
npx wrangler dev, then call it:curl "http://localhost:8787/checkout?device=kiosk-1". - A message with the device name and timestamp should appear in the browser. If it does not, check the
wrangler devconsole for the error message frompublishMqtt. - Deploy with
npx wrangler deployand repeat against theworkers.devURL; thecolofield 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.


