Tutorials

MQTT with Deno and Bun

Run MQTT.js 5 on Deno and Bun: npm specifiers, Deno permissions, TLS, .env handling, HTTP-to-MQTT bridges, tests with deno test and bun test, single binaries.

Updated · 8 min read

MQTT with Deno and Bun

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:

hello.deno.ts
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 },
);
Run with narrow permissions
deno run \
  --allow-net=broker.connectmq.com \
  --allow-env=MQTT_USER,MQTT_PASS \
  hello.deno.ts

Deno 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 debug package, which inspects the environment. If Deno prompts or stops with an error containing Requires 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@^5 keeps scripts stable. For projects, deno add npm:mqtt writes the mapping into deno.json so you can write import mqtt from "mqtt".
  • Buffer is not a global for your code. Payloads are Node.js Buffer objects and payload.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 setup
bun init -y
bun add mqtt
hello.bun.ts
import 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 });
Run
bun run hello.bun.ts

Bun-specific points:

  • .env is loaded automatically. Bun reads .env (and .env.local, .env.production, …) from the working directory without any flag. Handy for MQTT_USER and MQTT_PASS, but it also means a stray .env in 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:tls and 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:

mqtt-send.ts
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();
Same script, three runtimes
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.jsDenoBun
Installnpm install mqttnpm:mqtt@^5 or deno add npm:mqttbun add mqtt
Permissionsnone--allow-net, --allow-envnone
.env files--env-file=.env--env-file=.envloaded automatically
TypeScripttsx or built-in type strippingnative, deno checknative, no type check
Single binaryNode SEA (manual)deno compilebun build --compile
TestsVitest / Jestdeno testbun 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:

bridge.deno.ts
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:

bridge.bun.ts
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 ca option 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 the DENO_CERT variable), or set DENO_TLS_CA_STORE=system to 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:

mqtt_test.ts (Deno)
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:

Compile
deno compile --allow-net=broker.connectmq.com --allow-env -o mqtt-send mqtt-send.ts
bun build --compile ./mqtt-send.ts --outfile mqtt-send

Deno 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.