Skip to content

Roadmap: MQTT.js v6 — Zero/minimal dependencies, tree-shakeable transports, reduced bundle size #2038

@robertsLando

Description

@robertsLando

Summary

This issue outlines the implementation roadmap for MQTT.js v6, a major release focused on reducing dependencies, improving tree-shakability, reducing browser bundle size, and supporting multiple JavaScript runtimes — while maintaining or improving current performance.

This builds on the discussion in #2002 and depends on mqtt-packet v10 (a zero-dependency, TypeScript-native rewrite).

Current State (v5.x)

Metric Value
Production dependencies ~75 packages (transitive)
Browser bundle (minified) 368 KB
sideEffects in package.json Not declared
Tree-shakeable transports No (runtime require() with isBrowser check)
Runtime support Node.js, Browser (via polyfills)

Dependency Breakdown

The heaviest chains today:

Chain Packages Issue
mqtt-packetblreadable-stream + buffer ~12 Core codec pulls stream polyfills
readable-streambuffer, events, process, abort-controller ~8 Node stream polyfill for browser
worker-timers@babel/runtime, tslib, broker-factory, worker-factory ~10 Timers in web workers
socksip-address, smart-buffer ~4 SOCKS proxy (Node-only)
concat-stream, split2, commist, help-me, minimist ~8 CLI-only deps shipped to all consumers
number-allocatorjs-sdsl ~2 Full data structures library for simple ID allocation

Target State (v6)

Metric Target
Production dependencies ~15-20 packages
Browser bundle (minified) ~80-120 KB
Browser bundle format ESM only (drop IIFE)
Tree-shakeable transports Yes (separate packages in monorepo)
Runtime support Node.js, Browser, Deno, Bun
Minimum Node.js version 24+
sideEffects Declared false
Repository structure Monorepo (npm workspaces + Nx)

Decisions (from discussion)

Based on feedback from @mcollina and @jacoscaz:

Question Decision Rationale
Transport interface: callbacks or async iterators? Callbacks Better performance, less GC pressure, more consistent across runtimes. Worth benchmarking on modern runtimes first.
ws dependency? Eliminate entirely Node.js 24+ ships native WebSocket. Keep ws as devDependency for tests only.
Browser bundle format? ESM only Drop IIFE bundle. Modern bundlers and browsers all support ESM.
debug dependency? Keep for now Slight bias towards keeping it for ecosystem compatibility. Re-evaluate later.
Monorepo vs separate repos? Monorepo (npm workspaces + Nx) Each transport published as a separate package, CLI as separate package, all in one repo. Nx for task orchestration, build caching, and release management.
CLI? Keep in monorepo, publish separately Very useful debugging tool. Published as @mqttjs/cli, not bundled with the core library.
Transport interface design? Use Node.js Duplex streams The original TransportConnection interface was essentially reinventing streams. Transports should return Duplex streams (Node.js native). The write() side must accept both Uint8Array and Buffer without re-wrapping.
Size on disk? Not a goal Focus on reducing bundle size and dependency count, not size on disk.

Implementation Roadmap

Prerequisites

  • P.0 mqtt-packet v10 released (see mqtt-packet v10 roadmap)

    • Zero dependencies, TypeScript, Uint8Array-native with Buffer fast-paths
    • Backward-compatible parser() / generate() / writeToStream() API
  • P.1 Convert repository to monorepo (npm workspaces + Nx)

    • Structure:
      packages/
        mqtt/              → core client library (published as `mqtt`)
        mqtt-packet/       → packet codec (published as `mqtt-packet`)
        transport-tcp/     → TCP transport (published as `@mqttjs/transport-tcp`)
        transport-tls/     → TLS transport (published as `@mqttjs/transport-tls`)
        transport-ws/      → WebSocket transport (published as `@mqttjs/transport-ws`)
        transport-wx/      → WeChat transport (published as `@mqttjs/transport-wx`)
        transport-ali/     → Alibaba IoT transport (published as `@mqttjs/transport-ali`)
        transport-socks/   → SOCKS proxy transport (published as `@mqttjs/transport-socks`)
        cli/               → CLI tools (published as `@mqttjs/cli`)
      
    • npm workspaces for dependency management and linking
    • Nx (devDependency only — zero impact on published packages) for:
      • Task graph: understands inter-package dependencies, builds in correct topological order (e.g. mqtt-packet before mqtt, transports in parallel)
      • Build caching: local + remote via Nx Cloud (free for open source). A PR touching only transport-ws skips rebuilding/testing mqtt-packet, transport-tcp, etc.
      • Affected commands: nx affected -t test only tests packages impacted by the current changeset — significant CI time savings as the monorepo grows to 9+ packages
      • Release orchestration: nx release handles versioning, changelogs, and npm publishing across packages with dependency-aware version bumps
    • Shared tooling: TypeScript, ESLint, test runner configs at root
    • CI: nx affected -t lint,test,build for PRs, nx run-many -t lint,test,build for main branch
    • Task pipeline in nx.json:
      {
        "targetDefaults": {
          "build": { "dependsOn": ["^build"], "cache": true },
          "test": { "dependsOn": ["build"], "cache": true },
          "lint": { "cache": true }
        }
      }

Phase 1: Dependency Cleanup (non-breaking, can ship as v5.x minor/patch)

These changes reduce the dependency count without breaking the public API. They can be shipped incrementally in v5.x releases before the v6 major bump.

  • 1.1 Replace number-allocator with an inline implementation

    • Current: pulls in js-sdsl (a full data structures library) for allocating message IDs in range 1-65535
    • Replacement: a simple bitfield-based or array-based allocator (~50-80 lines)
    • Saves: ~2 dependencies
  • 1.2 Replace worker-timers with a lightweight implementation

    • Current: pulls in @babel/runtime, tslib, broker-factory, worker-factory, fast-unique-numbers (~10 packages)
    • The actual need: setTimeout/setInterval that work reliably in web workers (where native timers can be throttled)
    • Replacement: a focused ~100-line implementation using inline worker blob or native timers with isWebWorker detection
    • Saves: ~10 dependencies
  • 1.3 Evaluate lru-cache replacement

    • Current: 844KB on disk (mostly docs/types), used for topic alias send cache
    • Option: inline ~50-line LRU implementation (doubly-linked list + Map) since the use case is simple (fixed max size, string keys, numeric values)
    • Alternative: keep it — the runtime code is small and well-tested
    • Decision: benchmark and decide
  • 1.4 Make socks an optional dependency / plugin

    • Already stubbed out in browser builds
    • Most users don't use SOCKS proxies
    • Will become a separate transport package in the monorepo
    • Saves: ~4 dependencies

Phase 2: Adopt mqtt-packet v10

  • 2.1 Upgrade to mqtt-packet@10

    • The v10 compatibility layer should make this a drop-in upgrade
    • Run full test suite (Node + browser) to validate
    • This alone eliminates bl, process-nextick-args, and the transitive readable-stream + buffer from mqtt-packet's chain
  • 2.2 Remove @types/readable-stream from production dependencies

    • Currently listed in dependencies instead of devDependencies
    • Should be in devDependencies (or unnecessary if readable-stream ships its own types)
  • 2.3 Remove @types/ws from production dependencies

    • Same issue — should be in devDependencies

Phase 3: Transport Plugin Architecture

This is the core architectural change that enables tree-shaking and multi-runtime support. Each transport is published as a separate package from the monorepo.

  • 3.1 Define the transport contract

    • Transports return Node.js Duplex streams (not a custom interface — reinventing streams is not the goal)
    • The write() side must accept both Uint8Array and Buffer without re-wrapping
    • Readable side emits Uint8Array or Buffer chunks (both are fine, the parser handles either)
    • Transport factory signature: (client: MqttClient, opts: IClientOptions) => Duplex
  • 3.2 Extract transports into separate packages

    Package Transport Notes
    @mqttjs/transport-tcp mqtt://, tcp:// Node.js net.createConnection()
    @mqttjs/transport-tls mqtts://, ssl:// Node.js tls.connect()
    @mqttjs/transport-ws ws://, wss:// Native WebSocket (Node.js 24+ and browser)
    @mqttjs/transport-wx wx://, wxs:// WeChat mini-program
    @mqttjs/transport-ali ali://, alis:// Alibaba IoT
    @mqttjs/transport-socks SOCKS proxy wrapper Wraps TCP/TLS transports through SOCKS proxy

    Note: With Node.js 24+ shipping native WebSocket, the ws npm package is no longer needed as a production dependency. It can remain as a devDependency for tests.

  • 3.3 Update package.json exports map

    {
      ".": { "default": "..." },
      "./client": "./build/lib/client.js",
      "./transports/tcp": { "default": "./build/lib/transports/tcp.js" },
      "./transports/tls": { "default": "./build/lib/transports/tls.js" },
      "./transports/ws": { "default": "./build/lib/transports/ws.js" }
    }
  • 3.4 Add "sideEffects": false to package.json

    • Verify all modules are side-effect-free (no top-level mutations)
    • Fix the process.nextTick = setImmediate side effect in connect/index.ts (move to client init)
  • 3.5 Maintain backward-compatible connect() / connectAsync()

    • The default mqtt package import auto-registers all transports for the current environment (same as today)
    • Users who want tree-shaking import from sub-paths and use specific transport packages:
      import { MqttClient } from 'mqtt/client'
      import { wsTransport } from '@mqttjs/transport-ws'
      const client = new MqttClient({ transport: wsTransport, ... })

Phase 4: Remove readable-stream from MQTT.js Core

This is the most impactful and most difficult phase. It requires refactoring client.ts (2428 lines) to decouple from the readable-stream polyfill while still using Node.js native streams.

  • 4.1 Refactor MqttClient to use native Node.js streams

    • Replace readable-stream imports with node:stream
    • The parser wrapper from mqtt-packet v10 accepts chunks via parse(buf) — no need for a Writable pipe target
    • Transport streams are native Duplex — no polyfill needed
  • 4.2 Remove BufferedDuplex.ts

    • Currently wraps browser WebSocket in a Node.js Duplex stream
    • With the transport architecture, the WebSocket transport handles buffering internally
  • 4.3 Refactor Store to not depend on readable-stream

    • Currently Store.createStream() returns a Readable from readable-stream
    • Replace with native node:stream Readable or a callback-based iteration
  • 4.4 Remove readable-stream from dependencies

    • This eliminates readable-stream, buffer, events, process, abort-controller, event-target-shim, safe-buffer, string_decoder from the dependency tree
    • Saves: ~8 dependencies and significant bundle size
    • Node.js 24+ native streams are used directly — no polyfill
  • 4.5 Update esbuild configuration

    • Remove esbuild-plugin-polyfill-node (no more readable-stream to polyfill)
    • Remove browser field entries for fs, tls, net (handled by transport plugin architecture)
    • Ship ESM-only browser bundle (drop IIFE format)
    • Expected browser bundle: ~80-120 KB minified (down from 368 KB)

Phase 5: CLI as Separate Package

  • 5.1 Extract CLI into @mqttjs/cli package in the monorepo
    • Moves commist, help-me, minimist, concat-stream, split2 out of the core library's dependency tree
    • @mqttjs/cli depends on mqtt (the core library)
    • Provides mqtt, mqtt_pub, mqtt_sub bin commands
    • Saves: ~8 dependencies for library consumers who don't use the CLI

Phase 6: Multi-Runtime Support

  • 6.1 Verify Deno compatibility

    • With Uint8Array-native mqtt-packet and no Node.js stream dependency, Deno support should work via npm:mqtt
    • Add a TCP transport using Deno.connect() (optional, community-driven)
  • 6.2 Verify Bun compatibility

    • Bun supports Node.js APIs natively, so this should work out of the box
    • Validate with test suite
  • 6.3 React Native validation

    • Currently supported via react-native exports condition
    • Verify the WebSocket transport works with React Native's WebSocket implementation

Phase 7: Testing, Benchmarks & Release

  • 7.1 Full test suite pass

    • All existing Node.js tests
    • All existing browser tests (web-test-runner)
    • New tests for transport plugin registration
    • New tests for tree-shaking (verify bundle only includes registered transports)
  • 7.2 Performance benchmarks

    • Compare v6 vs v5 for:
      • Connection establishment time
      • Message publish throughput (QoS 0, 1, 2)
      • Message receive throughput
      • Memory usage under sustained load
    • Target: equal or better than v5 on Node.js
  • 7.3 Bundle size analysis

    • Measure full bundle (all transports)
    • Measure minimal bundle (WebSocket-only for browser)
    • Compare against v5
  • 7.4 Migration guide

    • Document all breaking changes
    • Provide codemods or migration scripts where possible
    • Document new tree-shakeable import patterns
  • 7.5 Release mqtt@6.0.0


Breaking Changes Summary (v5 → v6)

  1. Minimum Node.js version: Node.js 24+ (native WebSocket, stable TextEncoder/TextDecoder, modern streams)
  2. Browser bundle: ESM only (IIFE bundle dropped)
  3. ws dependency: Removed — uses native WebSocket on Node.js 24+
  4. Store.createStream(): May return AsyncIterable instead of Readable (if Phase 4.3 changes the interface)
  5. Transport registration: Custom stream builders need to adapt to the transport package pattern
  6. CLI: Published as separate @mqttjs/cli package (not bundled with core)
  7. socks proxy: Published as separate @mqttjs/transport-socks package
  8. Packet payload types: Uint8Array instead of Buffer in some contexts (though Buffer extends Uint8Array so most code works unchanged)

Non-Breaking

  • connect() / connectAsync() API preserved
  • MqttClient event API preserved (on('message'), on('connect'), etc.)
  • publish(), subscribe(), unsubscribe(), end() APIs preserved
  • All MQTT protocol versions supported (3.1, 3.1.1, 5.0)
  • All current transport protocols supported (mqtt, mqtts, ws, wss, wx, ali)

Expected Dependency Tree (v6, library consumers)

mqtt@6.0.0
├── mqtt-packet@10.x (zero deps)
├── @mqttjs/transport-tcp (zero deps, uses node:net)
├── @mqttjs/transport-tls (zero deps, uses node:tls)
├── @mqttjs/transport-ws (zero deps, uses native WebSocket)
├── debug@4.x
│   └── ms@2.x
├── lru-cache@10.x (or inline)
└── rfdc@1.x

Total: ~5-8 packages (down from ~75)


Monorepo Structure

mqttjs/MQTT.js (monorepo root)
├── packages/
│   ├── mqtt/                    → core client library (`mqtt` on npm)
│   ├── mqtt-packet/             → packet codec (`mqtt-packet` on npm)
│   ├── transport-tcp/           → `@mqttjs/transport-tcp`
│   ├── transport-tls/           → `@mqttjs/transport-tls`
│   ├── transport-ws/            → `@mqttjs/transport-ws`
│   ├── transport-wx/            → `@mqttjs/transport-wx`
│   ├── transport-ali/           → `@mqttjs/transport-ali`
│   ├── transport-socks/         → `@mqttjs/transport-socks`
│   └── cli/                     → `@mqttjs/cli`
├── package.json                 → npm workspaces config
├── nx.json                      → Nx task pipeline, caching, release config
├── tsconfig.base.json           → shared TS config
└── ...shared tooling (eslint, prettier, etc.)

Why npm workspaces + Nx (not just npm workspaces)

With 9+ packages, plain npm workspaces lacks:

  • Task orderingnpm run build across packages runs in parallel with no dependency awareness. Nx builds mqtt-packet first, then transports + core in parallel, then CLI — automatically.
  • Build caching — every CI run rebuilds everything from scratch. Nx caches locally and remotely (Nx Cloud, free for OSS). A PR touching only one transport skips everything else.
  • Change detection — without nx affected, a typo fix in transport-wx triggers the full test suite across all 9 packages.
  • Release management — coordinating version bumps, changelogs, and publishing across interdependent packages is manual and error-prone. nx release handles this with dependency-aware version bumps.

Nx is a devDependency only — it adds zero weight to published packages and zero impact on consumers.


Phasing & Compatibility

  • Phase 1 can ship in v5.x minor releases (non-breaking dependency swaps)
  • Phase 2 can ship as v5.x (mqtt-packet v10 with compat layer is drop-in)
  • Phases 3-7 are the v6 major release
  • P.1 (monorepo conversion) should happen early to enable parallel work on transports

This phasing means the community benefits incrementally — dependency reductions in Phase 1-2 land without waiting for the full v6.


Resolved Questions

# Question Answer Source
1 Transport interface: callbacks or async iterators? Callbacks. Use Node.js Duplex streams — don't reinvent the wheel. @mcollina, @jacoscaz
2 ws dependency? Eliminate. Node.js 24+ has native WebSocket. @mcollina
3 Browser bundle format? ESM only. Drop IIFE. @jacoscaz
4 debug dependency? Keep for now. @jacoscaz
5 Monorepo vs separate repos? Monorepo with npm workspaces + Nx. Each transport as separate published package. Nx for task orchestration, caching, and releases (devDependency only). @mcollina, @jacoscaz
6 CLI? Keep in monorepo, publish as separate @mqttjs/cli package. @mcollina, @jacoscaz
7 Minimum Node.js? Node.js 24+ @mcollina, @jacoscaz

Continuation of #2002.

CC: @seriousme @mcollina @jacoscaz

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions