MQTT QoS Levels and Duplicate Sensor Messages

The short answer is QoS 1 with a persistent session and an idempotent sink, and most of the difficulty in getting there comes from a single misunderstanding: MQTT’s quality-of-service levels describe the guarantee between one publisher and one broker, or between one broker and one subscriber — not end to end. A reading published at QoS 2 can still be lost by a subscriber that acknowledged it and crashed before writing. This guide sets out what each level actually buys for environmental telemetry, why duplicates are the cheap failure to design for, and how to absorb them in the sink. It extends the client configuration built in MQTT broker integration for environmental sensors.

What Each Level Costs, in Sensor Terms

QoS 0 sends the packet once with no acknowledgement. On a stable link it is nearly lossless; on a cellular gateway it loses every message in flight during a reconnect, and reconnects are the norm rather than the exception. Its legitimate use is high-rate diagnostic data where a gap is irrelevant — a per-second battery voltage feed, for instance.

QoS 1 adds a PUBACK: the sender retransmits until acknowledged. Delivery is guaranteed and duplicates are expected, because a lost PUBACK causes a retransmit of a message that already arrived. Two round trips instead of one, and one extra broker state entry per in-flight message.

QoS 2 adds a four-step handshake that guarantees exactly-once delivery to the broker or to the subscribing client. It quadruples the round trips and doubles the broker’s per-message state. For telemetry it solves a problem that a unique index solves better: the four-step handshake protects the transport, but nothing protects you from a device that resends a reading after a power cycle, which arrives as a legitimately new QoS 2 message and a genuine duplicate reading.

That last point is the argument. Duplicate messages have several causes, and only one of them — transport retransmission — is what QoS 2 prevents. Since you need idempotency in the sink for the other causes anyway, paying four round trips to eliminate one of them is a poor trade. The Kafka guides in this section reach the same conclusion from the other direction.

Round trips per message, by quality of service Flow diagram showing the packet exchange for QoS 0, 1 and 2 — one packet, two packets, and a four-step handshake respectively — with the broker state each level holds. Round trips per message, by quality of service QoS 0 PUBLISH 1 packet, no state QoS 1 PUBLISH → PUBACK 2 packets, per-message state QoS 2 4-step handshake 4 packets, doubled state Sink dedupe natural key absorbs any duplicate The last box solves duplicates from every cause; QoS 2 solves them from one cause, for four times the round trips.
A device that resends after a power cycle produces a legitimately new QoS 2 message and a genuine duplicate reading — which is the whole argument for the unique index.

Production-Ready Implementation

The subscriber configuration first. Three settings decide whether QoS 1 delivers on its promise:

# python 3.11 · paho-mqtt==2.1.0 · psycopg[binary]==3.1.18
import paho.mqtt.client as mqtt

def build_subscriber(client_id: str) -> mqtt.Client:
    """Persistent-session subscriber: the broker queues QoS 1 messages across outages."""
    client = mqtt.Client(
        mqtt.CallbackAPIVersion.VERSION2,
        client_id=client_id,          # STABLE — a random id gets a fresh, empty session
        clean_session=False,          # keep the subscription and queue across disconnects
        protocol=mqtt.MQTTv311,
    )
    client.max_inflight_messages_set(100)
    client.max_queued_messages_set(0)     # 0 = unbounded client-side outbound queue
    return client

A stable client_id with clean_session=False is the whole mechanism: the broker recognises the returning client, replays what it queued, and the outage becomes latency rather than loss. Generating a random client id per process — the default in many examples — silently converts every restart into a data gap.

The sink is where duplicates are absorbed. The natural key is the reading’s identity, not the message’s:

CREATE TABLE reading (
    device_id    text        NOT NULL,
    observed_at  timestamptz NOT NULL,
    metric       text        NOT NULL,
    value        double precision NOT NULL,
    received_at  timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (device_id, observed_at, metric)
);
INSERT_SQL = """
INSERT INTO reading (device_id, observed_at, metric, value)
VALUES (%(device_id)s, %(observed_at)s, %(metric)s, %(value)s)
ON CONFLICT (device_id, observed_at, metric) DO NOTHING
RETURNING 1
"""

def write_batch(conn, rows: list[dict]) -> int:
    """Insert readings idempotently. Returns how many were genuinely new."""
    with conn.cursor() as cur:
        cur.executemany(INSERT_SQL, rows, returning=True)
        inserted = 0
        while True:
            if cur.fetchone() is not None:
                inserted += 1
            if not cur.nextset():
                break
    conn.commit()
    return inserted

Counting the genuinely-new rows is not decoration: len(rows) - inserted is your duplicate rate, and it is the single most useful number for diagnosing this layer. A steady 1–3% is normal QoS 1 retransmission. A sudden jump to 50% means a device is republishing its buffer, and a drop to zero alongside falling volume means messages are being lost before they reach the sink.

The message handler must not acknowledge before the write succeeds, which in paho means doing the write inside the callback (or handing it to a queue whose failure path re-raises):

def on_message(client, userdata, msg):
    reading = decode(msg.payload)            # raises on malformed payloads
    try:
        write_batch(userdata["conn"], [reading])
    except psycopg.OperationalError:
        client.loop_stop()                   # stop consuming; the broker keeps the queue
        raise

Stopping the loop on a sink failure is what makes the broker your overflow buffer. The alternative — catching the exception, logging it, and returning — acknowledges a message you never stored.

Persistent session versus clean session, through one outage Timeline of a subscriber disconnecting for forty seconds. With a persistent session the broker queues QoS 1 messages and replays them on reconnect; with a clean session it discards the subscription and the messages are lost. Persistent session versus clean session, through one outage Connected subscribed at QoS 1 Disconnect clean_session decides now 18 messages published queued, or discarded Reconnect stable client_id Replay all 18 — or none seconds
One boolean and a stable client id decide whether an outage is latency or loss. A randomly generated client id silently chooses loss.

Parameter Tuning Guide

Scenario QoS clean_session Sink dedupe Expected duplicate rate
Regulatory air quality 1 False primary key 1–3%
Live operations dashboard 1 False primary key 1–3%
High-rate diagnostics (battery, RSSI) 0 True none needed n/a — gaps acceptable
Actuator command downlink 2 False command id ~0%
Backfill republish from a gateway buffer 1 False primary key 20–90% during replay

The last row is worth internalising: during a buffered backlog replay almost everything arriving is a duplicate, by design. If your monitoring alerts on a high duplicate rate, it will alert on every successful recovery.

Duplicate rate over a week, and what each spike means Line chart of the share of received messages that were duplicates over seven days, showing a 1 to 3 percent baseline from ordinary QoS 1 retransmission and a large spike during a gateway backlog replay. Duplicate rate over a week, and what each spike means 0 20 40 60 Mon Tue Wed Thu Sun buffered backlog replay duplicates (%) 6-hour interval over one week duplicate share of received messages
The spike is a successful recovery, not an incident. Alerting on duplicate rate without excluding replay windows pages on every restored outage.

Verification and Testing

The test that matters simulates the failure QoS 1 is meant to survive: an acknowledgement lost after the write.

# python 3.11 · pytest==8.2.0
def test_redelivery_after_lost_ack_does_not_duplicate_the_reading(conn):
    reading = {
        "device_id": "eui-70b3d5",
        "observed_at": "2026-08-01T10:00:00+00:00",
        "metric": "pm25",
        "value": 18.4,
    }
    assert write_batch(conn, [reading]) == 1      # first delivery: genuinely new
    assert write_batch(conn, [reading]) == 0      # redelivery: absorbed, not duplicated

    with conn.cursor() as cur:
        cur.execute("SELECT count(*) FROM reading WHERE device_id = 'eui-70b3d5'")
        assert cur.fetchone()[0] == 1

Then verify the persistent session end to end, because it is the setting most often wrong: connect a subscriber with clean_session=False, disconnect it, publish three messages at QoS 1, reconnect, and assert all three arrive. Run the same test with clean_session=True and watch all three vanish — it is the fastest way to convince a team that the flag is not a detail.

Gotchas

A randomly generated client id defeats the persistent session. Libraries and container orchestrators both encourage per-process randomness. Derive the client id from a stable identity — the hostname, the pod’s ordinal in a StatefulSet, the device serial — or the broker will treat every restart as a new client with an empty queue.

Two subscribers sharing one client id. The broker allows only one connection per client id and will disconnect the incumbent when the second connects. Two replicas configured identically produce an endless disconnect loop that looks like a network fault. Use MQTT 5 shared subscriptions for horizontal scaling, not duplicate client ids.

Deduplicating on received_at instead of observed_at. A duplicate arrives at a different wall-clock time, so a key that includes the receive time deduplicates nothing. The natural key must be composed only of facts about the reading itself.

Unbounded max_queued_messages on the broker for an offline subscriber. The broker will queue until it runs out of memory. Set a limit per client that reflects how long an outage you intend to cover, and accept that beyond it the queue drops the oldest — which is the honest failure and the one your gap detection will notice.


FAQ

Which QoS level should environmental telemetry use?

QoS 1 for almost everything. It guarantees delivery, costs two network round trips instead of one, and produces duplicates that a unique key on sensor and observation time absorbs for free. QoS 0 loses readings during any reconnect, and QoS 2 doubles the round trips again to solve a problem your database schema already solves.

Does the DUP flag tell me a message is a duplicate?

Only that the broker is redelivering that packet — it says nothing about whether you already processed it. A redelivery after your acknowledgement was lost arrives with DUP set and is genuinely new to you; a republish by a confused device arrives with DUP clear and is a duplicate. Deduplicate on the reading identity, never on the flag.

Why do I still lose messages with QoS 1?

Almost always because clean_session is True. A clean session tells the broker to discard the subscription and its queued messages the moment you disconnect, so QoS 1 guarantees delivery only while you are connected — which is not a guarantee. Set clean_session to False with a stable client id and the broker holds the queue across the gap.