MQTT with Deno and Bun does not need a special client: both runtimes run MQTT.js 5 from npm, with TCP, TLS and WebSocket transports, and MQTT.js explicitly recognizes Deno so it uses its Node.js build. What differs is how you install it, which permissions you grant, how environment variables are loaded and how tests clean up. This guide covers each runtime, a shared file that runs on Node.js, Deno and Bun, and the quirks you are likely to hit.
Examples connect to broker.connectmq.com (TCP 1883, TLS 8883, secure WebSocket 8084 at /mqtt) and use topics under testmqtt/deno-bun/....
Deno: npm specifiers and permissions
Deno can import MQTT.js straight from npm with an npm: specifier. No install step, no node_modules folder by default:
import mqtt from "npm:mqtt@^5";
const client = await mqtt.connectAsync("mqtts://broker.connectmq.com:8883", {
clientId: `deno-${crypto.randomUUID().slice(0, 8)}`,
username: Deno.env.get("MQTT_USER"),
password: Deno.env.get("MQTT_PASS"),
});
console.log("connected");
client.on("message", (topic, payload) => {
console.log(topic, payload.toString());
});
await client.subscribeAsync("testmqtt/deno-bun/#", { qos: 1 });
await client.publishAsync(
"testmqtt/deno-bun/hello",
JSON.stringify({ from: "deno", at: Date.now() }),
{ qos: 1 },
);deno run \
--allow-net=broker.connectmq.com \
--allow-env=MQTT_USER,MQTT_PASS \
hello.deno.tsDeno denies network and environment access unless you grant it, which is a good fit for MQTT tools: the script above can reach the broker and nothing else. Three things to know:
- Env access from dependencies. MQTT.js uses the
debugpackage, which inspects the environment. If Deno prompts or stops with an error containingRequires env access, the request comes from a dependency. Widen it to--allow-env(or accept the prompt) rather than switching everything on with-A. - Pin the major version.
npm:mqtt@^5keeps scripts stable. For projects,deno add npm:mqttwrites the mapping intodeno.jsonso you can writeimport mqtt from "mqtt". - Buffer is not a global for your code. Payloads are Node.js
Bufferobjects andpayload.toString()works, but if you need the type name, import it:import { Buffer } from "node:buffer".
Type checking is built in. deno check hello.deno.ts uses the declarations that ship with MQTT.js, so connectAsync options and event handlers are fully typed.
Bun: install and run
Bun behaves much like Node.js: install from npm, run TypeScript directly.
bun init -y
bun add mqttimport mqtt from "mqtt";
const client = await mqtt.connectAsync("mqtts://broker.connectmq.com:8883", {
clientId: `bun-${crypto.randomUUID().slice(0, 8)}`,
username: process.env.MQTT_USER,
password: process.env.MQTT_PASS,
});
client.on("message", (topic, payload) => console.log(topic, payload.toString()));
await client.subscribeAsync("testmqtt/deno-bun/#", { qos: 1 });
await client.publishAsync("testmqtt/deno-bun/hello", JSON.stringify({ from: "bun" }), { qos: 1 });bun run hello.bun.tsBun-specific points:
.envis loaded automatically. Bun reads.env(and.env.local,.env.production, …) from the working directory without any flag. Handy forMQTT_USERandMQTT_PASS, but it also means a stray.envin the folder can silently point a script at the wrong broker.- No type checking at run time. Bun strips types and runs. Add
tsc --noEmit(with@types/bun) to CI if you want errors caught. - Compatibility is good but not identical. Bun reimplements
node:net,node:tlsand friends. If a connection behaves differently from Node.js, reproduce it with the same script under Node before blaming the broker; upgrading Bun fixes many such differences.
One file for Node.js, Deno and Bun
If the project has a package.json with mqtt as a dependency, Deno 2 resolves bare imports from it too, so a single file can run everywhere. Read configuration through node:process, which all three runtimes provide:
import process from "node:process";
import mqtt from "mqtt";
const url = process.env.MQTT_URL ?? "mqtt://broker.connectmq.com:1883";
const topic = process.argv[2] ?? "testmqtt/deno-bun/portable";
const message = process.argv[3] ?? "hello";
const client = await mqtt.connectAsync(url, {
username: process.env.MQTT_USER,
password: process.env.MQTT_PASS,
connectTimeout: 5000,
});
await client.publishAsync(topic, message, { qos: 1 });
console.log(`published to ${topic}`);
await client.endAsync();npx tsx mqtt-send.ts testmqtt/deno-bun/portable "from node"
deno run --allow-net --allow-env mqtt-send.ts testmqtt/deno-bun/portable "from deno"
bun run mqtt-send.ts testmqtt/deno-bun/portable "from bun"| Node.js | Deno | Bun | |
|---|---|---|---|
| Install | npm install mqtt | npm:mqtt@^5 or deno add npm:mqtt | bun add mqtt |
| Permissions | none | --allow-net, --allow-env | none |
.env files | --env-file=.env | --env-file=.env | loaded automatically |
| TypeScript | tsx or built-in type stripping | native, deno check | native, no type check |
| Single binary | Node SEA (manual) | deno compile | bun build --compile |
| Tests | Vitest / Jest | deno test | bun test |
The endAsync() at the end matters on every runtime: an open MQTT connection keeps the process alive, so a CLI that forgets it never exits. More CLI patterns are in building an MQTT CLI tool in Node.js.
An HTTP-to-MQTT bridge
Both runtimes have a built-in HTTP server, which makes a tiny webhook bridge a few lines long. Keep one MQTT connection for the life of the process and publish on each request:
import mqtt from "npm:mqtt@^5";
const client = await mqtt.connectAsync("mqtts://broker.connectmq.com:8883", {
username: Deno.env.get("MQTT_USER"),
password: Deno.env.get("MQTT_PASS"),
});
Deno.serve({ port: 8000 }, async (req) => {
if (req.method !== "POST") return new Response("POST only", { status: 405 });
await client.publishAsync("testmqtt/deno-bun/http", await req.text(), { qos: 1 });
return Response.json({ ok: true });
});
Deno.addSignalListener("SIGINT", async () => {
await client.endAsync();
Deno.exit(0);
});The Bun version swaps two calls:
import mqtt from "mqtt";
const client = await mqtt.connectAsync("mqtts://broker.connectmq.com:8883", {
username: process.env.MQTT_USER,
password: process.env.MQTT_PASS,
});
Bun.serve({
port: 8000,
async fetch(req) {
if (req.method !== "POST") return new Response("POST only", { status: 405 });
await client.publishAsync("testmqtt/deno-bun/http", await req.text(), { qos: 1 });
return Response.json({ ok: true });
},
});
process.on("SIGINT", async () => {
await client.endAsync();
process.exit(0);
});Test either with curl -X POST localhost:8000 -d '{"door":"open"}' while the online MQTT client is subscribed to testmqtt/deno-bun/#. For a hardened version with validation and retries, see MQTT to webhook.
TLS and WebSockets
mqtts:// on port 8883 works on both runtimes with publicly trusted certificates. For a broker with a private CA:
- Bun accepts the MQTT.js
caoption the same way Node.js does:ca: [readFileSync("ca.pem")]. - Deno uses its own root store by default. Add a private CA with
--cert ca.pem(or theDENO_CERTvariable), or setDENO_TLS_CA_STORE=systemto trust the operating system's store. Reading the file in code needs--allow-read.
If outbound port 8883 is blocked, switch the URL to wss://broker.connectmq.com:8084/mqtt; nothing else in the code changes. Certificate errors are explained in MQTT TLS handshake failed.
Tests: deno test and bun test
Both runtimes ship a test runner. A round-trip test against a real broker looks almost the same in each; the important part is closing the client in finally:
import mqtt from "npm:mqtt@^5";
import { assertEquals } from "jsr:@std/assert";
Deno.test("publish and receive", async () => {
const topic = `testmqtt/deno-bun/test/${crypto.randomUUID()}`;
const client = await mqtt.connectAsync("mqtt://broker.connectmq.com:1883");
try {
await client.subscribeAsync(topic, { qos: 1 });
const received = new Promise<string>((resolve) =>
client.once("message", (_t, payload) => resolve(payload.toString())),
);
await client.publishAsync(topic, "ping", { qos: 1 });
assertEquals(await received, "ping");
} finally {
await client.endAsync();
}
});Run it with deno test --allow-net --allow-env. If you forget endAsync(), Deno's sanitizers fail the test with Leaks detected, which is annoying the first time and useful forever after.
With Bun, import test and expect from bun:test and replace the assertion with expect(await received).toBe("ping"). Then run bun test. The broader patterns (fake clients, timeouts, waiting for messages) are the same as in testing MQTT.js with Jest and Vitest.
Watch the Deno and Bun test topics
Shipping a single binary
Both runtimes can bundle a script, MQTT.js and the runtime itself into one executable, which is a neat way to hand a test tool to colleagues without installing anything:
deno compile --allow-net=broker.connectmq.com --allow-env -o mqtt-send mqtt-send.ts
bun build --compile ./mqtt-send.ts --outfile mqtt-sendDeno bakes the permissions into the binary, so it can still reach only the broker you allowed.
MQTT.js runs on Deno and Bun with very little change: npm: specifiers and explicit permissions on Deno, automatic .env loading and fast startup on Bun. Keep connection code in a portable file, always end the client, and test the result against a real broker before you put it in a pipeline.
Frequently asked questions
Is there a native Deno or Bun MQTT client?
You don't need one. Both runtimes run MQTT.js 5 from npm through their Node.js compatibility layers, including TCP, TLS and WebSocket transports, and MQTT.js detects Deno explicitly so it picks its Node build. Deno users import it with the npm:mqtt specifier; Bun installs it with bun add mqtt.
Which Deno permissions does an MQTT client need?
Network access to the broker host (--allow-net=broker.connectmq.com) and environment access if you read credentials from variables. Some dependencies inspect the environment as well, so if Deno reports "Requires env access", widen --allow-env. Add --allow-read only if you load CA files from disk.
Why does my Deno test fail with "Leaks detected"?
Deno's test sanitizers check that every socket and timer opened during a test is closed by the end of it. An MQTT client that is still connected keeps a socket and keep-alive timer open. Call await client.endAsync() in a finally block so the test cleans up even when an assertion fails.


