← Back to blog
ARCHITECTURE7 min read

QoS 0, 1, and 2: Choosing the Right Delivery Guarantee for Every Message

HB
Henco Burger
March 31, 2026

Not all messages are equal. A GPS coordinate from a moving vehicle that arrives 200ms late is still useful. That same coordinate arriving twice is harmless. But a payment confirmation that gets processed twice is a serious problem, and a payment that disappears silently is even worse.

Quality of Service (QoS) levels let you express how important it is that a specific message gets delivered, and how much overhead you are willing to pay for it. The three levels originated in MQTT, and the concepts apply broadly to any real-time messaging system. What matters in practice is knowing exactly which part of a message's journey a level covers, so this post is precise about that for NoLag.

Two legs, one setting

A message published through NoLag travels two legs: from your client to a NoLag gateway over WebSocket, and from that gateway across NoLag's internal MQTT broker to the gateways of every subscriber. The QoS level you set on a publish (0, 1 or 2, default 1) is validated and applied to the second leg, the broker hop. The first leg, the WebSocket connection, has its own simpler contract, which the last section spells out. Keep that split in mind while reading what each level means.

QoS 0: At Most Once (Fire and Forget)

QoS 0 is the simplest delivery mode. The message is forwarded once across the broker hop. No acknowledgment, no retry, no storage. If a broker node is under pressure or a subscriber's gateway is unreachable at that instant, the message is gone.

// QoS 0: forwarded once on the broker hop, no ack, no retry
room.emit('positions', { lat: 51.5074, lng: -0.1278, speed: 42 }, { qos: 0 })

// The broker sends this to subscribers and forgets it.
// If a subscriber is offline, they miss it. That's acceptable
// for position updates because the next one arrives in 100ms.

QoS 0 has the lowest latency and the lowest broker overhead. There is no acknowledgment round-trip, no message storage, no retry queue. The broker processes the message and moves on.

When to use QoS 0

  • High-frequency telemetry. A temperature sensor sending a reading every second does not need guaranteed delivery. The next reading arrives momentarily. Losing one sample in a thousand is acceptable.
  • Typing indicators. Whether or not "Alice is typing..." arrives is not important. It is a transient UI hint. A missed indicator does not corrupt state.
  • Live cursor positions. In a collaborative document editor, cursor positions are sent many times per second. Missing one frame is invisible to users.
  • Animated dashboards. A dashboard showing a live metric that updates 10 times per second can afford to drop occasional frames without the user noticing.

QoS 1: At Least Once (Acknowledged Delivery)

QoS 1 adds an acknowledgment handshake on the broker hop. The gateway publishes into the internal broker and waits for its acknowledgment; if that acknowledgment is lost, the MQTT client can resend, which is where a duplicate can come from. This is NoLag's default level.

// QoS 1 (the default, shown explicitly): acknowledged on the broker hop
room.emit('messages', { text: 'Hey, are you free?', msgId: 'msg_01' }, { qos: 1 })

// Flow on the broker hop:
// 1. Gateway publishes the message into the internal broker
// 2. Broker delivers to subscribers' gateways
// 3. Broker acknowledges (PUBACK) to the publishing gateway
//
// If step 3 is lost the hop can resend, so a subscriber
// may receive it twice. The application must handle this.

The "at least once" label is precise: the message is delivered across the hop, but it might be delivered more than once if an ack gets lost and the hop retries. The subscriber must handle duplicate delivery, either by ignoring duplicates (idempotent processing) or by tracking message IDs and deduplicating.

When to use QoS 1

  • Chat messages. A message that never arrives is a broken product. A message that appears twice is an edge case the UI can handle by deduplicating on message ID. QoS 1 is the right default for chat.
  • Notifications. A push notification that gets lost means the user never knew about an important event. Receiving it twice is an annoyance. The reliability side of that tradeoff wins.
  • Order status updates. A user waiting for their delivery status to change needs that update. Missing it means a support ticket. Receiving it twice means a brief flicker in the UI.
  • IoT commands. Sending a "turn off" command to a device that occasionally drops packets needs retry logic. A double-delivery of "turn off" when the device is already off is harmless.

QoS 2: Deduplicated on the Broker Hop

QoS 2 is MQTT's strongest level. On the broker hop it runs a four-step handshake so that a retry can never produce a duplicate across that hop.

// QoS 2: four-way handshake on the broker hop
room.emit('payments', { amount: 299.00, orderId: 'ord_7x2k', idempotencyKey: 'pay_7x2k_1' }, { qos: 2 })

// Flow on the broker hop:
// 1. Gateway sends PUBLISH (stored by the broker)
// 2. Broker sends PUBREC (publish received)
// 3. Gateway sends PUBREL (publish release), broker can now deliver
// 4. Broker delivers to subscribers' gateways, sends PUBCOMP (complete)
//
// A retry inside this flow cannot produce a duplicate on the hop.
// It says nothing about the WebSocket legs on either side of it.

This four-step protocol is more expensive than QoS 1. Each message requires two round-trips on the hop instead of one, and the broker must hold message state across the full handshake, which means more writes, more memory, and more latency. For a high-volume stream this overhead is prohibitive.

What QoS 2 does not give you is exactly-once delivery end to end. The handshake runs between NoLag's gateways and its broker; your client's WebSocket connection is not part of it. If your client publishes the same payment twice (a retry after a lost acknowledgment, a double-click, a replayed request), QoS 2 will faithfully deliver both. So the rule for irreversible actions is: use QoS 2 for the hop, carry an idempotency key in the payload, and make the consumer idempotent. The key is what makes the action happen once; QoS 2 removes one source of duplicates on the way there.

When to use QoS 2

  • Financial transactions. A payment that processes twice charges the customer twice, and a payment that is silently dropped causes a lost sale. Pay the QoS 2 cost on the hop, and dedupe on the idempotency key at the consumer, because the level alone does not make the charge exactly-once.
  • Inventory mutations. Decrementing stock by one must happen once per sale. Treat the sale ID as the idempotency key so a redelivered message is a no-op.
  • Audit log entries. A compliance trail should not carry the same event twice. QoS 2 removes hop-level duplicates; a unique event ID lets the writer discard the rest.
  • Irreversible downstream triggers. Any message that fires an action you cannot undo deserves the strongest hop-level setting and an idempotent receiver.

The Latency vs Reliability Tradeoff

The cost of each level is concrete, and it is paid on the broker hop:

QoS levelRound trips on the hopBroker storageDuplicate risk on the hop
QoS 00NoneNone (but may be dropped)
QoS 11Until acknowledgedPossible
QoS 22Until completeNone

For most interactive apps, QoS 0 and QoS 1 cover everything. QoS 0 for ephemeral state (presence, cursors, typing) and QoS 1 for durable events (messages, notifications). QoS 2 is reserved for cases where the downstream consequence of a duplicate or a drop is financially or legally significant, and even there it is one layer of a defence that also needs an idempotency key.

Per-Message QoS, Not Per-Connection

One of the design decisions that matters most in practice is whether QoS is a per-connection setting or a per-message setting. If it is per-connection, every message on that connection uses the same delivery guarantee. That forces you to open multiple connections if you have mixed requirements, or to use the most conservative QoS for everything (which means paying the QoS 2 cost for typing indicators that do not need it).

Per-message QoS is the right model. A single connection can carry QoS 0 telemetry, QoS 1 chat messages, and QoS 2 payment events simultaneously. The broker applies the appropriate delivery logic per message based on what the publisher requested.

How NoLag Handles QoS

NoLag attaches QoS per message. Pass { qos } on any emit, or set a connection-wide default with NoLag(token, { qos }) (the platform default is 1). Anything outside 0 to 2 is treated as 1. Subscriptions can carry their own qos too, for the delivering side of the hop.

import { NoLag } from '@nolag/js-sdk'

// APP_SLUG is the slug returned when you created the app (it has a random
// suffix); the room must already exist in the control plane.
const client = NoLag(ACTOR_TOKEN)
await client.connect()
const room = client.setApp(APP_SLUG).setRoom('general')

// Ephemeral presence-style update: forwarded once, no storage
room.emit('cursors', cursorPosition, { qos: 0 })

// Chat message: acknowledged on the hop, duplicates are manageable
room.emit('messages', chatMessage, { qos: 1 })

// Payment event: deduplicated on the hop, and the payload carries the
// idempotency key that makes the consumer safe against everything else
room.emit('payments', { ...paymentEvent, idempotencyKey }, { qos: 2 })

All three flow over the same WebSocket connection. The connection overhead is paid once. The per-message overhead reflects only what that specific message requires.

The WebSocket leg between your client and NoLag has a simpler and separate contract, and it is the same for every level:

  • A publish can be acknowledged. Pass a callback to emit and the SDK attaches a msgRef to the frame; the broker answers with a published frame once it has accepted the message (protocol v2: js-sdk 1.6+, python-sdk 2.5+, go-sdk v0.5.0). That acknowledgment is what the callback resolves on, and a rejection (unknown room, ACL, rate limit, size) arrives as the callback's error instead.
  • Nothing is resent for you. If no acknowledgment arrives within 10 seconds the callback receives an error, and it is up to you to publish again, which is exactly the moment an idempotency key earns its keep.
  • Two platform limits apply to every message. A connection may publish at most 50 messages per second, and a message may be at most 900 KB on every plan; both are rejected with an error rather than queued.
room.emit('payments', paymentEvent, { qos: 2 }, (err) => {
  if (err) {
    // Not accepted, or not acknowledged within 10s. The SDK did not retry.
    schedulePublishRetry(paymentEvent, err)
    return
  }
  markAsSent(paymentEvent)
})

Two things NoLag does not do, so you can plan for them: it does not replay messages to an ordinary client that reconnects (subscriptions are restored, nothing is resent; the exception is durable delivery for load-balanced worker groups), and it never delivers a message back to the actor that published it, whatever the level. Both are worth knowing before you write a test that publishes and expects to receive.

Practical Guidance: What to Use When

If you are not sure where to start, here is a simple decision tree:

  • The message is transient state (typing, cursor, presence). Use QoS 0. A missed delivery has no lasting effect.
  • The message represents a durable event (chat, notification, status change). Use QoS 1, the default. Ensure your subscriber deduplicates on message ID for resilience.
  • The message triggers an irreversible action (payment, inventory decrement, audit record). Use QoS 2 on the hop, pass a callback so you know the broker accepted it, put an idempotency key in the payload, and make the consumer idempotent. QoS alone does not make an action happen exactly once.

The goal is to match the delivery guarantee to the actual consequence of failure. Over-engineering everything to QoS 2 wastes resources and adds latency. Under-engineering payment flows to QoS 0 is a production incident waiting to happen. The right answer is almost always a mix, applied per message type, with idempotency doing the part that no transport level can.