Using digital twin retained MQTT messages is the lightest way to give a twin memory: each property lives on its own retained topic, so any viewer that subscribes receives the complete current state instantly, without a database or API. In this tutorial you will build a small machine twin with a Python simulator, a Node.js viewer that rebuilds full state on connect, and the commands you need to inspect and clean up retained topics.
For the wider concept, see the digital twin technology guide.
How Retained Messages Work
Normally an MQTT broker forwards a message to current subscribers and forgets it. When a publisher sets the retain flag, the broker also stores that message as the last known value for the topic. Any client that later subscribes to a matching topic receives the stored message immediately, marked as retained.
Key rules:
- The broker keeps one retained message per topic: the latest one replaces the previous.
- Wildcard subscriptions receive the retained message of every matching topic.
- Publishing an empty payload with the retain flag deletes the retained message.
- Retained messages are delivered at the lower of the published and subscribed QoS.
That combination is exactly what a twin needs: a key-value store of current state, addressed by topic, readable by wildcard. For an overview of where this fits among other state approaches, see digital twin architecture.
Designing the Topic Tree: One Property per Topic
You can retain one JSON document per asset or one value per property. This tutorial uses one topic per property, which has real advantages:
| Approach | Pros | Cons |
|---|---|---|
| One retained JSON per asset | Fewer messages, atomic snapshot | Every change resends all fields; concurrent writers overwrite each other |
| One retained topic per property | Small updates, independent writers, easy subscriptions to a single value | More topics; no atomic snapshot across properties |
For a CNC machine on the public broker, the tree looks like this (keep within 8 topic levels):
connectmq/demo/f0c3/cell1/cnc1/spindle_rpm
connectmq/demo/f0c3/cell1/cnc1/feed_mm_min
connectmq/demo/f0c3/cell1/cnc1/tool
connectmq/demo/f0c3/cell1/cnc1/mode
connectmq/demo/f0c3/cell1/cnc1/alarm
connectmq/demo/f0c3/cell1/cnc1/status
Payloads are plain values, optionally with a timestamp. Here we use a small JSON object {"v": 8000, "ts": 1792152000000} so viewers know how fresh each value is.
Replace f0c3 with your own random string. The public MQTT test broker is readable by everyone, so publish only demo data.
Step 1: Publish Twin Properties as Retained Messages
This Python simulator publishes each property only when it changes, always with retain=True, and uses a last will to mark the machine offline:
import json
import random
import time
import paho.mqtt.client as mqtt
BASE = "connectmq/demo/f0c3/cell1/cnc1"
last_sent = {}
def publish_property(client, name, value):
if last_sent.get(name) == value:
return
payload = json.dumps({"v": value, "ts": int(time.time() * 1000)})
client.publish(f"{BASE}/{name}", payload, qos=1, retain=True)
last_sent[name] = value
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="cnc1-twin-f0c3")
client.will_set(f"{BASE}/status", json.dumps({"v": "offline"}), qos=1, retain=True)
client.connect("broker.connectmq.com", 1883)
client.loop_start()
publish_property(client, "status", "online")
publish_property(client, "tool", 3)
try:
while True:
running = random.random() > 0.1
publish_property(client, "mode", "AUTO" if running else "HOLD")
publish_property(client, "spindle_rpm", random.choice([7950, 8000, 8050]) if running else 0)
publish_property(client, "feed_mm_min", 1000 if running else 0)
publish_property(client, "alarm", None if running else "E-STOP")
time.sleep(2)
except KeyboardInterrupt:
publish_property(client, "status", "offline")
time.sleep(0.5)
client.loop_stop()
client.disconnect()
Publishing on change rather than on a timer keeps traffic low and respects the public broker's rate limit of 10 messages per second per client. Retained state guarantees viewers still see unchanged values.
Step 2: Build a Viewer That Loads Full State Instantly
The viewer subscribes with a wildcard, collects retained messages into a twin object, then keeps applying live updates. With MQTT.js in Node.js:
const mqtt = require("mqtt");
const base = "connectmq/demo/f0c3/cell1/cnc1";
const twin = {};
const client = mqtt.connect("mqtt://broker.connectmq.com:1883", {
clientId: "cnc1-viewer-" + Math.random().toString(16).slice(2, 10),
protocolVersion: 5,
});
client.on("connect", () => {
client.subscribe(`${base}/+`, { qos: 1 });
});
client.on("message", (topic, payload, packet) => {
const property = topic.slice(base.length + 1);
if (payload.length === 0) {
delete twin[property];
return;
}
const { v, ts } = JSON.parse(payload.toString());
twin[property] = { value: v, ts, fromRetained: packet.retain };
console.clear();
console.table(twin);
});
Run the viewer after the simulator has started. The table fills with every property at once, because each retained message is delivered on subscribe with packet.retain set to true. Subsequent updates arrive as live messages. In a browser, the same code works with wss://broker.connectmq.com:8084/mqtt, which is how 3D twin viewers load a whole scene in one round trip; digital twin with MQTT shows the browser variant.
Step 3: Inspect and Clean Up Retained Topics
Retained messages stay until replaced or cleared, which is a feature until you rename a property. Inspect everything under your prefix:
mosquitto_sub -h broker.connectmq.com -p 1883 -i inspect-f0c3 \
-t "connectmq/demo/f0c3/#" -v
Clear a property you no longer use by publishing an empty retained message:
mosquitto_pub -h broker.connectmq.com -p 1883 -i cleanup-f0c3 \
-t "connectmq/demo/f0c3/cell1/cnc1/alarm" -r -n
The -n flag sends a null (zero-length) payload; together with -r it removes the retained message.
Scaling the Pattern to Many Assets
One machine is easy; a whole plant needs a few extra conventions. Keep the same property names across assets of the same type, so a viewer can render any CNC from the same template. Put the asset hierarchy in the topic path, for example site/area/line/asset/property, so viewers can subscribe to exactly the branch they display: a line view subscribes to .../line2/+/+, a single-machine page to .../line2/cnc4/+. On the public broker, remember the limit of 8 topic levels and 10 subscriptions per client, which is plenty for a demo but a reason to use your own broker for real deployments.
Viewers that open a large branch receive one retained message per property at once. That is usually fine, but parse and apply them in a batch before the first render, rather than re-rendering the scene for every single message. The result is a twin that appears complete on the first frame.
MQTT 5 Options That Improve Retained Twins
MQTT 5 adds controls that make retained state more precise:
- Message expiry interval: set an expiry on volatile properties so a retained value disappears if it is not refreshed, rather than showing a stale reading forever.
- Retain handling on subscribe:
0sends retained messages on every subscribe,1only for new subscriptions,2never. Use2for consumers that only want live changes. - Retain as published: lets a bridge or relay see whether the original message was retained.
In MQTT.js, a subscription with { qos: 1, rh: 2 } skips retained messages, and publishing with { retain: true, properties: { messageExpiryInterval: 300 } } makes a value expire after five minutes. See MQTT 5 features for more.
Pitfalls and Troubleshooting
- Ghost properties: renamed topics leave old retained values behind. Clear them explicitly.
- Retained commands: never retain command topics; devices will re-execute them on reconnect.
- Stale values shown as live: include timestamps and honour the status topic.
- Oversized retained payloads: keep each value small; the public broker accepts up to 64 KB per message and the free Serverless plan 256 KB.
- No atomic snapshot: if several properties must change together, publish them as one retained JSON document instead.
- Broker restarts: plan for retained state to be republished by devices on reconnect rather than assuming it survives broker restarts forever. Devices publishing full state on connect makes the twin self-healing.
When Retained Topics Are Not Enough
Retained topics are ideal for current state. Add other components when you need history (a time-series subscriber), queries across assets or per-twin access control (a twin service such as Eclipse Ditto with MQTT), or desired vs reported handling at scale. For the desired/reported pattern on retained topics, read digital twin state synchronization.
Run Your Retained-State Twin on ConnectMQ
For real assets, move from the public broker to a private broker on ConnectMQ, a managed MQTT broker with MQTT 3.1.1 and 5.0, retained messages, message expiry and TLS. Replace the host with your broker, add YOUR_USERNAME, YOUR_PASSWORD and YOUR_CLIENT_ID from the portal, and switch to port 8883 or 8084 for browsers. Sign up at app.connectmq.com on the free Serverless plan, no credit card required, and watch your twin's retained topics in the live topic explorer.
Frequently Asked Questions
What is a retained message in MQTT?
It is a message the broker stores as the last known value for its topic and delivers immediately to any new subscriber of that topic.
How do I delete a retained MQTT message?
Publish a zero-length payload with the retain flag to the same topic, for example mosquitto_pub -t <topic> -r -n.
Should each twin property have its own retained topic?
It works well for independent properties and small updates. Use one retained JSON document per asset when several values must change together atomically.
Can retained messages replace a database for a digital twin?
For current state, often yes. For history, cross-asset queries or audit trails, add a database subscriber alongside the broker.



