MQTT looks tiny from the outside — connect, publish, subscribe, three QoS levels, done. That surface simplicity is why it won IoT. But the difference between a demo and a production deployment is almost entirely in the parts the protocol leaves to you and the broker: how you shape the topic tree, and who is allowed to touch which part of it. Get those two right and the rest of MQTT mostly takes care of itself; get them wrong and you will be re-provisioning device fleets to fix it.

Topics are a contract, not a string#

An MQTT topic is a /-separated, case-sensitive path that exists the moment someone publishes to it — there is no CREATE TOPIC, no schema, no registry. That freedom means the topic tree is designed by whoever publishes first, whether they meant to design it or not. Treat the hierarchy as a public API from day one:

  • Put fixed concepts at fixed depths: org/site/device-type/device-id/channel. Every consumer, ACL rule, and dashboard will key off positions in this path.
  • Keep high-cardinality identifiers (device IDs) at their own level, never embedded in a segment like sensor-4711-temp — wildcards and ACL placeholders can match a level, not a substring.
  • Split directions: devices publish telemetry to devices/{id}/up, and receive commands on devices/{id}/cmd. Symmetric topics make authorization rules symmetric too.
  • No leading slash (it creates an empty first level), no spaces, and put a version segment (v1/) in front of anything you might regret.
  • Topics carry routing, payloads carry data. Encoding values in the topic (…/temp/23.4) floods the broker’s topic index and defeats retained messages.

Wildcards: the query language you already have#

Subscriptions — never publishes — may use two wildcards: + matches exactly one level, # matches any number of levels and must be the last character. Combined with a disciplined hierarchy, they are surprisingly expressive:

SubscriptionMatchesDoes not match
plant1/+/temperatureplant1/oven3/temperatureplant1/hall2/oven3/temperature
plant1/#plant1/hall2/oven3/temperature, plant1/statusplant2/status
+/+/statusplant1/oven3/statusplant1/status (only two levels)
devices/+/updevices/dev-42/updevices/dev-42/up/debug

One subtlety worth knowing: topics beginning with $ (like the broker’s own $SYS/… stats) are excluded from # and + at the root, so a lazy # subscription does not accidentally ingest broker internals.

MQTT 5 adds shared subscriptions on top: several consumers subscribing to $share/group/devices/+/up split the stream between them, Kafka-consumer-group style. That is the standard answer to “one subscriber can’t keep up” — scale the consumers out horizontally without every instance seeing every message.

ACLs: authorization is not authentication#

Authentication answers who is connecting — username/password, client certificates, or a signed token. It is necessary and completely insufficient: an authenticated device can still publish to any topic on the broker unless something says otherwise. That something is the ACL (access control list), and the default posture you want is deny-everything, then grant narrow patterns.

The pattern that makes fleet-scale ACLs manageable is placeholders: rules written once against the client’s identity, expanded per connection. A device whose client ID is dev-42 should publish telemetry only as itself and hear commands only addressed to itself — one rule pair covers the whole fleet:

%% EMQX file-based authorization (acl.conf) — evaluated top-down.
%% ${clientid} / ${username} expand per connection.

{allow, all, publish,   ["devices/${clientid}/up"]}.
{allow, all, subscribe, ["devices/${clientid}/cmd"]}.

%% backend services authenticate as user "ingest" and may fan in everything
{allow, {username, "ingest"}, subscribe, ["devices/+/up"]}.

%% everything not explicitly allowed is refused
{deny, all}.
  • Deny by default and end the list with an explicit deny-all, so a missing rule fails closed instead of open.
  • Never let field devices subscribe with # or publish outside their own subtree — a single compromised device should be able to impersonate exactly one device: itself.
  • ACL the wildcards too: granting subscribe on devices/+/up is a privilege reserved for backend consumers, not something devices inherit.
  • Keep rules in a real source (database, HTTP authorizer, JWT claims) once the fleet is dynamic — files are fine until devices come and go without redeploys.
DEVICESEMQX CLUSTERBACKENDMQTT/TLSMQTT/TLSshared subDevice dev-42pub devices/dev-42/upDevice dev-97sub devices/dev-97/cmdBroker node 1authn + ACLBroker node 2session routingIngest workers$share/ingest/devices/+/upRule enginebridge → Kafka / DB
Each device is confined to its own subtree by placeholder ACL rules; backend consumers fan in with shared subscriptions and the rule engine bridges telemetry onward.

Picking a broker when throughput matters#

All of the above is broker-implemented behavior, so the broker choice is part of the architecture. For high-throughput, high-connection-count systems, EMQX is the option I reach for first: it clusters horizontally to millions of concurrent connections, implements full MQTT 5 (shared subscriptions included), and its authorization chain covers exactly the progression above — file, then database, HTTP, or JWT-claim ACLs with ${clientid}/${username} placeholders — plus a rule engine that bridges topics straight into Kafka or a database, which is usually the next integration you need anyway.

It is not the only good answer. Mosquitto is superb as a single-node broker for small fleets, edge gateways, and development; NanoMQ targets constrained edge hardware; HiveMQ plays the same clustered-enterprise role as EMQX. The selection question is honest scale: a few thousand devices on one site do not need a cluster, and pretending they do just adds operations. But if the roadmap says hundreds of thousands of connections or six-figure messages per second, pick a broker that scales out — retrofitting clustering onto a single-node broker is a migration, not an upgrade.

A topic tree is an API and an ACL is its type system. Design both before the first device ships, because after that, every change is a firmware rollout.