Scaling with Shared Subscriptions
The problem: fan-out vs load balancing
By default MQTT uses fan-out: every subscriber to a topic receives every message. If you run two workers subscribed to jobs/#, each message lands in both workers — work is duplicated.
Shared subscriptions solve this. Clients join a named group, and the broker selects one group member for each message. The selection strategy is broker-specific; MQTT does not require round-robin distribution.
Syntax
Subscribe to $share/<group>/<topic> instead of <topic> directly. The group name is arbitrary; all workers that should share load must use the same group name. The publisher publishes to the plain topic as usual.
import asyncio
from zmqtt import QoS, create_client
async def worker(worker_id: int) -> None:
async with create_client("broker.example.com") as client:
async with client.subscribe("$share/workers/jobs/#", qos=QoS.AT_LEAST_ONCE) as sub:
async for msg in sub:
print(f"worker {worker_id} got {msg.topic}: {msg.payload}")
async def main() -> None:
# Both workers receive disjoint subsets of messages — no duplicates
await asyncio.gather(worker(1), worker(2))
The publisher needs no changes:
async with create_client("broker.example.com") as client:
await client.publish("jobs/resize", b"image-42.jpg", qos=QoS.AT_LEAST_ONCE)
Broker-specific subscription prefixes
Some brokers provide group-less subscription prefixes in addition to the standard $share/<group>/... syntax. zmqtt recognises $queue/... and $exclusive/... by default. The broker removes such a prefix before delivering the message, so a subscription to $queue/jobs/# receives messages published to jobs/#.
Use stripped_prefixes to support another prefix used by your broker. The tuple replaces the defaults, so include any default prefixes you still need:
client = create_client(
"broker.example.com",
stripped_prefixes=("$queue", "$exclusive", "$q"),
)
The public topic_matches() helper follows the same rules when matching topics outside an active subscription:
from zmqtt import topic_matches
assert topic_matches("$queue/jobs/+", "jobs/resize")
assert topic_matches("$q/jobs/+", "jobs/resize", stripped_prefixes=("$q",))
Do not add namespaces such as $SYS to stripped_prefixes: brokers publish those topics with the namespace intact.
QoS recommendation
Use QoS 1 (AT_LEAST_ONCE) or QoS 2 (EXACTLY_ONCE) when losing a
message during worker failure is unacceptable. QoS 0 has no acknowledgement, so
a delivery interrupted by disconnection can be lost. QoS 1/2 makes the delivery
eligible for retry, but whether another group member receives it depends on the
broker and persistent shared-subscription session.
QoS 2 prevents duplicate protocol delivery within the MQTT session; it does not make external application side effects exactly once. Keep handlers idempotent, or combine QoS 1 with manual acknowledgement and an application deduplication key.
Broker compatibility
All brokers supported by zmqtt's test suite accept the $share/<group>/<topic> syntax for both MQTT 3.1.1 and 5.0 connections:
| Broker | MQTT versions tested | Notes |
|---|---|---|
| Apache ActiveMQ Artemis | 3.1.1 and 5.0 | Standard $share/<group>/... syntax |
| Eclipse Mosquitto | 3.1.1 and 5.0 | Standard $share/<group>/... syntax |
| EMQX | 3.1.1 and 5.0 | Also tested with group-less $queue/... subscriptions |
| HiveMQ CE | 3.1.1 and 5.0 | Standard $share/<group>/... syntax |
| NanoMQ | 3.1.1 and 5.0 | Rejects double-slash filters — see note below |
NanoMQ — avoid double slashes in shared filters
NanoMQ strictly validates topic filters and rejects any filter containing
//(two consecutive slashes). This can happen silently if your base topic starts with a leading slash:topic = "/sensors/temp" # leading slash shared = f"$share/workers/{topic}" # → "$share/workers//sensors/temp" ❌Strip the leading slash before building the shared filter:
topic = "/sensors/temp" shared = f"$share/workers/{topic.lstrip('/')}" # → "$share/workers/sensors/temp" ✓Other brokers tolerate the double slash, but NanoMQ disconnects the client immediately on SUBSCRIBE.
Shared subscriptions are part of MQTT 5.0. On MQTT 3.1.1 they are a broker extension; check the broker's documentation.
See also: Manual Ack · Backpressure