MQTT 5 added features that change how applications behave — metadata in user properties, messages that expire, shorter packets through topic aliases, sessions with a lifetime. Each of them depends on both the client library and the broker implementing it correctly, so test MQTT 5 features end to end before you rely on them.
This article gives you small, runnable tests with MQTT.js 5 (Node.js) and paho-mqtt 2.x (Python). Each test publishes, observes what arrives, and asserts on it — so it can run in CI as well as on your laptop.
What to test and what "pass" means
| Feature | Test | Pass condition |
|---|---|---|
| User properties | Publish with key/value pairs | Subscriber receives the same pairs |
| Content type, correlation data | Publish with both set | Subscriber receives both unchanged |
| Message expiry (retained) | Retained message with a 3 s expiry, subscribe after 5 s | Nothing is delivered |
| Message expiry (queued) | Queue messages for an offline session | Unexpired message arrives with a reduced interval; expired one is dropped |
| Session expiry | Reconnect with clean_start=False | session present is true and queued messages arrive |
| Topic aliases | Register an alias, then publish with an empty topic | Subscriber sees the full topic name |
| No local | Subscribe and publish on the same connection with nl | The client does not receive its own message |
First, confirm the broker accepts MQTT 5 connections and check its CONNACK limits (topic alias maximum, maximum QoS) with the broker feature probe. A test that fails because the broker advertises TopicAliasMaximum 0 is a configuration finding, not a bug.
Setup
npm install mqtt@5
pip install "paho-mqtt>=2.0"All examples use broker.connectmq.com:1883 and a random topic prefix under testmqtt/mqtt5/ so parallel runs do not see each other's messages. Add username and password ($MQTT_USER / $MQTT_PASS) if your broker requires them.
MQTT.js: properties, expiry, aliases and no-local
The script below runs four tests in sequence and exits non-zero on the first failure. nextMessage waits for one message or times out, which makes "nothing must arrive" just as easy to assert as "this must arrive".
import mqtt from 'mqtt';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
const URL = 'mqtt://broker.connectmq.com:1883';
const opts = { protocolVersion: 5, username: process.env.MQTT_USER, password: process.env.MQTT_PASS };
const base = `testmqtt/mqtt5/${randomUUID().slice(0, 8)}`;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const nextMessage = (client, timeoutMs = 5000) =>
new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('timeout')), timeoutMs);
client.once('message', (topic, payload, packet) => {
clearTimeout(timer);
resolve({ topic, payload, packet });
});
});
const sub = await mqtt.connectAsync(URL, opts);
const pub = await mqtt.connectAsync(URL, opts);
// 1. User properties, content type, correlation data
await sub.subscribeAsync(`${base}/props`, { qos: 1 });
let incoming = nextMessage(sub);
await pub.publishAsync(`${base}/props`, '{"temp":21.5}', {
qos: 1,
properties: {
userProperties: { source: 'ci', 'trace-id': 'abc123' },
contentType: 'application/json',
responseTopic: `${base}/reply`,
correlationData: Buffer.from('req-42'),
},
});
const { packet } = await incoming;
assert.equal(packet.properties.userProperties.source, 'ci');
assert.equal(packet.properties.userProperties['trace-id'], 'abc123');
assert.equal(packet.properties.contentType, 'application/json');
assert.equal(packet.properties.correlationData.toString(), 'req-42');
console.log('PASS user properties / content type / correlation data');
// 2. Retained message expiry
await pub.publishAsync(`${base}/expiry`, 'short-lived', {
qos: 1, retain: true, properties: { messageExpiryInterval: 3 },
});
await sleep(5000);
const late = await mqtt.connectAsync(URL, opts);
incoming = nextMessage(late, 3000);
await late.subscribeAsync(`${base}/expiry`, { qos: 1 });
await assert.rejects(incoming, /timeout/, 'expired retained message was delivered');
console.log('PASS retained message expired');
// 3. Topic alias: register alias 1, then publish with an empty topic
const aliasClient = mqtt.connect(URL, opts);
const connack = await new Promise((resolve, reject) => {
aliasClient.once('connect', resolve);
aliasClient.once('error', reject);
});
const aliasMax = connack.properties?.topicAliasMaximum ?? 0;
if (aliasMax === 0) {
console.log('SKIP topic alias: broker advertises TopicAliasMaximum 0');
} else {
const long = `${base}/alias/site-01/building-a/floor-3/sensor-temperature`;
await sub.subscribeAsync(long, { qos: 1 });
incoming = nextMessage(sub);
await aliasClient.publishAsync(long, 'first', { qos: 1, properties: { topicAlias: 1 } });
assert.equal((await incoming).topic, long);
incoming = nextMessage(sub);
await aliasClient.publishAsync('', 'second', { qos: 1, properties: { topicAlias: 1 } });
assert.equal((await incoming).topic, long, 'broker did not resolve the alias');
console.log(`PASS topic alias (broker allows ${aliasMax})`);
}
// 4. No local: a client must not receive its own publish
await pub.subscribeAsync(`${base}/nolocal`, { qos: 1, nl: true });
incoming = nextMessage(pub, 2000);
await pub.publishAsync(`${base}/nolocal`, 'echo?', { qos: 1 });
await assert.rejects(incoming, /timeout/, 'received own message despite no-local');
console.log('PASS no local');
await Promise.all([sub, pub, late, aliasClient].map((c) => c.endAsync()));Run it with node mqtt5.test.mjs. A few details are worth knowing:
- User properties in MQTT.js are a plain object. If a key appears several times on the wire, its value becomes an array — MQTT 5 allows duplicate keys and their order must be preserved.
- Expiry test order matters. Attach the message listener before subscribing, because a retained message can arrive before
subscribeAsyncresolves. - Topic alias out of range. MQTT.js refuses to send an alias above the broker's
TopicAliasMaximumand emits an error. A broker that receives one anyway disconnects with0x94 Topic Alias invalid. More in the dedicated topic alias test. - No local is a subscription option, set per subscription. Other options (retain as published, retain handling) are explained in MQTT subscription options.
paho-mqtt: session expiry and queued message expiry
The most valuable MQTT 5 test is also the one people skip: what happens to messages while a device is offline. The script creates a persistent session, disconnects, publishes two messages (one with a 60 s expiry, one with 2 s), waits, and reconnects.
import time, uuid
import paho.mqtt.client as mqtt
from paho.mqtt.packettypes import PacketTypes
from paho.mqtt.properties import Properties
HOST, PORT = "broker.connectmq.com", 1883
TOPIC = f"testmqtt/mqtt5/{uuid.uuid4().hex[:8]}/queued"
CLIENT_ID = f"mqtt5-session-{uuid.uuid4().hex[:8]}"
received = []
def on_connect(client, userdata, flags, reason_code, properties):
print("connected:", reason_code, "| session present:", flags.session_present)
def on_message(client, userdata, msg):
expiry = getattr(msg.properties, "MessageExpiryInterval", None)
user = getattr(msg.properties, "UserProperty", None)
print(f"got {msg.payload.decode()!r} expiry left: {expiry} user properties: {user}")
received.append(msg.payload.decode())
def session_client():
c = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=CLIENT_ID,
protocol=mqtt.MQTTv5)
c.on_connect, c.on_message = on_connect, on_message
props = Properties(PacketTypes.CONNECT)
props.SessionExpiryInterval = 120 # keep the session 2 minutes after disconnect
c.connect(HOST, PORT, clean_start=False, properties=props)
c.loop_start()
time.sleep(1)
return c
# 1. Create the session and subscription, then go offline
s = session_client()
s.subscribe(TOPIC, qos=1)
time.sleep(1)
s.disconnect(); s.loop_stop()
# 2. Publish while the subscriber is offline
pub = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, protocol=mqtt.MQTTv5)
pub.connect(HOST, PORT); pub.loop_start()
for i, expiry in enumerate((60, 2)):
props = Properties(PacketTypes.PUBLISH)
props.MessageExpiryInterval = expiry
props.UserProperty = ("test", f"msg-{i}")
pub.publish(TOPIC, f"msg-{i}", qos=1, properties=props).wait_for_publish()
pub.disconnect(); pub.loop_stop()
time.sleep(5) # longer than the 2 s expiry
# 3. Reconnect: msg-0 arrives with less than 60 s left, msg-1 has expired
s = session_client()
time.sleep(3)
s.disconnect(); s.loop_stop()
assert received == ["msg-0"], received
print("PASS session kept, unexpired message delivered, expired message dropped")Expected output: the second connect prints session present: True, and msg-0 arrives with an expiry around 54–55 seconds. The broker must forward the remaining lifetime, not the original value — if you see 60, the broker is not decrementing it, and downstream consumers cannot tell how stale the message is. If session present is False, the broker did not keep the session: check its maximum session expiry setting and whether the client ID changed. The session expiry test goes deeper into edge cases such as expiry on disconnect.
Request/response with response topic and correlation data
MQTT 5 standardises request/response: the requester sets a response topic and correlation data; the responder publishes its answer to that topic and copies the correlation data back. Test 1 above already checks that the broker forwards both properties. To test a real responder, subscribe to the response topic, send a request, and assert that the reply carries the same correlation data within your timeout. The pattern and a full example are in the MQTT request/response test.
Testing mixed MQTT 3.1.1 and MQTT 5 fleets
Most fleets are mixed for years. Add one more run of the property test with the subscriber connected as 3.1.1 (protocolVersion: 4 in MQTT.js, protocol=mqtt.MQTTv311 in paho):
- The message must still arrive.
packet.propertiesis absent: 3.1.1 clients never see user properties, content type or correlation data.- Message expiry still works, because the broker enforces it.
If your backend relies on user properties for routing or tracing, this run shows which devices will fall back to the payload. See MQTT 5 vs 3.1.1 for the full list of differences.
Watch the MQTT 5 tests in the browser
MQTT 5 features are only as reliable as the weakest implementation between your publisher, the broker and your subscriber. A few hundred lines of tests — properties round-trip, expiry, aliases, sessions — catch the differences early. Keep them in CI and run them again after every broker or library upgrade.
Frequently asked questions
Do MQTT 5 properties work if the subscriber uses MQTT 3.1.1?
The message is delivered, but a 3.1.1 subscriber cannot receive properties: user properties, content type, response topic and correlation data are dropped for that connection. Message expiry still applies on the broker side.
Why does my retained message not expire?
Check that you published with MQTT 5 (properties are ignored on 3.1.1 connections), that the expiry interval is set on the PUBLISH and not on CONNECT, and that your broker implements expiry for retained messages. Test with a short interval and a fresh subscriber.
Do I have to manage topic aliases myself?
Not necessarily. MQTT.js can assign and reuse aliases automatically (autoAssignTopicAlias, autoUseTopicAlias) and many libraries resolve incoming aliases for you. Manage them by hand only when you need exact control over bandwidth.


