MqttX

Hex.pmDocsCI

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. Also rendered on hexdocs.

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.11.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()}
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",
clean_session: false, # maintain session across reconnects
session_store: MqttX.Session.ETSStore # built-in ETS store
)

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.

Publishing from a server callback (broadcast / fan-out)

To bridge from your application (Phoenix.PubSub, a GenServer, an Oban worker, โ€ฆ) to a connected MQTT client, send a message to the 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"})

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-350457794457489",
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

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

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>>)

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):

PropertyDefaultConfigurable
shared_subscription_available1No
topic_alias_maximum100Yes (transport_opts)
receive_maximum65535Yes (transport_opts)
retain_available1No
wildcard_subscription_available1No
subscription_identifier_available0No
server_keep_aliveNot sentYes (transport_opts)
maximum_packet_sizeNot sentYes (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
qos2_retry_interval: 5000, # QoS 2 retry timer (ms)
qos2_max_retries: 3 # QoS 2 max retries before drop
}],
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}

Use handle_connect/4 to log protocol version or make version-specific decisions. Existing handle_connect/3 handlers continue to work unchanged.

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:

InstanceIdle-ish devices (~1 msg/min)Chatty devices (1 msg/sec)Binding constraint
1 vCPU / 2 GB~50,000~15,000RAM / CPU
4 vCPU / 16 GB~400,000~60,000RAM, fds, kernel memory
16 vCPU / 128 GB~1,000,000~160,000ETS 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.

These are estimates from architectural analysis and the codec benchmarks below โ€” not end-to-end load tests. 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:

OperationMqttXmqtt_packet_mapResult
PUBLISH encode5.05M ips1.72M ips2.9x faster
SUBSCRIBE encode3.42M ips0.82M ips4.2x faster
PUBLISH decode2.36M ips2.25M ips~same

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

FunctionDescription
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:

OptionDescriptionDefault
:hostBroker hostnamerequired
:portBroker port1883 / 8883 / 8083 / 8084
:client_idClient identifierrequired
:usernameAuthentication usernamenil
:passwordAuthentication passwordnil
:clean_sessionStart fresh sessiontrue
:keepaliveKeep-alive interval (seconds)60
:await_connectBlock until the first CONNACK resolves (see Common Pitfalls)false
:protocol_versionMQTT protocol level: 3, 4 (3.1.1) or 55
:transport:tcp, :ssl, :ws, or :wss:tcp
:ssl_optsSSL options, merged over the secure baseline (see TLS/SSL)[]
:ws_pathWebSocket path for :ws or :wss"/mqtt"
:proxyHTTP CONNECT proxy, e.g. [host: "proxy.corp", port: 3128, auth: {"u", "p"}]nil
:retry_intervalQoS retry interval (ms)5000
:max_inflightMax pending QoS 1/2 messages100
:max_packet_sizeReject inbound packets declaring more than this (:infinity disables)1 MiB
:will_topic / :will_payload / :will_qos / :will_retain / :will_propertiesLast Will & Testamentnil / "" / 0 / false / %{}
:connect_propertiesMQTT 5.0 CONNECT properties (e.g. %{session_expiry_interval: 3600})%{}
:session_storeSession store modulenil
:handlerCallback module for messagesnil
:handler_stateInitial handler statenil

MqttX.Server

FunctionDescription
start_link(handler, handler_opts, opts)Start an MQTT server. Options: :transport, :port, :rate_limit, :ip. Protocol options go in handler_opts under :transport_opts

Callbacks:

CallbackDescription
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). 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) MQTT 5.0 session expiry elapsed after disconnect. 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

FunctionDescription
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

FunctionDescription
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

FunctionDescription
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

FeatureStatusDescription
Full MQTT 5.0 ComplianceDoneComplete server and client compliance โ€” all CONNACK properties, enhanced AUTH, flow control, server redirect
WebSocket TransportDoneMQTT over WebSocket via Bandit (ws:// and wss://)
Broker ValidationDone104 Mosquitto tests (TCP + WebSocket) + 49 EMQX Cloud interop tests
ClusteringPlannedDistributed router across Erlang nodes via pg
Session Persistence (Server)PlannedServer-side session persistence (currently client-only)
MQTT 5.0 Enhanced AuthPartialAUTH exchange and re-authentication implemented; no built-in SCRAM/external providers
Telemetry DocsDoneSee the Telemetry guide
Property-based TestsDoneStreamData round-trips + decode/encode fuzzing of the codec
End-to-end Load TestsPlannedBenchee-based throughput validation under realistic workloads

License

Apache-2.0