Skip to content

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.

MQTT QoS 0, 1 and 2 handshakes compared Three sequence panels between a publisher and a broker: QoS 0 one fire-and-forget arrow; QoS 1 a PUBLISH and PUBACK with a dashed duplicate re-PUBLISH; QoS 2 the four-step PUBLISH, PUBREC, PUBREL, PUBCOMP exchange. Each panel lists its delivery guarantee, latency cost and the factory telemetry class it suits. QoS 0 AT-MOST-ONCE Publisher Broker PUBLISH fire-and-forget · no ack lost silently on roam QoS 1 AT-LEAST-ONCE Publisher Broker PUBLISH PUBACK PUBLISH (DUP=1) duplicate on resend QoS 2 EXACTLY-ONCE Publisher Broker PUBLISH PUBREC PUBREL PUBCOMP no duplicate, no loss Latency: lowest · 0 round trips Broker state: none High-rate analog vibration · temperature · current Latency: +1 round trip Needs: idempotency key Discrete state fault · recipe · cycle count Latency: +2 round trips Broker state: per-message Compliance-critical batch yield · audit · safety

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 (PUBLISHPUBRECPUBRELPUBCOMP). 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:

  1. 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.
  2. 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.
  3. Retained flags. Use retain=True sparingly. 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 INSERT inflates 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 PUBREL can 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=True with 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_session semantics. 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.