Best Practices for MQTT QoS Levels in Factory Networks
Selecting an MQTT Quality of Service (QoS) level is one concrete decision inside MQTT topic hierarchies, and on a factory network it is a deterministic data contract rather than a network-tuning toggle. The level you assign to each topic branch governs whether telemetry survives a Wi-Fi roam, whether a fault event is counted once or twice, and whether downstream Overall Equipment Effectiveness (OEE) math sees a continuous series or a series riddled with phantom gaps. Misaligned QoS assignments are a leading cause of inflated micro-stop counts, double-counted cycles, and broker queue saturation that stalls an entire ingestion pipeline. This page covers how to map each QoS level to a telemetry class so that the precision and rounding contract downstream operates on data that is neither lost nor duplicated.
QoS 0 — High-Frequency Telemetry and the Interpolation Trap Permalink to this section
QoS 0 (at-most-once) is the right default for high-frequency vibration, temperature, and motor-current streams where statistical eventual consistency outweighs strict per-sample delivery. It minimizes broker CPU overhead and network jitter because the publisher fires and forgets — no PUBACK, no retransmission, no session state. The cost surfaces during momentary network disruption: when a floor switch reboots or a wireless access point hands off during a roaming event, in-flight QoS 0 packets simply vanish with no acknowledgment.
For OEE availability, that silent loss deflates runtime counters and tempts analysts to forward-fill across the void — masking the true machine state. Confine QoS 0 to non-critical health monitoring, and make the aggregation layer flag missing intervals explicitly instead of bridging them. Where a genuine network gap must be reconstructed, hand it to bounded gap-filling algorithms that record every synthesized value, rather than letting a rolling average quietly absorb it.
import struct
import logging
import paho.mqtt.client as mqtt
log = logging.getLogger("vibration_publisher")
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
client.connect("broker.internal.mfg", 1883, keepalive=60)
client.loop_start()
def publish_vibration_sample(accel_g: float) -> None:
"""Publish a single 50 Hz accelerometer sample at QoS 0.
QoS 0 is acceptable here because a dropped sample is statistically
absorbed by the stream; it must NOT be used for state transitions.
"""
payload = struct.pack("<f", accel_g) # little-endian Float32
info = client.publish(
"plant_eu/line_03/cnc_01/spindle/vibration_rms_f",
payload=payload,
qos=0,
retain=False,
)
if info.rc != mqtt.MQTT_ERR_SUCCESS:
# rc != 0 usually means the local out-queue is full (backpressure)
log.warning("vibration publish dropped locally rc=%s", info.rc)
QoS 1 — Discrete State Transitions and Deduplication Contracts Permalink to this section
QoS 1 (at-least-once) eliminates packet loss but introduces duplicate delivery during broker failover or client reconnection. Persistent sessions (clean_session=False) replay unacknowledged messages, so the same fault code or cycle-complete event can arrive twice, causing time-series database sync routines to double-count event-based metrics.
The durable mitigation is idempotency: embed a monotonically increasing sequence number or a content hash in the payload envelope so the ingestion worker can reject replays before they reach the store. Reserve QoS 1 for discrete state transitions — fault codes, recipe changes, shift handovers, and cycle counts — the events that feed availability and performance directly. Aligning these to standardized topic branches is governed by PLC tag standardization, which keeps control-plane events on routes distinct from raw data-plane telemetry so the broker never fans high-rate analog traffic into deduplicated event queues.
import hashlib
import psycopg2
from psycopg2.extensions import connection as PgConnection
def ingest_qos1_event(conn: PgConnection, topic: str, payload: bytes) -> bool:
"""Idempotently persist a QoS 1 event; returns True if newly inserted.
The payload_hash carries a UNIQUE constraint in TimescaleDB, so a
replayed at-least-once delivery resolves to a no-op rather than a
double-counted cycle or fault event.
"""
payload_hash = hashlib.sha256(topic.encode() + b":" + payload).hexdigest()
query = """
INSERT INTO machine_events (ts, topic, payload, payload_hash)
VALUES (NOW(), %s, %s, %s)
ON CONFLICT (payload_hash) DO NOTHING;
"""
try:
with conn, conn.cursor() as cur:
cur.execute(query, (topic, payload, payload_hash))
return cur.rowcount == 1
except psycopg2.Error:
conn.rollback()
raise
QoS 2 — Compliance-Critical Metrics and Exactly-Once Delivery Permalink to this section
QoS 2 (exactly-once) guarantees a single delivery through a four-step handshake (PUBLISH → PUBREC → PUBREL → PUBCOMP). That guarantee costs two extra round trips and per-message broker state, which makes it unsuitable for high-frequency polling but ideal for data that must reconcile exactly once: regulatory logs, final batch yield, and safety interlock confirmations.
Reserve QoS 2 for a deliberately small set of topics:
- Regulatory compliance trails (FDA 21 CFR Part 11, ISO 9001 audit records)
- Final batch reconciliation and scrap reporting feeding OEE formula validation
- Safety interlock state confirmations
Over-subscribing QoS 2 across broad wildcard filters exhausts the broker’s in-flight window. In Mosquitto the ceiling is max_inflight_messages in mosquitto.conf; in HiveMQ it is max-inflight-messages per listener. Exceeding it produces backpressure that stalls every consumer behind it. Keep QoS 2 payloads small (under 10 KB) to avoid TCP buffer bloat during the longer handshake.
# mosquitto.conf — bound the exactly-once window so a burst of
# compliance traffic cannot starve the broker of session memory.
listener 8883
max_inflight_messages 20 # concurrent QoS 1/2 messages awaiting ack
max_queued_messages 1000 # per-client backlog before drop
message_size_limit 10240 # 10 KB hard cap on any single PUBLISH
persistent_client_expiration 2h
Pipeline Integration: Topics, Timestamps, and Retained State Permalink to this section
QoS behavior only holds end to end if the surrounding contract is sound. Map standardized tags to a deterministic route — {site}/{line}/{asset}/{domain}/{metric}, e.g. detroit/line_04/press_01/telemetry/tonnage_f — so consumers never string-parse to recover topology. Then align QoS with write batching:
- Buffering. Accumulate QoS 0/1 samples into ~500 ms micro-batches before writing to InfluxDB or TimescaleDB to amortize write cost; deduplicate within the batch.
- Clock alignment. Keep PLCs, edge gateways, and database servers within sub-10 ms of each other. Out-of-order QoS 1 redeliveries corrupt continuous aggregates when timestamps drift, which is why clock drift correction belongs upstream of any QoS decision.
- Retained flags. Use
retain=Truesparingly. A retained QoS 1 message replays to every new subscriber on reconnect and can overwrite a shift-start baseline during a broker restart, leaving OEE dashboards showing stale values.
At sustained ingest rates the deduplication and write path itself becomes the bottleneck — offload it to the workers described in high-throughput MQTT ingestion with Celery so the broker’s in-flight window drains faster than it fills.
Gotchas and Anti-Patterns Permalink to this section
- Treating QoS as a global setting. A single QoS for every topic either loses safety-critical state (all QoS 0) or saturates the broker (all QoS 2). Assign per telemetry class, per branch.
- QoS 1 without idempotency. At-least-once guarantees duplicates eventually. Shipping QoS 1 cycle counts into a plain
INSERTinflates production totals on the first failover — always pair it with a uniqueness constraint or sequence check. - Trusting QoS 2 to fix ordering. Exactly-once is not in-order. A delayed
PUBRELcan still land a compliance record out of sequence; sort by event timestamp at ingestion, never by arrival time. - Retained QoS 0 “last value” topics. Combining
retain=Truewith QoS 0 means new subscribers may receive a stale value or none at all — neither is a reliable current-state source for a dashboard. - Ignoring
clean_sessionsemantics. A persistent session with no message expiry accumulates an unbounded offline queue; the device reconnects and floods the broker with hours of redeliveries, tripping backpressure across unrelated lines.
Quick Reference: QoS Selection Matrix Permalink to this section
| Telemetry class | QoS | Delivery guarantee | Idempotency required | Example topic |
|---|---|---|---|---|
| High-frequency analog (vibration, temp, current) | 0 | At-most-once | No (flag gaps) | …/spindle/vibration_rms_f |
| Discrete state (fault, recipe, cycle count) | 1 | At-least-once | Yes (hash/sequence) | …/cnc_12/state/fault_code_i |
| Shift / handover events | 1 | At-least-once | Yes | …/line_04/shift/handover |
| Batch yield, scrap reconciliation | 2 | Exactly-once | Built-in | …/press_01/batch/yield_final |
| Regulatory / audit trail | 2 | Exactly-once | Built-in | …/compliance/cfr_part11_log |
| Safety interlock confirmation | 2 | Exactly-once | Built-in | …/safety/interlock_state |
For protocol-level semantics, consult the OASIS MQTT v5.0 specification and the Eclipse Paho Python client documentation.
Related Permalink to this section
- MQTT Topic Hierarchies for Manufacturing Telemetry and OEE Workflows — the parent topic this QoS decision sits inside
- PLC Tag Standardization — map controller registers to the topic branches you assign QoS to
- High-Throughput MQTT Ingestion with Celery — drain the in-flight window faster than it fills
- Syncing Edge Timestamps with NTP Servers — keep QoS 1 redeliveries in order
- OEE Formula Validation — verify the metrics your QoS choices ultimately feed