In MQTT 3.1.1, the question “how many QoS 1 messages can I have in flight at once?” had no clear answer. The spec didn’t say. Implementations varied — some Servers accepted thousands of pipelined PUBLISH packets; others would only process one in-flight message per client. Clients that wanted predictable behavior had to assume the worst (one in-flight message at a time) and accept catastrophic throughput consequences.
MQTT 5 fixed this with explicit flow control. Both parties declare exactly how many in-flight QoS 1 and QoS 2 messages they will accept via the Receive Maximum property during connection establishment. The sender then MUST stay within that limit — exceeding it results in immediate DISCONNECT with Reason Code 0x93 (Receive Maximum exceeded). This single addition unlocks dramatic throughput improvements for high-volume systems while protecting Servers from being flooded by aggressive clients.
This article explains how MQTT 5 Flow Control works: the Receive Maximum mechanism, how the counter logic interacts with QoS 1 vs QoS 2 acknowledgements, how it pairs with Maximum Packet Size for complete bandwidth governance, and how to tune both values for production deployments.
For broader MQTT 5 context, see our MQTT 5 New Features Overview and MQTT QoS Levels Explained.
Table of Contents
What MQTT 5 Flow Control is in one paragraph
MQTT 5 Flow Control is the mechanism by which clients and Servers declare to each other the maximum number of unacknowledged QoS 1 and QoS 2 PUBLISH messages they will accept at any time. Per the OASIS MQTT v5.0 standard, each party communicates a Receive Maximum value (UINT16, range 1-65,535) in CONNECT (client) or CONNACK (Server). The sender then MUST NOT send more PUBLISH packets than the receiver’s Receive Maximum allows to remain in flight at once. Violating this rule triggers DISCONNECT with Reason Code 0x93. The mechanism enables explicit pipelining for high throughput while providing backpressure to protect overloaded receivers — both critical for industrial IoT deployments where messaging rates can spike dramatically.
Why MQTT 5 added explicit flow control
MQTT 3.1.1 had no flow control mechanism. The result was a long list of operational pain points:
Pain point 1: Implementation-dependent behavior
Some MQTT 3.1.1 Servers accepted unlimited pipelined PUBLISH packets — happily queuing thousands of in-flight QoS 1 messages and acknowledging them out of order. Other Servers strictly enforced “one in-flight message at a time” — accepting a PUBLISH, returning PUBACK, then accepting the next. The same client code performed differently against different Servers.
Pain point 2: Memory exhaustion vulnerability
Servers without flow control could be flooded by aggressive clients. A misbehaving client pushing 100,000 QoS 1 messages per second could exhaust the Server’s memory while it waited to process acknowledgements. There was no protocol-level mechanism to slow the client down — only TCP backpressure, which is too coarse-grained for message-level rate limiting.
Pain point 3: No portable throughput tuning
Production tuning required vendor-specific knowledge. “How fast can I publish QoS 1 messages to HiveMQ vs EMQX vs Mosquitto?” had different answers per implementation, none of them documented in the spec.
Pain point 4: Pipelining was a guessing game
Clients wanting to maximize throughput had to guess how many in-flight messages the Server would accept. Guess too low, throughput suffered. Guess too high, the Server might disconnect with no useful diagnostic.
MQTT 5 Flow Control addresses all four problems with a single mechanism: both parties declare their capacity explicitly, the sender stays within that capacity, violations are detected and signaled clearly. Implementation-portable, backpressure-aware, and predictable.
Receive Maximum (Property 0x21) — the core mechanism
Receive Maximum is a UINT16 property exchanged during connection establishment:
| Field | Value |
|---|---|
| Property Identifier | 0x21 (33 decimal) |
| Type | UINT16 (Variable Byte Integer encoded as 2 bytes) |
| Valid range | 1 to 65,535 |
| Invalid value | 0 (triggers Protocol Error, Reason Code 0x82) |
| Default if absent | 65,535 (effectively unlimited) |
| Appears in | CONNECT (client→Server), CONNACK (Server→client) |
How both directions work
The client sends its Receive Maximum in CONNECT — telling the Server “you may have at most N QoS 1+2 messages in flight to me at once.” The Server sends its Receive Maximum in CONNACK — telling the client “you may have at most M QoS 1+2 messages in flight to me at once.”
The two values are independent. A client can declare Receive Maximum = 10 (it’s an embedded device with limited memory) while the Server declares Receive Maximum = 1000 (it can handle many in-flight messages from a beefy client). Each direction has its own limit.
Whose limit applies?
The receiver’s limit governs the sender’s behavior:
- Client → Server PUBLISH: limited by the Server’s Receive Maximum (from CONNACK)
- Server → Client PUBLISH: limited by the client’s Receive Maximum (from CONNECT)
A common confusion: the client’s Receive Maximum is the limit the Server must respect when publishing to the client. It is NOT a limit on what the client can publish.
The counter logic — what increments and decrements
The Receive Maximum constraint operates as a counter at the sending side. Per OASIS MQTT v5.0 Section 4.9:
Conceptual sender-side counter:
Initial value: 0
When sending a QoS 1 or QoS 2 PUBLISH:
counter += 1
When receiving certain acknowledgements:
counter -= 1
When counter ≥ receiver's Receive Maximum:
DO NOT send more PUBLISH (block or queue)
The sender’s job is to ensure the counter never exceeds the receiver’s declared Receive Maximum. The sender maintains separate counters for messages going in each direction (its own outbound count, and the receiver’s outbound count is the receiver’s problem to track).
What does NOT count toward the limit
- QoS 0 PUBLISH messages — no acknowledgement, no in-flight state, don’t count
- CONNECT, SUBSCRIBE, UNSUBSCRIBE, PINGREQ, DISCONNECT, AUTH — control packets, not affected by flow control
- PUBACK, PUBREC, PUBREL, PUBCOMP, SUBACK, UNSUBACK, PINGRESP, CONNACK — acknowledgement packets, not subject to flow control themselves
Only QoS 1 and QoS 2 PUBLISH messages count. This is critical — a publisher can keep sending QoS 0 fire-and-forget messages even when at the QoS 1+2 limit.
QoS 1 vs QoS 2 counter behavior
This is where many engineers get confused. The counter behavior differs between QoS 1 and QoS 2:
QoS 1 — increment on PUBLISH, decrement on PUBACK
Sender sends PUBLISH (QoS 1, packet_id=42) counter = N
... wait ...
Sender receives PUBACK (packet_id=42) counter = N - 1
Simple. Counter holds for the duration between PUBLISH and PUBACK.
QoS 2 — increment on PUBLISH, decrement on PUBCOMP
QoS 2 has a four-message handshake. Per the spec, the counter:
- Increments when sender sends PUBLISH
- Does NOT decrement when PUBREC arrives with success
- Decrements only when PUBCOMP arrives, completing the full handshake
Sender sends PUBLISH (QoS 2, packet_id=43) counter = N
Sender receives PUBREC (packet_id=43) counter = N (still!)
Sender sends PUBREL (packet_id=43) counter = N (still!)
Sender receives PUBCOMP (packet_id=43) counter = N - 1
The implication: QoS 2 messages occupy capacity longer than QoS 1 messages. If you mix QoS 1 and QoS 2, the QoS 2 messages will dominate Receive Maximum capacity because they release more slowly.
Special case: PUBREC with failure Reason Code
If a QoS 2 PUBLISH gets PUBREC with a failure Reason Code (≥0x80), the flow is abandoned and the counter does decrement immediately. The handshake doesn’t continue to PUBREL/PUBCOMP because the message was rejected.
Sender sends PUBLISH (QoS 2, packet_id=44) counter = N
Sender receives PUBREC (packet_id=44, RC=0x87) counter = N - 1 ← abandoned
For more on the failure Reason Codes, see our MQTT 5 Negative Acknowledgements article.
Maximum Packet Size (Property 0x27) — the companion mechanism
Receive Maximum controls how many in-flight messages. Maximum Packet Size controls how big any single packet can be. They work together to bound the receiver’s memory commitment.
Per the OASIS MQTT v5.0 specification:
| Field | Value |
|---|---|
| Property Identifier | 0x27 (39 decimal) |
| Type | UINT32 (4 bytes) |
| Valid range | 1 to 268,435,455 (the MQTT max packet size = ~256 MB) |
| Invalid value | 0 (triggers Protocol Error) |
| Default if absent | No limit (subject to absolute spec maximum) |
| Appears in | CONNECT (client→Server), CONNACK (Server→client) |
If the sender exceeds the receiver’s Maximum Packet Size, the receiver responds with DISCONNECT and Reason Code 0x95 (Packet too large).
How Receive Maximum and Maximum Packet Size combine
Memory budget estimation:
Worst case receiver memory commitment
= Receive Maximum × Maximum Packet Size
For Receive Maximum = 100 and Maximum Packet Size = 1 MB, the receiver must be prepared to hold up to ~100 MB of message data in flight at peak. For embedded devices with limited memory, both values must be tuned together.
Practical flow example
A complete example showing flow control in action:
─── Connection establishment ───
Client → Server: CONNECT
Client Identifier: "sensor-007"
Properties:
Receive Maximum: 5
Maximum Packet Size: 8192
Server → Client: CONNACK
Reason Code: 0x00 Success
Properties:
Receive Maximum: 1000
Maximum Packet Size: 524288
─── Client can now send up to 1000 in-flight QoS 1+2 to Server ───
─── Server can now send up to 5 in-flight QoS 1+2 to Client ───
─── Client publishes a burst of QoS 1 messages ───
Client → Server: PUBLISH (QoS 1, packet_id=1, topic="t/1") counter = 1
Client → Server: PUBLISH (QoS 1, packet_id=2, topic="t/2") counter = 2
Client → Server: PUBLISH (QoS 1, packet_id=3, topic="t/3") counter = 3
... up to 1000 in flight ...
Client → Server: PUBLISH (QoS 1, packet_id=1000, ...) counter = 1000
─── Client CANNOT send packet_id=1001 until something is acknowledged ───
Server → Client: PUBACK (packet_id=1) counter = 999
Server → Client: PUBACK (packet_id=2) counter = 998
─── Now there is capacity for 2 more in-flight messages ───
Client → Server: PUBLISH (QoS 1, packet_id=1001, ...) counter = 999
Client → Server: PUBLISH (QoS 1, packet_id=1002, ...) counter = 1000
The pattern: pipeline up to the limit, wait for acknowledgements to free up capacity, send more. Sustained throughput is bounded by network round-trip time and Receive Maximum.
Failure mode — Receive Maximum exceeded (0x93)
If the sender violates the receiver’s Receive Maximum, the receiver responds with DISCONNECT.
When the receiver detects more than the negotiated Receive Maximum in-flight QoS 1 or QoS 2 PUBLISH messages from the sender:
Receiver → Sender: DISCONNECT
Reason Code: 0x93 (Receive Maximum exceeded)
Properties (optional):
Reason String: "Client exceeded declared Receive Maximum of 5"
The TCP connection is then closed. The sender must reconnect to resume operation.
Why this is the sender’s responsibility
The spec is clear: it is the sender’s responsibility to track the in-flight count and stop sending before exceeding the limit. The receiver’s enforcement (DISCONNECT 0x93) is a safety net, not a normal flow control mechanism. A well-behaved sender never triggers this.
If your client implementation regularly triggers 0x93, it has a bug in its flow control accounting — possibly:
- Not tracking the counter at all
- Not handling PUBCOMP correctly (forgetting to decrement)
- Mistakenly using PUBREC to decrement for QoS 2 (incorrect)
- Not respecting the Server’s declared Receive Maximum
Tuning Receive Maximum for production
The right value depends on your deployment characteristics:
For embedded devices (sensors, gateways)
| Scenario | Recommended Receive Maximum |
|---|---|
| Tiny memory (e.g., 64 KB total) | 5-10 |
| Modest memory (e.g., 512 KB) | 20-50 |
| Higher-capacity device | 100-500 |
Lower values use less memory but limit throughput. For an embedded sensor publishing at slow rates (every few seconds), low values are appropriate.
For high-throughput publishers
| Scenario | Recommended Receive Maximum |
|---|---|
| Continuous bulk publishing | 1000+ |
| Batch publishing | 100-500 |
| Mixed QoS workload | Higher (QoS 2 holds capacity longer) |
Higher values enable pipelining to maximize throughput. The constraint is memory at both ends — both sender (tracking unacknowledged messages) and receiver (handling in-flight messages) need memory proportional to Receive Maximum.
For Servers (the Server’s declared Receive Maximum)
Most Servers can handle high in-flight counts. Defaults commonly seen:
| Server | Default Receive Maximum |
|---|---|
| HiveMQ | 10 (configurable up to 65,535) |
| EMQX | 32 (configurable) |
| Mosquitto | 20 (configurable) |
| AWS IoT Core | 100 (per device fleet) |
These are conservative defaults. Production deployments with verified higher-capacity clients often raise these values significantly.
Memory budgeting
Quick budget formula for tuning:
Total memory budget = Receive Maximum × (avg message size + overhead)
Where overhead is typically ~64-256 bytes per in-flight message
(packet identifier mapping, sequence tracking, etc.)
For Receive Maximum = 1000 with 1 KB average messages and 128-byte overhead per message, expect ~1.13 MB of memory committed to in-flight tracking per direction per connection.
Interaction with other MQTT 5 features
Flow Control interacts with several other MQTT 5 mechanisms:
Topic Aliases reduce per-packet size
When the Topic Alias property reduces packet sizes (by replacing repeated topic names with 16-bit alias IDs), the same Receive Maximum allows higher effective throughput. Smaller packets mean less Maximum Packet Size pressure.
Session Expiry affects in-flight state
If the client disconnects with Clean Start = 0 and a non-zero Session Expiry Interval, in-flight QoS 1+2 messages are preserved across reconnections. On reconnection, the new Receive Maximum applies — but the existing in-flight messages still count.
User Properties don’t affect flow control
User Properties (Property 0x26) add metadata but don’t directly impact flow control beyond contributing to overall packet size. For more on User Properties, see our MQTT 5 User Properties article.
Receive Maximum constraints during re-authentication
When using Enhanced Authentication with re-authentication (see Enhanced Authentication in MQTT 5), in-flight message counters persist through the re-auth process. The Receive Maximum value cannot change during the session — it’s fixed at CONNECT/CONNACK time.
Backward compatibility with MQTT 3.1.1
MQTT 3.1.1 has no Receive Maximum property and no explicit flow control. Compatibility considerations:
MQTT 5 client connecting to MQTT 3.1.1 Server
Doesn’t happen — MQTT 5 clients can’t downgrade to MQTT 3.1.1 mid-handshake. The CONNECT would return CONNACK with Reason Code 0x84 (Unsupported Protocol Version), forcing the client to reconnect using MQTT 3.1.1 semantics if it wants to talk to the Server. Once using MQTT 3.1.1, there is no flow control to discuss.
MQTT 3.1.1 client connecting to MQTT 5 Server
The Server downgrades to MQTT 3.1.1 semantics. Flow control properties are not exchanged. The Server typically uses an implementation-specific limit on in-flight messages (often the same default it would announce to MQTT 5 clients). Clients have no way to know this limit explicitly.
Mixed MQTT 5 / MQTT 3.1.1 fleets
In a single Server handling both protocol versions, the Server enforces in-flight limits via:
- Receive Maximum (for MQTT 5 clients)
- Implementation-specific limit (for MQTT 3.1.1 clients)
Both sets of clients are protected from flooding the Server, but only MQTT 5 clients get explicit visibility into the limits.
For broader version comparison, see our MQTT 5 vs MQTT 3.1.1 Comparison article.
Implementation guidance
For client implementations
Build a flow control state machine that:
- Reads the Server’s Receive Maximum from CONNACK
- Maintains an outbound counter of in-flight QoS 1+2 PUBLISH messages
- Blocks or queues new QoS 1+2 PUBLISH operations when at the limit
- Increments on send, decrements on PUBACK (QoS 1) or PUBCOMP (QoS 2)
- Handles QoS 2 PUBREC failures by decrementing immediately
- Resets on session expiry or reconnection (but accounts for preserved in-flight messages with Session Expiry)
For Server implementations
Servers typically need to:
- Track in-flight counts per connection
- Enforce Receive Maximum by responding with DISCONNECT 0x93 if exceeded
- Track per-connection memory budgets
- Apply rate limits beyond Receive Maximum (Maximum Packet Size + connection rate limits)
- Provide observability — log when clients approach or exceed limits
Pseudocode for client flow control
python
class FlowControl:
def __init__(self, server_receive_max):
self.limit = server_receive_max
self.in_flight = 0
def can_publish_qos1_or_qos2(self):
return self.in_flight < self.limit
def on_publish_sent(self, qos):
if qos >= 1:
self.in_flight += 1
def on_puback_received(self):
self.in_flight -= 1
def on_pubrec_received(self, reason_code):
if reason_code >= 0x80: # Failure
self.in_flight -= 1 # Flow abandoned
def on_pubcomp_received(self):
self.in_flight -= 1 # QoS 2 complete
This is a simplified model — real implementations also track packet identifiers, handle retransmission, and manage session state.
Common errors and troubleshooting
| Symptom | Likely cause | What to fix |
|---|---|---|
| Server returns DISCONNECT 0x93 | Client exceeded Server’s Receive Maximum | Add flow control tracking on client side |
| Server returns DISCONNECT 0x95 | Packet exceeds Maximum Packet Size | Use Topic Aliases; reduce payload size |
| Throughput stuck at “one in flight” | Server has Receive Maximum = 1 (rare but possible) | Check Server config; request higher limit |
| Client’s in-flight counter drifts | Not handling PUBREC failures correctly | Decrement counter on PUBREC RC ≥ 0x80 |
| Connection occasionally dies | QoS 2 messages holding counter too long | Reduce QoS 2 usage or raise Receive Maximum |
| Memory exhaustion on Server | Multiple clients pipelining aggressively | Lower Receive Maximum on Server config |
| Inconsistent throughput | Network RTT varying | Higher Receive Maximum lets more in flight, masks RTT |
Frequently asked questions
What is MQTT 5 Flow Control?
MQTT 5 Flow Control is the mechanism by which clients and Servers explicitly declare the maximum number of unacknowledged QoS 1 and QoS 2 PUBLISH messages they will accept at once. Each party communicates a Receive Maximum value (UINT16, range 1-65,535) in CONNECT (client) or CONNACK (Server). The sender must stay within the receiver’s declared limit, with violations triggering DISCONNECT with Reason Code 0x93. This replaces MQTT 3.1.1’s implementation-dependent and undocumented in-flight handling.
What is Receive Maximum in MQTT 5?
Receive Maximum (Property 0x21) is a UINT16 value (range 1-65,535) sent in CONNECT or CONNACK that declares the maximum number of in-flight QoS 1 and QoS 2 PUBLISH messages the sender will accept. Setting Receive Maximum to 0 is invalid and triggers Protocol Error (Reason Code 0x82). If absent, the default is 65,535 (effectively unlimited). The client’s Receive Maximum applies to messages flowing from Server to client; the Server’s Receive Maximum applies to messages flowing from client to Server.
Does MQTT 3.1.1 have flow control?
No. MQTT 3.1.1 has no explicit flow control mechanism. Some implementations defaulted to one in-flight message at a time; others accepted unlimited pipelining. Behavior was implementation-dependent and not documented in the protocol. This is one of the key reasons MQTT 5 was designed to add the Receive Maximum property — to provide explicit, portable flow control.
Does QoS 0 count toward Receive Maximum?
No. QoS 0 PUBLISH messages do not count toward the in-flight limit because they have no acknowledgement — there’s no concept of “in flight” for QoS 0. A client can continue sending QoS 0 messages even when at the QoS 1+2 limit. Only QoS 1 and QoS 2 PUBLISH messages count toward Receive Maximum.
What happens to the in-flight counter for QoS 2 messages?
For QoS 2, the counter increments when PUBLISH is sent and decrements only when PUBCOMP is received (completing the full 4-message handshake). PUBREC alone does not decrement the counter — only PUBCOMP does for successful QoS 2 flows. There’s a special case: if PUBREC carries a failure Reason Code (≥0x80), the flow is abandoned and the counter decrements immediately. This means QoS 2 messages occupy capacity longer than QoS 1 messages.
What is Reason Code 0x93 in MQTT 5?
Reason Code 0x93 means “Receive Maximum exceeded.” It’s sent in DISCONNECT by the receiver when the sender exceeds the receiver’s declared Receive Maximum limit. The TCP connection is then closed. This is a safety net — a well-behaved sender tracks the in-flight count and never triggers this. If your client frequently sees 0x93, it has a flow control accounting bug.
What is Maximum Packet Size in MQTT 5?
Maximum Packet Size (Property 0x27) is a UINT32 value declared in CONNECT or CONNACK that limits the maximum size in bytes of a single packet the sender will accept. It complements Receive Maximum: Receive Maximum controls how many in-flight messages; Maximum Packet Size controls how big each one can be. Together they bound the receiver’s worst-case memory commitment. Exceeding Maximum Packet Size triggers DISCONNECT with Reason Code 0x95 (Packet too large).
How should I tune Receive Maximum for an embedded device?
For embedded devices with limited memory, set Receive Maximum to a conservative value. Recommended starting points: 5-10 for very memory-constrained devices (64 KB RAM), 20-50 for modest devices (512 KB RAM), 100-500 for higher-capacity devices. Memory budget is roughly Receive Maximum × (average message size + overhead). The lower the value, the less memory needed but the lower the throughput potential.
How should I tune Receive Maximum for high throughput?
For high-throughput publishers, use higher values (1000+) to enable pipelining that masks network round-trip time. Mixed QoS 1/QoS 2 workloads need extra headroom because QoS 2 messages occupy capacity longer. Servers usually enforce sensible limits — check your Server’s documentation for its default and maximum. AWS IoT Core defaults to 100; HiveMQ defaults to 10; EMQX to 32. Production deployments often configure these higher.
Why does the in-flight counter not decrement on PUBREC?
PUBREC is the first response in the QoS 2 four-message handshake, but it doesn’t complete the message exchange. The handshake continues with PUBREL (sender → receiver) and PUBCOMP (receiver → sender). Only the final PUBCOMP indicates the message exchange is complete — at which point the receiver has fully processed the message and can release its tracking state. Decrementing on PUBREC would let the sender pipeline more messages while the previous one is still being tracked, defeating the flow control purpose.
Does Receive Maximum survive client reconnection?
Receive Maximum is renegotiated at each new CONNECT — it’s not preserved across connections like some other session state. However, if the client reconnects with Clean Start = 0 and Session Expiry > 0, the in-flight messages themselves are preserved. The Server may continue to deliver unacknowledged QoS 1+2 messages. Those preserved messages count toward the new Receive Maximum until acknowledged.
How does Topic Alias relate to Flow Control?
Topic Aliases (a separate MQTT 5 feature) reduce the size of PUBLISH packets by replacing repeated topic name strings with 16-bit aliases. This indirectly helps Flow Control because smaller packets mean less Maximum Packet Size pressure and lower per-message memory cost — allowing higher effective throughput within the same Receive Maximum limit.
