Philipp Lehmann @PhilippTheServer

MQTT Persistent Sessions and Retained Messages for Offline Subscribers

Keeping a client session and its last-known values alive across a disconnect.

The problem

MQTT looks like it should just work across a flaky connection: the client reconnects, the broker resumes, messages keep flowing. The default behaviour is not that.

A subscriber that connects with clean_session=True (or, in MQTT 5, clean_start=True with no session expiry) tells the broker to forget it the moment it disconnects. Every subscription is dropped. Every message published while it was offline, at any QoS, is gone — there is no session left to queue it against. When the client reconnects it has to resubscribe, and the broker has nothing to catch it up with.

A second, separate problem shows up even when a subscriber has been connected the whole time: a client that subscribes to sensors/+/temperature for the first time sees nothing until the next publish. MQTT is a message bus, not a store — a topic carries no memory of its own by default, so a freshly-started dashboard shows a blank field until something happens to publish again.

Both failures are easy to miss in development, because a laptop running a broker and a client in two terminals rarely has either process actually go away. They show up in production, on the first restart of a subscriber during a deploy, or the first time a device drops off Wi-Fi for thirty seconds — and the failure mode is silence, not an error, which makes it hard to notice until someone asks why a value never updated.

Working through it

Session state versus message content

MQTT actually offers two independent mechanisms here, and conflating them is the most common mistake.

A persistent session is broker-side bookkeeping tied to a client ID: which topics this client is subscribed to, and which QoS 1/2 messages are queued for it while it is offline. It survives a disconnect only if the client asks for that at connect time and reconnects with the same client ID.

A retained message is broker-side storage of the last message published to a topic, independent of any client. Any client that subscribes afterwards — for the first time or the hundredth — receives it immediately, before any new publish happens.

They solve different problems: persistent sessions catch messages published during an outage, for a client that already existed. Retained messages give a new subscriber current state without waiting for the next event.

Getting persistence right at both ends

Persistence only works if three things line up, and losing any one silently degrades to the default behaviour.

The client must ask for it and use a stable ID. clean_session=False (MQTT 3.1.1) or clean_start=False with a non-zero session_expiry_interval (MQTT 5) tells the broker to keep state. If the client ID changes between connections — a common bug when the ID is derived from a random value generated at process start rather than something stable like a hostname or serial number — the broker sees a brand new client with no session to resume, and the flag does nothing useful.

The broker must be configured to persist sessions across its own restarts, not just across a client’s reconnect. Mosquitto keeps sessions in memory by default; a broker restart during a deploy loses them unless persistence is enabled and pointed at a volume.

Messages must be QoS 1 or 2. QoS 0 is fire-and-forget — the broker never queues it for an offline client, persistent session or not, because there is nothing to redeliver.

What retained messages cost

A retained message is stored once per topic and delivered to every future subscriber until replaced or explicitly cleared (an empty payload with retain=True clears it). That is cheap for a handful of state topics — device/<id>/status, device/<id>/last-reading — and expensive as a design if applied to every message on a high-churn topic, because the broker is now holding one retained value per distinct topic string forever, and a topic that includes something unbounded (a request ID, a timestamp) leaks memory in the broker. Retain state topics; do not retain event topics.

Why bring Redis into an MQTT article

Persistent sessions solve the offline-subscriber problem only for clients that hold an MQTT session — a service that queries current state without maintaining a live MQTT connection has no session to be resumed. A small bridge process that subscribes once and writes every value into Redis gives any consumer a synchronous, always-current read without it having to speak MQTT or manage reconnect logic itself. This is a deliberate trade: an extra moving part and one more place state can drift, in exchange for a plain key lookup for consumers that have no business holding a persistent broker session.

The solution

A complete, runnable setup: a Mosquitto broker with persistence enabled, a Redis cache, a bridge that copies retained-worthy values from MQTT into Redis, and a publisher/subscriber pair that demonstrate both persistent sessions and retained messages.

# docker-compose.yml
services:
  mosquitto:
    image: eclipse-mosquitto:2.0.18
    ports:
      - "1883:1883"
    volumes:
      - ./mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
      - mosquitto-data:/mosquitto/data

  redis:
    image: redis:7.2.4
    ports:
      - "6379:6379"

volumes:
  mosquitto-data:
# mosquitto.conf
listener 1883
allow_anonymous true

persistence true
persistence_location /mosquitto/data/
# requirements.txt
# paho-mqtt==1.6.1
# redis==5.0.1

# bridge.py — subscribes once, writes last-known values into Redis.
import json
import paho.mqtt.client as mqtt
import redis

TOPIC_FILTER = "sensors/+/temperature"
BROKER_HOST = "localhost"
BRIDGE_CLIENT_ID = "state-bridge-01"

r = redis.Redis(host="localhost", port=6379, decode_responses=True)


def on_connect(client, userdata, flags, rc):
    print(f"bridge connected, rc={rc}, session_present={flags.get('session present')}")
    client.subscribe(TOPIC_FILTER, qos=1)


def on_message(client, userdata, msg):
    device_id = msg.topic.split("/")[1]
    r.set(f"state:{device_id}:temperature", msg.payload.decode())
    print(f"cached {msg.topic} -> {msg.payload.decode()}")


client = mqtt.Client(client_id=BRIDGE_CLIENT_ID, clean_session=False)
client.on_connect = on_connect
client.on_message = on_message
client.connect(BROKER_HOST, 1883, keepalive=60)
client.loop_forever()
# publisher.py — publishes with retain=True so late subscribers get current state.
import time
import paho.mqtt.publish as publish

for i in range(3):
    value = 20.0 + i
    publish.single(
        topic="sensors/device-a/temperature",
        payload=str(value),
        qos=1,
        retain=True,
        hostname="localhost",
    )
    print(f"published {value}")
    time.sleep(2)
# subscriber.py — a persistent-session subscriber; run it, kill it, run it again.
import paho.mqtt.client as mqtt

CLIENT_ID = "dashboard-01"  # must stay the same across reconnects


def on_connect(client, userdata, flags, rc):
    print(f"connected, session_present={flags.get('session present')}")
    client.subscribe("sensors/+/temperature", qos=1)


def on_message(client, userdata, msg):
    print(f"received {msg.topic} = {msg.payload.decode()}")


client = mqtt.Client(client_id=CLIENT_ID, clean_session=False)
client.on_connect = on_connect
client.on_message = on_message
client.connect("localhost", 1883, keepalive=60)
client.loop_forever()

Running it:

docker compose up -d
pip install paho-mqtt==1.6.1 redis==5.0.1

python subscriber.py &
SUBPID=$!
sleep 1
python publisher.py         # subscriber prints three "received" lines

kill $SUBPID                # simulate the subscriber going offline
python publisher.py         # two more values published while it's down

python subscriber.py        # reconnects with the SAME client ID
# -> connected, session_present=True
# -> received sensors/device-a/temperature = 22.0   (queued while offline)
# -> received sensors/device-a/temperature = 23.0

Starting a brand new subscriber that has never connected before still sees current state immediately, because the last publish was retained:

python -c "
import paho.mqtt.subscribe as subscribe
msg = subscribe.simple('sensors/device-a/temperature', hostname='localhost')
print(msg.topic, msg.payload.decode())
"
# -> sensors/device-a/temperature 23.0

And the Redis bridge gives any process a synchronous read with no MQTT client of its own:

python bridge.py &
redis-cli GET state:device-a:temperature
# -> "23.0"

Conclusion

Two mechanisms, two different failures, and neither is a substitute for the other. Persistent sessions are about a known client catching up on what it missed; retained messages are about any client getting current state without waiting.

A stable client ID is the whole mechanism. clean_session=False on a client ID that changes every process start persists nothing, and the bug is silent — the broker just treats every connection as new.

QoS 0 and persistence do not mix. If a message has to survive an offline subscriber, it has to be published at QoS 1 or 2; the persistence flags on the client have no effect on a message the broker never queued.

Retain state, not events. A retained message is a standing cost on the broker until something replaces or clears it — fine for last-known-value topics, a liability on topics with unbounded key space.

A bridge into a plain store is a legitimate design, not a workaround. Not every consumer should have to be an MQTT client managing reconnects and session state; a small number of services doing that once, on behalf of everything else, is a reasonable trade for a large number of simple synchronous reads.