Broker Testing

Testing MQTT 5 Features: User Properties, Expiry, Topic Aliases

Hands-on tests for MQTT 5 features with MQTT.js and paho-mqtt: user properties, message and session expiry, topic aliases, request/response and no-local.

Updated · 10 min read

Testing MQTT 5 Features: User Properties, Expiry, Topic Aliases

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

FeatureTestPass condition
User propertiesPublish with key/value pairsSubscriber receives the same pairs
Content type, correlation dataPublish with both setSubscriber receives both unchanged
Message expiry (retained)Retained message with a 3 s expiry, subscribe after 5 sNothing is delivered
Message expiry (queued)Queue messages for an offline sessionUnexpired message arrives with a reduced interval; expired one is dropped
Session expiryReconnect with clean_start=Falsesession present is true and queued messages arrive
Topic aliasesRegister an alias, then publish with an empty topicSubscriber sees the full topic name
No localSubscribe and publish on the same connection with nlThe 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

Install both libraries
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".

mqtt5.test.mjs
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 subscribeAsync resolves.
  • Topic alias out of range. MQTT.js refuses to send an alias above the broker's TopicAliasMaximum and emits an error. A broker that receives one anyway disconnects with 0x94 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.

session_expiry_test.py
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.properties is 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.