Guidance for AI coding assistants integrating MqttX into a project. Read this before suggesting code that uses this library — it captures the mental model and the mistakes agents most often make.
Modifying MqttX itself? See
CONTRIBUTING.mdfor repo layout, test commands, and deferred work.
What MqttX is
A single hex package ({:mqttx, "~> 0.11.0"}) that ships three independent
pieces — choose only what you need:
| Piece | Module | Use when |
|---|---|---|
| Wire codec | MqttX.Packet.Codec | You have your own transport and just need encode/decode |
| Client | MqttX.Client | Your app connects to an MQTT broker (AWS IoT, EMQX, HiveMQ, Mosquitto, …) |
| Broker | MqttX.Server | You are running an MQTT broker (e.g. an IoT backend that owns its devices) |
Most apps want only the client. Build a broker only when you need to own the message routing — for talking to a third-party broker, the client is sufficient on its own.
Picking a transport
The codec is dep-free; transports are optional packages:
| Transport | Add to deps |
|---|---|
TCP / TLS client (tcp / ssl) | nothing extra |
Any client transport behind an HTTP CONNECT proxy | nothing extra — pass proxy: [host:, port:, auth:] |
WebSocket client (ws / wss) | nothing extra (RFC 6455 client is built-in) |
| TCP server | {:thousand_island, "~> 1.4"} (preferred) or {:ranch, "~> 2.2"} |
| WebSocket server | {:bandit, "~> 1.6"} + {:websock_adapter, "~> 0.5 or ~> 0.6"} |
If MqttX.Transport.ThousandIsland (or Ranch, or Bandit) fails at server
startup with an undefined-module / undefined-function error, the
corresponding optional dep is missing from mix.exs — that's the single most
common setup mistake.
Mental model — client side
your code ──MqttX.Client.subscribe──▶ broker
your code ──MqttX.Client.publish───▶ broker
│
MqttX.Client ◀─PUBLISH──────┘
│
▼
handler_module.handle_mqtt_event(:message, {topic, payload, packet}, state)The client is a GenServer. You don't poll it — it pushes events to a handler module.
MqttX.Client.connect/1is asynchronous: it returns as soon as the process starts, before CONNACK. Asubscribe/publishissued immediately after it returns gets{:error, :not_connected}. Either act in the:connectedhandler event, or passawait_connect: trueto haveconnect/1block until the first attempt resolves (returning{:error, reason}if it failed). The async default exists so a client can start before the broker is reachable and retry with backoff.subscribe/3is synchronous and waits for SUBACK before returning{:ok, granted_qos_list}.publish/4returns:okas soon as the packet is written to the socket (it does not wait for PUBACK at QoS 1/2 — those acks are tracked in the background and surfaced via the handler module).If the connection has dropped (and not yet reconnected),
subscribe,publish, andunsubscribereturn{:error, :not_connected}immediately — they do not queue.Resubscription is automatic (since 0.11.0): granted subscriptions are tracked and replayed after any reconnect whose CONNACK reports
session_present: false. You do not need to resubscribe in the:connectedhandler.The handler module implements
handle_mqtt_event/3, which receives:(:connected, %{properties: props, session_present: bool}, state)— after CONNACK success(:disconnected, reason, state)—reasonis:closed,:pingresp_timeout,{:error, posix},{:protocol_error, reason},{:server_disconnect, code, props}, or{:connack_error, code, info}(:message, {topic, payload, full_packet}, state)— for each PUBLISH(:publish_error, {topic, packet_id, reason_code}, state)— the broker rejected one of your QoS 1/2 publishes (e.g.0x87not authorized)
Give the handler a catch-all clause (
handle_mqtt_event(_e, _d, state)) so new event types don't raise. A raising handler is caught and logged rather than killing the connection, but its state update is lost.
topic arrives as a list of segments (["sensors", "room1", "temp"]),
not the original string — use Enum.join(topic, "/") if you need to round-trip.
Mental model — broker side
use MqttX.Server defines a behaviour with one callback per MQTT verb:
device ──CONNECT──▶ handle_connect(client_id, creds, info, state)
device ──SUBSCRIBE─▶ handle_subscribe(topics, state) → grant per-topic QoS
device ──PUBLISH──▶ handle_publish(topic, payload, opts, state)
device ──DISCONNECT▶ handle_disconnect(reason, state)
your app ──send(broker_pid, msg)─▶ handle_info(msg, state)
└─▶ {:publish, topic, payload, state} (fan out to device)Servers are per-connection state machines — state is one device's state.
For app-wide state (subscriber registry, message bus), use Phoenix.PubSub or
:pg from inside the callbacks.
Idiomatic patterns
Receive messages on the client
defmodule MyApp.MqttHandler do
def handle_mqtt_event(:connected, _info, state), do: state
def handle_mqtt_event(:disconnected, _reason, state), do: state
def handle_mqtt_event(:message, {topic, payload, _packet}, state) do
Logger.info("got #{payload} on #{Enum.join(topic, "/")}")
state
end
def handle_mqtt_event(:publish_error, {_topic, _packet_id, reason_code}, state) do
Logger.warning("broker rejected publish: #{inspect(reason_code)}")
state
end
# Catch-all so new event types don't raise
def handle_mqtt_event(_event, _data, state), do: state
end
{:ok, c} = MqttX.Client.connect(
host: "broker.example.com",
client_id: "my-app-#{node()}",
handler: MyApp.MqttHandler,
handler_state: %{},
# connect/1 is async by default; block so the subscribe below succeeds
await_connect: true
)
{:ok, _granted} = MqttX.Client.subscribe(c, "sensors/#", qos: 1)Module-based client (use MqttX)
Equivalent to the handler-module pattern above, but callbacks, connection and
supervision live in one module. Callbacks run in the module's own process,
so publishing from inside one is safe (a GenServer.call back into the
connection would otherwise deadlock):
defmodule MyApp.Sensors do
use MqttX
@impl true
def init(_opts), do: {:ok, %{}}
@impl true
def handle_connected(_info, state) do
subscribe("sensors/#", qos: 1)
{:ok, state}
end
@impl true
def handle_message(topic, payload, _packet, state) do
publish("ack/" <> Enum.join(topic, "/"), payload, qos: 1)
{:ok, state}
end
end
children = [{MyApp.Sensors, host: "broker.example.com", client_id: "sensors-1"}]Callbacks: init/1, handle_message/4, handle_connected/2,
handle_disconnected/2, handle_publish_error/4, handle_info/2 — all
defaulted, each returning {:ok, state} or {:stop, reason, state}. Note
these are not the same as handle_mqtt_event/3; a module using
use MqttX implements these instead.
Bridge MQTT broker ↔ Phoenix.PubSub (fan-out)
defmodule MyBroker do
use MqttX.Server
def init(_), do: %{}
def handle_connect(client_id, _creds, _info, state) do
Phoenix.PubSub.subscribe(MyApp.PubSub, "downlink:#{client_id}")
{:ok, Map.put(state, :client_id, client_id)}
end
def handle_publish(topic, payload, _opts, state) do
Phoenix.PubSub.broadcast(MyApp.PubSub, "uplink", {state.client_id, topic, payload})
{:ok, state}
end
def handle_info({:downlink, topic, payload}, state) do
{:publish, topic, payload, %{qos: 1, retain: false}, state}
end
def handle_subscribe(topics, s), do: {:ok, Enum.map(topics, & &1.qos), s}
def handle_disconnect(_r, _s), do: :ok
end
# elsewhere in your app:
Phoenix.PubSub.broadcast(MyApp.PubSub, "downlink:device-123",
{:downlink, "device-123/cmd", "reboot"})MQTT 5.0 persistent sessions (resume QoS 1/2 across reconnects)
MqttX.Client.connect(
host: "broker.example.com",
client_id: "stable-id-not-uuid", # MUST be stable across reconnects
protocol_version: 5, # required for properties
clean_session: false,
connect_properties: %{session_expiry_interval: 3600},
session_store: MqttX.Session.ETSStore # client-side persistence
)Custom auth (reject CONNECT)
def handle_connect(client_id, %{username: u, password: p}, _info, state) do
case MyApp.Auth.verify(u, p) do
{:ok, _} -> {:ok, state}
:error -> {:error, 0x86, state} # 0x86 = Bad User Name or Password
end
endReason codes worth knowing: 0x80 Unspecified, 0x86 Bad credentials,
0x87 Not authorized, 0x95 Packet too large, 0x97 Quota exceeded.
Full list in MQTT 5.0 §2.4.
Common mistakes (do not do these)
- Assuming TLS is unverified. Since 0.11.0
:ssl/:wssverify the broker certificate by default (OS trust store + SNI + hostname check). Pointing at a self-signed/expired dev broker now fails — supply the CA withssl_opts: [cacertfile: ...], or opt out deliberately withssl_opts: [verify: :verify_none]. Don't "fix" a verification failure by reaching forverify_nonein production code. - Wildcards in PUBLISH.
+and#are subscribe-only — publishing them is a Protocol Error and the broker will disconnect. Validate withMqttX.Topic.validate_publish/1for any topic that mixes user input. - Using
handle_publish/4on the client. That's a server callback. The client receives PUBLISHes viahandle_mqtt_event(:message, …). They are not the same callback — agents confuse this constantly. clean_session: falsewithout:session_store. The flag tells the broker to keep state. For the client to remember in-flight QoS 1/2 across reconnects, also pass:session_store.- Random
client_idper connect. Sessions, retained messages, and shared subscriptions are all keyed byclient_id.UUID.uuid4()per connect silently breaks all three. - Picking QoS 2 by default. QoS 2 is a 4-packet handshake (PUBLISH → PUBREC → PUBREL → PUBCOMP) — use it only when duplicate delivery would cause real harm (financial transactions). For telemetry use QoS 0; for commands use QoS 1.
- Expecting
#to match$SYS/.... Per MQTT §4.7.2,$-prefixed topics require explicit subscription.subscribe(c, "#")does not receive$SYS/broker/uptime. - Assuming MqttX's own broker keeps sessions.
MqttX.Serverdoes not queue messages for disconnected clients and always answerssession_present: false—clean_session: falsebuys you nothing against this broker (it works as expected against EMQX, Mosquitto, etc.). Model durable state in your handler with Phoenix.PubSub or a database. - Treating
MqttX.Server.Routeras a public pubsub. It is the broker's internal subscription index. To send messages between processes, use Phoenix.PubSub or:pg, then bridge via the broker callback. - Setting
keepalivehigher than the cloud-proxy idle timeout. Fly.io, AWS NLB, Azure Front Door all idle TCP at ~60s. Use ≤ 30s for cellular IoT or setserver_keep_alive: 30intransport_optsto enforce it server-side for v5 clients. - Assuming retained = "all past messages". Retain stores the last message per topic only — it's a "current state" mechanism, not a history.
- Ignoring CONNACK reason codes. By default
connect/1returns{:ok, pid}before the handshake completes, so a rejection arrives at the handler as(:disconnected, {:connack_error, reason_code, %{server_reference: ref_or_nil}})— handle it there (e.g.0x84unsupported version,0x86bad credentials,0x9Cuse the server reference to redirect). Withawait_connect: truethe same tuple is returned fromconnect/1instead. Non-retryable codes stop the client rather than reconnecting forever.
Decision helpers
- Topic structure: prefer hierarchy that matches your subscription
patterns.
tenant/{id}/device/{id}/telemetry/{metric}letstenant/+/device/+/telemetry/#fan out cleanly. Avoid encoding multiple dimensions into one segment. - Payload format: Protobuf for cellular IoT (5-10× smaller than JSON); JSON for backend interop where payload size doesn't dominate.
max_inflighton the client: default 100. Raise only if your broker advertises a highreceive_maximumand you're seeing throughput limits; otherwise increasing it just delays backpressure.- Shared subscriptions (
$share/group/topic): use to load-balance consumers, not to broadcast. Each message goes to exactly one subscriber in the group. Subscribing to the same topic from N clients without$sharedelivers N copies.
Where to find authoritative answers
- Public API: hexdocs.pm/mqttx —
@docstrings on every public function - Worked examples:
README.md("Common Patterns") and the integration tests attest/mqttx/integration_test.exsandtest/mqttx/interop_emqx_test.exs - Recent behaviour changes:
CHANGELOG.md— the[0.11.0]entry is the important one: secure-by-default TLS, packet/idle limits, automatic resubscription, anduse MqttX. The[0.10.0]entry documents the spec sweep that tightened many edge cases older internet examples don't reflect - MQTT spec: OASIS 3.1.1 /
5.0 — section references in
this codebase (e.g.
§3.3.1.2) point here