MQTT 5 for Industrial, The Features That Actually Pay Off
TL;DR — Reason codes, shared subscriptions, message expiry and topic aliases earn their slot. User properties are a useful escape hatch. Request/response is fine but rarely worth the complexity.
MQTT 3.1.1 is still everywhere and that’s mostly fine. It works, it’s understood, and most industrial gear speaks it. But MQTT 5.0 has been a stable target since 2019, every major broker supports it, and a few of the new features actually change how you design a system. After about three years of running MQTT 5 in plant networks, here’s my opinionated take on what’s worth turning on.
I’ll cover what each feature does, when to use it, and what to do instead if you can’t. I’m assuming EMQX 5.7, HiveMQ 4.28 or Mosquitto 2.0 on the broker side. Edge clients in Python with paho-mqtt 2.x or Go with eclipse/paho.golang.
The frame I use, and I think it’s the right one, is that every MQTT 5 feature should justify itself against the cost of teaching three different vendor field engineers what it does at two in the morning.
Shared Subscriptions
This is the feature I’d argue is non-negotiable. A shared subscription lets multiple consumers join a group on the same topic filter, and the broker load-balances messages between them. In MQTT 3, you faked this with sticky sessions or by sharding topics yourself. In MQTT 5 it’s built in:
$share/<group>/<topic-filter>
A consumer pool subscribing to $share/ingest/plant/+/+/+/+/+/+ gets each message delivered exactly once to one of them. Lose a consumer, the rest pick up. This is how you build horizontally scalable ingest workers without inventing a coordination layer. EMQX 5.7 supports several load-balancing strategies, including round robin, random and hash. Use hash when you need per-device ordering preserved within a partition.
Here’s a Python ingest worker:
import paho.mqtt.client as mqtt
def on_message(client, userdata, msg):
# Idempotent write. The shared group guarantees a single delivery
# but consumers should still be safe under broker failover.
write_to_kafka(msg.topic, msg.payload, msg.properties)
c = mqtt.Client(client_id="ingest-7", protocol=mqtt.MQTTv5)
c.on_message = on_message
c.connect("broker.plant.local", 1883)
c.subscribe("$share/ingest/plant/+/+/+/+/+/+", qos=1)
c.loop_forever()
If you take one thing from this post, take this one.
Reason Codes
In MQTT 3, when a publish failed or a subscription was rejected, the broker either disconnected you or silently dropped the message. Debugging that in production is miserable. MQTT 5 returns a reason code on almost every interaction. 0x87 is “Not Authorized”. 0x97 is “Quota Exceeded”. 0x9F is “Connection Rate Exceeded”. You can finally answer “why did my client get kicked”.
This single change has saved me more hours than any other. Configure your clients to log reason codes at WARN level. When a field gateway suddenly stops publishing, you know in five seconds whether it’s a credential rotation, a quota hit, or a topic ACL change.
Message Expiry
Every publish can carry a Message Expiry Interval. If a client is offline and the broker has been queueing messages, anything older than the expiry gets dropped. For telemetry this is a godsend. Nobody wants a temperature reading from forty minutes ago landing in the historian out of order when a gateway reconnects.
Set expiry to roughly the cadence of the sensor times some small multiple. A 1 Hz tag with a 10-second expiry will discard stale points but keep anything close enough to be useful for trending:
from paho.mqtt.properties import Properties
from paho.mqtt.packettypes import PacketTypes
props = Properties(PacketTypes.PUBLISH)
props.MessageExpiryInterval = 10
client.publish("plant/jkt1/line3/temp1/c", payload="72.4", qos=1, properties=props)
There’s a real subtlety here. Expiry only applies to queued messages. For live delivery to a subscribed consumer there’s no effect. If you want to drop late data at the consumer too, you have to check the timestamp yourself. Most teams forget that and assume expiry covers both cases.
Topic Aliases
Topic strings in industrial namespaces get long. plant/jkt1/packaging/line3/scale4/weight_kg is 41 bytes before the payload. Over cellular, that’s expensive. MQTT 5 lets the publisher map a topic to a short integer alias on the first publish, then send the integer thereafter. The broker remembers the mapping for the duration of the session.
For LTE-connected gateways pushing thousands of points per minute, topic aliases cut bytes-on-wire by 15–30 percent in my measurements. Worth the small client-side complexity. Negotiate TopicAliasMaximum at connect, then use them.
User Properties
User properties are arbitrary key/value pairs attached to a message. They’re the MQTT 5 equivalent of HTTP headers. I use them sparingly, mostly for two things. Carrying a trace ID end-to-end through the broker, and tagging messages with a content type when payloads might be JSON, MessagePack or protobuf depending on the device.
The temptation is to stuff metadata into user properties to avoid changing payloads. Resist. Anything load-bearing should be in the payload schema, where your stream processor can validate it. User properties are the escape hatch, not the design.
Request/Response
MQTT 5 added formal request/response semantics with ResponseTopic and CorrelationData. It’s clean. It’s also rarely the right tool. If you’re doing request/response over a message bus, you usually want a different bus. Configuration pushes, command-and-control of actuators, those are fine as fire-and-forget with an ack on a separate topic. The handful of cases where request/response made sense for me were diagnostics, where a backend service asks a gateway for a live status snapshot.
If you do use it, watch session lifetime. A request waiting on a response topic from a disconnected client will sit there forever unless you set message expiry. I’ve seen broker memory creep from forgotten correlation IDs.
Common Pitfalls
- Mixing MQTT 3 and MQTT 5 clients on the same broker without thinking. Brokers support it, but features like reason codes and properties get downgraded silently when delivered to v3 subscribers. Document which topics are v5-only.
- Assuming Will messages work like you think. The MQTT 5 Will Delay Interval lets you delay the will publish in case the client reconnects quickly. Without it, a flaky cellular link causes will spam every time the radio cycles.
- Over-using Retained messages. They’re for last-known state, not history. A retained message on a high-cardinality topic tree gets expensive fast. EMQX has a separate retained store you can tune, but the better fix is fewer retained topics.
- Ignoring Receive Maximum. The client and broker negotiate how many unacked QoS 1 and 2 messages can be in flight. Defaults are conservative. For high-throughput ingest, bump it.
- Forgetting that QoS is end-to-end only through the broker. The broker downgrades to the subscriber’s max QoS. A QoS 1 publish to a QoS 0 subscriber arrives at QoS 0. That’s by design but bites people.
Wrapping Up
If you’re rolling out MQTT 5 for an industrial workload in 2024, turn on shared subscriptions, log reason codes, set message expiry on telemetry, and use topic aliases over constrained links. Be conservative with user properties. Skip request/response unless you have a specific use for it.
The next post in this series covers scaling EMQX and HiveMQ in production, where these features start to interact with cluster behavior. For the protocol itself, the official OASIS MQTT 5.0 specification is dense but worth bookmarking.
The protocol gives you tools. The architecture is still your job.