MqttX

Hex.pm Docs CI

MqttX

Fast, pure Elixir MQTT 5.0 — client, server, and codec in one package.

AI coding assistants: see AGENTS.md for the mental model, idiomatic patterns, and a list of mistakes commonly made when integrating MqttX.

Name note: MqttX (this Elixir library) is not affiliated with MQTTX, EMQX's desktop MQTT client tool. The hex package name mqttx is stable — you can depend on it.

Installation

Requires Elixir 1.18+ / OTP 27+ (the JSON payload codec uses the native JSON module; CI covers Elixir 1.18-1.20 on OTP 27-29).

Add mqttx to your dependencies:

def deps do
  [
    {:mqttx, "~> 0.12.0"},
    # Optional: Pick a transport
    {:thousand_island, "~> 1.4"},  # or {:ranch, "~> 2.2"}
    # Optional: WebSocket transport
    {:bandit, "~> 1.6"},
    {:websock_adapter, "~> 0.5 or ~> 0.6"},
    # Optional: Payload codecs
    {:protox, "~> 2.0"}
  ]
end

Quick Start

MQTT Server

Create a handler module:

defmodule MyApp.MqttHandler do
  use MqttX.Server

  @impl true
  def init(_opts) do
    %{subscriptions: %{}}
  end

  @impl true
  def handle_connect(client_id, credentials, connect_info, state) do
    # credentials:  %{username: String.t(), password: String.t()}
    # connect_info: %{protocol_version: 3 | 4 | 5, keep_alive: non_neg_integer(),
    #                 session_present: boolean(), subscriptions: [map()], ...}
    IO.puts("[MQTT] CONNECT #{client_id} v#{connect_info.protocol_version} keepalive=#{connect_info.keep_alive}")
    {:ok, state}
  end

  @impl true
  def handle_publish(topic, payload, opts, state) do
    IO.puts("Received on #{inspect(topic)}: #{payload}")
    {:ok, state}
  end

  @impl true
  def handle_subscribe(topics, state) do
    qos_list = Enum.map(topics, fn t -> t.qos end)
    {:ok, qos_list, state}
  end

  @impl true
  def handle_disconnect(reason, _state) do
    IO.puts("Client disconnected: #{inspect(reason)}")
    :ok
  end
end

Start the server:

{:ok, _pid} = MqttX.Server.start_link(
  MyApp.MqttHandler,
  [],
  transport: MqttX.Transport.ThousandIsland,
  port: 1883
)

MQTT Client

# Connect with TCP (default). connect/1 is asynchronous — `await_connect: true`
# blocks until the session is live so the calls below work inline; long-lived
# clients should instead act on the handler's :connected event.
{:ok, client} = MqttX.Client.connect(
  host: "localhost",
  port: 1883,
  client_id: "my_client",
  await_connect: true,
  username: "user",        # optional
  password: "secret"       # optional
)

# Subscribe (returns {:ok, granted_qos_list})
{:ok, [1]} = MqttX.Client.subscribe(client, "sensors/#", qos: 1)

# Publish
:ok = MqttX.Client.publish(client, "sensors/temp", "25.5")

# Disconnect
:ok = MqttX.Client.disconnect(client)

TLS/SSL Connection

Certificates are verified by default since v0.11.0 — verify_peer against the OS trust store, with SNI and HTTPS-style hostname checking:

{:ok, client} = MqttX.Client.connect(
  host: "broker.example.com",
  port: 8883,                    # default SSL port
  client_id: "secure_client",
  transport: :ssl
)

Options in :ssl_opts are merged over that baseline — supply a private CA with ssl_opts: [cacertfile: "/etc/ssl/private-ca.pem"], or, for a development broker with a self-signed certificate, opt out explicitly with ssl_opts: [verify: :verify_none] (logs a warning on every connect).

Behind an HTTP proxy

Where direct outbound to 1883/8883 is blocked, tunnel through an HTTP CONNECT proxy — works for every transport, and TLS is still negotiated with the broker through the tunnel:

{:ok, client} = MqttX.Client.connect(
  host: "broker.example.com",
  port: 8883,
  client_id: "behind_proxy",
  transport: :ssl,
  proxy: [host: "proxy.corp", port: 3128, auth: {"user", "pass"}]
)

Session Persistence

# Enable session persistence for QoS 1/2 message reliability
{:ok, client} = MqttX.Client.connect(
  host: "localhost",
  client_id: "persistent_client",
  protocol_version: 5,
  clean_session: false,          # maintain session across reconnects
  connect_properties: %{session_expiry_interval: 3600},  # v5 default is 0 = ends at disconnect
  session_store: MqttX.Session.ETSStore  # built-in ETS store
)

The stored record carries expires_at from the negotiated Session Expiry Interval; an expired record is discarded on the next connect and the ETS store sweeps expired records periodically.

Packet Codec (Standalone)

# Encode a packet
packet = %{
  type: :publish,
  topic: "test/topic",
  payload: "hello",
  qos: 0,
  retain: false
}
{:ok, binary} = MqttX.Packet.Codec.encode(4, packet)

# Decode a packet
{:ok, {decoded, rest}} = MqttX.Packet.Codec.decode(4, binary)

Why MQTT, and why MqttX?

MQTT is the right protocol for constrained and cellular deployments, and this library exists because the alternatives in the ecosystem each leave a gap. The reasoning — protocol-overhead comparisons against HTTP and WebSocket, real payload measurements, monthly cellular data budgets, and an honest comparison with the other Elixir/Erlang MQTT libraries — lives in Why MQTT for IoT.

Common Patterns

Receiving messages on the client

Provide a handler module that implements handle_mqtt_event/3. The client calls it on connect, disconnect, for every incoming PUBLISH, and when the broker rejects one of your QoS 1/2 publishes:

defmodule MyApp.MqttClientHandler do
  def handle_mqtt_event(:message, {topic, payload, _packet}, state) do
    IO.puts("Got #{payload} on #{Enum.join(topic, "/")}")
    state
  end

  # Catch-all so other events (:connected, :disconnected, :publish_error)
  # don't raise
  def handle_mqtt_event(_event, _data, state), do: state
end

{:ok, client} = MqttX.Client.connect(
  host: "broker.example.com",
  client_id: "subscriber",
  handler: MyApp.MqttClientHandler,
  handler_state: %{},
  await_connect: true
)

{:ok, _granted} = MqttX.Client.subscribe(client, "sensors/#", qos: 1)

topic arrives as a list of segments (["sensors", "room1", "temp"]), not the original string. The full event list, the payload/packet shapes, and the rules for calling back into the client from a handler are in the Client Guide.

Module-based client (use MqttX)

For a client that owns its callbacks, connection, and supervision in one module:

defmodule MyApp.Sensors do
  use MqttX

  @impl true
  def handle_message(topic, payload, _packet, state) do
    # Safe to publish from inside a callback — callbacks run in this module's
    # own process, not inside the connection
    publish("ack/" <> Enum.join(topic, "/"), payload, qos: 1)
    {:ok, state}
  end
end

# In your supervision tree:
children = [{MyApp.Sensors, host: "broker.example.com", client_id: "sensors-1"}]

Every callback has a default, so implement only what you need. The full callback list and the injected helpers are documented in MqttX.SimpleClient and the Client Guide.

Fan-out between clients (MqttX.Server.deliver/3)

The broker does its own routing when you ask it to. Authorize the publish in handle_publish/4, then hand it to deliver/3: it matches every session's subscriptions on the listener, applies each subscription's options, sends to connected clients and queues QoS 1/2 for persistent sessions that are away:

defmodule MyApp.MqttHandler do
  use MqttX.Server

  def init(_), do: %{client_id: nil}

  def handle_connect(client_id, _creds, _info, state),
    do: {:ok, %{state | client_id: client_id}}

  def handle_publish(topic, payload, opts, state) do
    if MyApp.ACL.may_publish?(state.client_id, topic) do
      MqttX.Server.deliver(topic, payload, Map.put(opts, :publisher, state.client_id))
      {:ok, state}
    else
      {:error, :unauthorized, state}
    end
  end

  def handle_subscribe(topics, state), do: {:ok, Enum.map(topics, & &1.qos), state}
  def handle_disconnect(_reason, _state), do: :ok
end

# From anywhere in your app, by the listener's :name (or port):
MqttX.Server.start_link(MyApp.MqttHandler, [], name: MyApp.Broker, port: 1883)
MqttX.Server.deliver(MyApp.Broker, "device-123/cmd", "reboot", %{qos: 1})

Pushing to one connection from your app

To bridge from your application (Phoenix.PubSub, a GenServer, an Oban worker, …) to one connected MQTT client, send a message to its connection process and return a {:publish, ...} tuple from handle_info/2:

defmodule MyApp.MqttHandler do
  use MqttX.Server

  def init(_), do: %{}

  def handle_connect(client_id, _creds, _info, state) do
    Phoenix.PubSub.subscribe(MyApp.PubSub, "client:#{client_id}")
    {:ok, state}
  end

  def handle_publish(_topic, _payload, _opts, state), do: {:ok, state}
  def handle_subscribe(topics, state), do: {:ok, Enum.map(topics, & &1.qos), state}
  def handle_disconnect(_reason, _state), do: :ok

  def handle_info({:downlink, topic, payload}, state) do
    {:publish, topic, payload, %{qos: 1, retain: false}, state}
  end
end

Then anywhere in your app:

Phoenix.PubSub.broadcast(MyApp.PubSub, "client:device-123",
  {:downlink, "device-123/cmd", "reboot"})

When the topic matches one of the client's subscriptions, the delivery honors that subscription's options: QoS is capped at the granted QoS, retain is forwarded only for Retain-As-Published subscriptions, and subscription identifiers are attached. Pass publisher: originating_client_id in the opts to honor no_local. A publish matching no subscription is sent exactly as given.

MQTT 5.0 persistent sessions (resume after disconnect)

In MQTT 5.0 the client tells the broker how long to keep its session via :session_expiry_interval and resumes by reconnecting with the same client_id and clean_session: false:

{:ok, client} = MqttX.Client.connect(
  host: "broker.example.com",
  client_id: "device-imei-490154203237518",
  protocol_version: 5,
  clean_session: false,
  connect_properties: %{session_expiry_interval: 3600},
  session_store: MqttX.Session.ETSStore
)

A spec-compliant broker queues QoS 1/2 messages while the client is offline (up to 1 hour in this example) and replays them on reconnect. Note that MqttX's own broker does not implement offline queueing — see MqttX.Server if you are running MqttX as the broker.

Common Pitfalls

Transport Adapters

MqttX supports pluggable transports:

MqttX.Server.start_link(
  MyHandler,
  [],
  transport: MqttX.Transport.ThousandIsland,
  port: 1883
)

Ranch

MqttX.Server.start_link(
  MyHandler,
  [],
  transport: MqttX.Transport.Ranch,
  port: 1883,
  # :ranch_tcp (default) or :ranch_ssl — note this option was named
  # `:transport` before v0.11.0, which collided with the adapter selector above
  ranch_transport: :ranch_tcp
)

WebSocket

# ws://
MqttX.Server.start_link(
  MyHandler,
  [],
  transport: MqttX.Transport.WebSocket,
  port: 8083
)

# wss://
MqttX.Server.start_link(
  MyHandler,
  [],
  transport: MqttX.Transport.WebSocket,
  port: 8084,
  scheme: :https,
  transport_options: [certfile: "/etc/mqttx/cert.pem", keyfile: "/etc/mqttx/key.pem"]
)

Payload Codecs

Built-in payload codecs for message encoding/decoding:

JSON (Erlang/OTP 27+)

Uses the built-in Erlang JSON module:

{:ok, json} = MqttX.Payload.JSON.encode(%{temp: 25.5})
{:ok, data} = MqttX.Payload.JSON.decode(json)

Protobuf

{:ok, binary} = MqttX.Payload.Protobuf.encode(my_proto_struct)
{:ok, struct} = MqttX.Payload.Protobuf.decode(binary, MyProto.Message)

Raw (Pass-through)

{:ok, binary} = MqttX.Payload.Raw.encode(<<1, 2, 3>>)
{:ok, binary} = MqttX.Payload.Raw.decode(<<1, 2, 3>>)

Publishing with a codec

Pass the term and the codec; the payload is encoded and the codec's MQTT 5.0 payload_format_indicator / content_type go out with it:

MqttX.Client.publish(client, "sensors/room1", %{temp: 21.5},
  qos: 1,
  codec: MqttX.Payload.JSON
)

See the Codec guide.

Topic Routing

The server includes a topic router with wildcard support:

alias MqttX.Server.Router

router = Router.new()
router = Router.subscribe(router, "sensors/+/temp", client_ref, qos: 1)
router = Router.subscribe(router, "alerts/#", client_ref, qos: 0)

# Find matching subscriptions
matches = Router.match(router, "sensors/room1/temp")
# => [{client_ref, %{qos: 1}}]

Protocol Support

All 15 packet types are supported:

Compliance

Fully compliant with MQTT 3.1, 3.1.1, and 5.0 specifications:

Validated against Mosquitto (104 automated protocol tests across TCP and WebSocket) and EMQX Cloud (49 interop tests covering all QoS levels, properties, session persistence, and subscription options).

MQTT 5.0 Server Features

Server CONNACK properties (sent to MQTT 5.0 clients):

Property Default Configurable
shared_subscription_available 1 No
topic_alias_maximum 100 Yes (transport_opts)
receive_maximum 65535 Yes (transport_opts)
retain_available 1 No
wildcard_subscription_available 1 No
subscription_identifier_available 1 No
server_keep_alive Not sent Yes (transport_opts)
maximum_packet_size Not sent Yes (transport_opts)
session_expiry_interval Not sent Sent when the client asked for more than max_session_expiry_interval (transport_opts)

transport_opts configuration:

MqttX.Server.start_link(
  MyHandler,
  [transport_opts: %{
    server_keep_alive: 30,           # override client keepalive (v5)
    topic_alias_maximum: 100,        # max topic aliases
    receive_maximum: 65535,          # max inflight QoS>0
    max_packet_size: 256_000,        # reject oversized packets
    max_outbound_queue: 10_000,      # publishes queued per session (window + offline)
    max_session_expiry_interval: 86_400  # longest a session outlives its connection
  }],
  transport: MqttX.Transport.ThousandIsland,
  port: 1883
)

handle_connect callback:

The optional 4-arity handle_connect/4 receives connection metadata separately from credentials:

# credentials (both arities):
%{username: "device_imei", password: "secret"}

# connect_info (4-arity only):
%{protocol_version: 5, keep_alive: 50, authentication_method: nil,
  session_present: true, subscriptions: [%{topic: "sensors/#", qos: 1, ...}]}

Use handle_connect/4 to log the protocol version, make version-specific decisions, or see which session the client is resuming (session_present and the subscriptions coming back with it). Existing handle_connect/3 handlers continue to work unchanged.

Sessions: the broker keeps Session State per listener, in ETS. A client connecting with Clean Start 0 and a Session Expiry Interval (or CleanSession 0 on 3.1.1) gets session_present: true on reconnect, its subscriptions back, unacknowledged QoS 1/2 messages re-sent, and whatever deliver/3 queued while it was away. Sessions are kept for at most max_session_expiry_interval (default one day) and live in memory on one node.

Performance

Architected to scale from tens of thousands to roughly a million concurrent devices on a single BEAM node, depending on hardware and workload. Each connection is a lightweight Erlang process (~20KB of BEAM state plus ~4-8KB of kernel socket buffers), and the hot paths are optimized for high message throughput:

Capacity depends on hardware, so these figures are anchored to instance sizes rather than given as a single ceiling. Devices are the practical targets from the capacity planning method, which reserves headroom for the runtime, ETS, and reconnect storms:

Instance Idle-ish devices (~1 msg/min) Chatty devices (1 msg/sec) Binding constraint
1 vCPU / 2 GB ~50,000 ~15,000 RAM / CPU
4 vCPU / 16 GB ~400,000 ~60,000 RAM, fds, kernel memory
16 vCPU / 128 GB ~1,000,000 ~160,000 ETS contention, accept rate

Beyond roughly 500K connections per node the limit stops being RAM and becomes kernel socket memory, file descriptors, and contention on shared ETS tables — none of which improve with more cores — so horizontal scaling usually beats a larger instance. Message-rate figures assume small QoS 0 payloads, plaintext TCP, and a handler doing negligible work.

The device counts are estimates from architectural analysis — nobody has load-tested a million connections here. Per-message cost, on the other hand, is measured end to end (below). See the Performance & Scaling guide for the sizing formula, per-vCPU throughput, and the caveats behind each number.

Codec benchmarks vs mqtt_packet_map — measured, on an Apple M4 Pro:

Operation MqttX mqtt_packet_map Result
PUBLISH encode 5.05M ips 1.72M ips 2.9x faster
SUBSCRIBE encode 3.42M ips 0.82M ips 4.2x faster
PUBLISH decode 2.36M ips 2.25M ips ~same

End-to-end, MqttX client ↔ MqttX broker over loopback — measured on the same machine with mix run bench/e2e.exs (one publisher; fan-out is one PUBLISH forwarded to 50 subscribed connections):

Scenario 100 B 4 KiB 64 KiB
Uplink QoS 0 164k msg/s 100k msg/s 25k msg/s (1.6 GB/s)
Uplink QoS 1 (every PUBACK awaited) 98k msg/s 82k msg/s 18k msg/s
Fan-out 1 → 50 450k deliveries/s 425k deliveries/s 117k deliveries/s (7.7 GB/s)

WebSocket figures, the receive-buffer and retained-store measurements, and how to run the suites yourself are in Measured Throughput; mix test --include load runs a many-client completeness check.

The performance guide also covers VM tuning (+P/+Q limits, which you must raise past 65K connections), OS tuning, and multi-node deployment.

Guides

Getting Started · Why MQTT for IoT · Client · Server / Broker · Packet Codec · Telemetry · Performance & Scaling

API Reference

MqttX.Client

Function Description
connect(opts) Connect to an MQTT broker
connect_supervised(opts) Connect under MqttX.Client.Supervisor with crash recovery
list() List all registered client connections
whereis(client_id) Look up a connection by client_id
publish(client, topic, payload, opts \\ []) Publish a message. Options: :qos (0-2), :retain (boolean), :properties (MQTT 5.0)
subscribe(client, topics, opts \\ []) Subscribe to topics. Options: :qos (0-2), :no_local, :retain_as_published, :retain_handling, :properties (MQTT 5.0)
unsubscribe(client, topics) Unsubscribe from topics
disconnect(client) Disconnect from the broker
connected?(client) Check if client is connected

Connect Options:

Option Description Default
:host Broker hostname required
:port Broker port 1883 / 8883 / 8083 / 8084
:client_id Client identifier required
:username Authentication username nil
:password Authentication password nil
:clean_session Start fresh session true
:keepalive Keep-alive interval (seconds) 60
:await_connect Block until the first CONNACK resolves (see Common Pitfalls) false
:protocol_version MQTT protocol level: 3, 4 (3.1.1) or 5 5
:transport :tcp, :ssl, :ws, or :wss :tcp
:ssl_opts SSL options, merged over the secure baseline (see TLS/SSL) []
:ws_path WebSocket path for :ws or :wss "/mqtt"
:proxy HTTP CONNECT proxy, e.g. [host: "proxy.corp", port: 3128, auth: {"u", "p"}] nil
:max_inflight Max pending QoS 1/2 messages 100
:max_packet_size Reject inbound packets declaring more than this (:infinity disables) 1 MiB
:will_topic / :will_payload / :will_qos / :will_retain / :will_properties Last Will & Testament nil / "" / 0 / false / %{}
:connect_properties MQTT 5.0 CONNECT properties (e.g. %{session_expiry_interval: 3600}) %{}
:session_store Session store module nil
:handler Callback module for messages nil
:handler_state Initial handler state nil

Unacknowledged QoS 1/2 publishes are held until the broker acknowledges them and re-sent only when a session is resumed after a reconnect (MQTT §4.4) — never on a live connection. :retry_interval is deprecated and ignored since 0.12.0.

MqttX.Server

Function Description
start_link(handler, handler_opts, opts) Start an MQTT server. Options: :transport, :port, :name, :rate_limit, :ip. Protocol options go in handler_opts under :transport_opts
deliver(topic, payload, opts) From a callback: fan a message out to every session subscribed to topic on this listener, queueing QoS 1/2 for sessions that are away. Returns {:ok, n}
deliver(listener, topic, payload, opts) The same from any process, by the listener's :name or port
disconnect(pid, reason_code, properties) Send DISCONNECT to a connection and close it

Callbacks:

Callback Description
init(opts) Initialize handler state
handle_connect(client_id, credentials, state) Handle client connection. Return {:ok, state} or {:error, reason_code, state}
handle_connect(client_id, credentials, connect_info, state) (optional) Same as above with connection metadata (protocol_version, keep_alive, authentication_method, session_present, subscriptions). Takes precedence over 3-arity when defined
handle_publish(topic, payload, opts, state) Handle incoming PUBLISH. Return {:ok, state}
handle_subscribe(topics, state) Handle SUBSCRIBE. Return {:ok, granted_qos_list, state}
handle_unsubscribe(topics, state) Handle UNSUBSCRIBE. Return {:ok, state}
handle_disconnect(reason, state) Handle client disconnection. Return :ok
handle_session_expired(client_id, state) (optional) The client's session ended after its connection did; the broker has dropped its own state for it. Return :ok
handle_info(message, state) Handle custom messages. Return {:ok, state}, {:publish, topic, payload, state}, {:publish, topic, payload, opts, state}, {:disconnect, reason_code, state}, {:disconnect, reason_code, properties, state}, or {:stop, reason, state}

MqttX.Packet.Codec

Function Description
encode(version, packet) Encode a packet to binary. Returns {:ok, binary}
decode(version, binary) Decode a packet from binary. Returns {:ok, {packet, rest}} or {:error, reason}
encode_iodata(version, packet) Encode to iodata (more efficient). Returns {:ok, iodata}

MqttX.Server.Router

Function Description
new() Create a new empty router
subscribe(router, filter, client, opts) Add a subscription. Options: :qos
unsubscribe(router, filter, client) Remove a subscription
unsubscribe_all(router, client) Remove all subscriptions for a client
match(router, topic) Find matching subscriptions. Returns [{client, opts}]

MqttX.Topic

Function Description
validate(topic) Validate and normalize a topic. Returns {:ok, normalized} or {:error, :invalid_topic}
validate_publish(topic) Validate topic for publishing (no wildcards)
matches?(filter, topic) Check if a filter matches a topic
normalize(topic) Normalize topic to list format
flatten(normalized) Convert normalized topic back to binary string
wildcard?(topic) Check if topic contains wildcards

Roadmap

Feature Status Description
Full MQTT 5.0 Compliance Done Complete server and client compliance — all CONNACK properties, enhanced AUTH, flow control, server redirect
WebSocket Transport Done MQTT over WebSocket via Bandit (ws:// and wss://)
Broker Validation Done 104 Mosquitto tests (TCP + WebSocket) + 49 EMQX Cloud interop tests
Clustering Planned Distributed router across Erlang nodes via pg
Session Persistence (Server) Done Session State per listener in ETS: session_present, subscriptions and in-flight QoS 1/2 resume on reconnect, MqttX.Server.deliver/3 queues for sessions that are away. In memory on one node; a durable/shared store is part of Clustering
MQTT 5.0 Enhanced Auth Done Challenge–response exchange before CONNACK and re-authentication, on both client and broker, via handle_auth/3; the mechanism itself (SCRAM, tokens, …) is the handler's — no built-in providers
Telemetry Docs Done See the Telemetry guide
Property-based Tests Done StreamData round-trips + decode/encode fuzzing of the codec
End-to-end Load Tests Done Benchee suites in bench/ (codec, router/retained/buffer, client↔broker over loopback) + mix test --include load; numbers in the Performance guide

License

Apache-2.0