The MQTT 5 Message Expiry Interval tells the broker how long a message stays useful. If it is still waiting for delivery when that time runs out, the broker must throw it away instead of delivering stale data. To test it, publish messages with different expiry values while a persistent subscriber is offline, wait, reconnect, and check which messages arrive and with what remaining interval.
This guide walks through that test with mosquitto_pub / mosquitto_sub, a self-checking Python script and a separate test for retained messages.
How message expiry actually works
The publisher sets the Message Expiry Interval property (in seconds) on a PUBLISH packet. From then on the broker is responsible for it:
| Situation | What the broker must do |
|---|---|
| Subscriber online | Deliver immediately; expiry rarely matters |
| Subscriber offline with a persistent session | Queue the message, drop it once the interval has passed |
| Message is retained | Serve it to new subscribers only until it expires, then delete it |
| Message is forwarded after waiting | Send the remaining interval, not the original value |
| No expiry property set | Message never expires (broker-wide limits may still apply) |
The key point for testing: expiry only becomes visible when a message waits. A test with an always-connected subscriber proves nothing. You need an offline session or a retained message. For the theory and design advice, see MQTT message expiry.
Prerequisites
- A broker with MQTT 5 support. Check the CONNACK of an MQTT 5 connection, or run the general MQTT 5 feature tests first.
- A subscriber that keeps a session while offline: fixed client ID, Clean Start
falseand a non-zero Session Expiry Interval. If the session itself expires, its queue goes with it, and your expiry test measures the wrong thing. Persistent sessions explains the mechanics. - QoS 1 on both the subscription and the publish. Many brokers do not queue QoS 0 messages for offline clients at all.
The examples use broker.connectmq.com and topics under testmqtt/expiry/. Add a random suffix so your test does not collide with other users.
Quick manual test with the Mosquitto CLI
Step 1: create a persistent session and subscribe, then stop the subscriber with Ctrl+C.
mosquitto_sub -h broker.connectmq.com -p 1883 -V mqttv5 \
-i expiry-sub-7f3a -c -x 600 -q 1 \
-t 'testmqtt/expiry/7f3a/#' -F '%t expiry=%E payload=%p'-c disables Clean Start, -x 600 keeps the session for 10 minutes after disconnect, and %E prints the message expiry interval of every received message.
Step 2: while the subscriber is offline, publish three messages:
mosquitto_pub -h broker.connectmq.com -V mqttv5 -q 1 \
-t testmqtt/expiry/7f3a/short -m 'expires in 10s' \
-D publish message-expiry-interval 10
mosquitto_pub -h broker.connectmq.com -V mqttv5 -q 1 \
-t testmqtt/expiry/7f3a/long -m 'expires in 300s' \
-D publish message-expiry-interval 300
mosquitto_pub -h broker.connectmq.com -V mqttv5 -q 1 \
-t testmqtt/expiry/7f3a/none -m 'never expires'Step 3: wait about 30 seconds, then run the exact command from step 1 again. A correct broker delivers long (with an expiry noticeably below 300) and none, and never delivers short.
If short arrives, the broker ignores expiry for queued messages. If nothing arrives at all, your session was not kept: look at the session first, not at expiry.
A self-checking Python test
Manual tests are fine once. For regressions, use a script that asserts the result. This one uses paho-mqtt 2.x and MQTT 5 properties:
import time, uuid
import paho.mqtt.client as mqtt
from paho.mqtt.properties import Properties
from paho.mqtt.packettypes import PacketTypes
HOST, PORT = "broker.connectmq.com", 1883
RUN = uuid.uuid4().hex[:8]
TOPIC = f"testmqtt/expiry/{RUN}"
SUB_ID = f"expiry-sub-{RUN}"
WAIT = 15 # seconds the subscriber stays offline
received = {}
session = {}
def client(cid):
return mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=cid, protocol=mqtt.MQTTv5)
def connect_props(session_expiry):
p = Properties(PacketTypes.CONNECT)
p.SessionExpiryInterval = session_expiry
return p
def on_message(c, userdata, msg):
left = getattr(msg.properties, "MessageExpiryInterval", None)
received[msg.topic.rsplit("/", 1)[1]] = left
# 1. Create a persistent session with a subscription, then go offline
sub = client(SUB_ID)
sub.connect(HOST, PORT, clean_start=True, properties=connect_props(600))
sub.loop_start()
sub.subscribe(f"{TOPIC}/#", qos=1)
time.sleep(1)
sub.disconnect()
sub.loop_stop()
# 2. Publish with different expiry intervals while it is offline
pub = client(f"expiry-pub-{RUN}")
pub.connect(HOST, PORT)
pub.loop_start()
for name, expiry in [("short", 5), ("long", 120), ("none", None)]:
props = Properties(PacketTypes.PUBLISH)
if expiry:
props.MessageExpiryInterval = expiry
pub.publish(f"{TOPIC}/{name}", name, qos=1, properties=props).wait_for_publish()
pub.disconnect()
pub.loop_stop()
time.sleep(WAIT)
# 3. Resume the session and collect what is left
sub = client(SUB_ID)
sub.on_message = on_message
sub.on_connect = lambda c, u, flags, rc, p: session.update(present=flags.session_present)
sub.connect(HOST, PORT, clean_start=False, properties=connect_props(600))
sub.loop_start()
time.sleep(3)
end = Properties(PacketTypes.DISCONNECT)
end.SessionExpiryInterval = 0 # clean up the session
sub.disconnect(properties=end)
sub.loop_stop()
print("session present:", session.get("present"), "received:", received)
if not session.get("present"):
raise SystemExit("ABORT: the broker did not keep the session, expiry cannot be judged")
checks = {
"short expired": "short" not in received,
"long delivered": "long" in received,
"long shows remaining time": received.get("long") is not None and received["long"] <= 120 - WAIT,
"no-expiry delivered": "none" in received,
}
for name, ok in checks.items():
print(("PASS " if ok else "FAIL ") + name)A typical passing run prints session present: True and a long value a little below 105. Small differences of a second are normal, because brokers round to whole seconds.
ABORT with session present: False means the broker discarded or never stored the session, so expiry cannot be judged. Shared public test endpoints often do not keep offline sessions at all, so run this test against your own broker instance. Otherwise check the session settings first: testing session expiry covers this case.
Testing expiry of retained messages
Retained messages are the second place where expiry matters, and the one people forget. A retained "device online" status with a 30-second expiry vanishes automatically if the device stops refreshing it.
mosquitto_pub -h broker.connectmq.com -V mqttv5 -q 1 -r \
-t testmqtt/expiry/7f3a/status -m online \
-D publish message-expiry-interval 20
# Immediately: prints the message (expiry close to 20)
mosquitto_sub -h broker.connectmq.com -V mqttv5 \
-t testmqtt/expiry/7f3a/status -F '%r %E %p' -W 3
sleep 25
# After expiry: prints nothing and exits after the 3 s timeout
mosquitto_sub -h broker.connectmq.com -V mqttv5 \
-t testmqtt/expiry/7f3a/status -F '%r %E %p' -W 3In the first output, %r prints 1 for a retained message. In the second run, nothing should print. If the old value still appears, the broker keeps retained messages past their expiry. Some brokers delete expired retained messages lazily or on a timer, so test with a margin. Retained behavior in general is covered in testing retained messages.
You can also watch this live in the online MQTT client: subscribe to the status topic before and after the expiry window and compare.
Watch expiring messages in the browser
Common results and what they mean
- The short-lived message is delivered after it expired. The broker ignores the property, or the session is handled by an MQTT 3.1.1 code path. Confirm that both publisher and subscriber connect with protocol version 5.
- The forwarded expiry equals the original value. The broker does not subtract the waiting time. That breaks downstream logic that forwards the message again, such as bridges or gateways.
- No property on received messages, even with expiry set. A bridge or a proxy in between may speak MQTT 3.1.1 and strip MQTT 5 properties.
- Nothing is delivered at all. Check
session_present, QoS levels (QoS 0 is often not queued) and ACLs. A subscription the broker refused never queues anything. The online reason code lookup helps decode SUBACK results. - Messages expire later than expected. Brokers check expiry at delivery time or on a periodic sweep. A difference of a second or two is fine. Minutes are not.
Checklist for message expiry tests
- Connect publisher and subscriber with MQTT 5, and log the CONNACK.
- Use a fixed client ID, Clean Start
falseand a session expiry longer than your test. - Publish at QoS 1 with at least three expiry values: shorter than the wait, longer than the wait, and none.
- Keep the subscriber offline longer than the short expiry. Reconnect and assert what arrives.
- Check that the forwarded interval has gone down by roughly the waiting time.
- Repeat for a retained message and for a new subscriber after expiry.
- Clean up: end the session with Session Expiry 0 and clear retained topics with an empty payload.
Message expiry is a small property with a big effect: it decides whether a device waking up gets useful commands or a burst of stale ones. Put the script above in your test suite and run it after every broker upgrade or migration.
Frequently asked questions
Does Message Expiry Interval affect clients that are online?
Usually not. An online subscriber receives the message almost immediately, long before it expires. The interval matters when the message waits: in an offline client's session queue, as a retained message, or in a slow consumer's backlog.
Why does my subscriber receive a smaller expiry value than I published?
That is correct behavior. MQTT 5 requires the broker to forward the remaining lifetime, so a message published with 60 seconds that waited 20 seconds in a queue arrives with an expiry of about 40 seconds.
Does message expiry work with MQTT 3.1.1?
No. Message Expiry Interval is an MQTT 5 property. A 3.1.1 publisher cannot set it, and a broker treats messages without the property as never expiring. Brokers may still apply their own global limits.


