MQTT-SN vs MQTT vs CoAP: Protocol Choice for Constrained Sensors (2026)
A temperature reading is four bytes of payload. Wrapped in MQTT over TCP over IPv6 over a 6LoWPAN radio link, that same reading drags along a TCP handshake, a 20-byte TCP header, a 25-byte application packet and a keep-alive schedule that forces the radio awake whether or not anything changed. The sensor is not doing more work. The protocol stack is simply sized for a different kind of device. Choosing between MQTT-SN vs MQTT (and CoAP, the third contender) is really a choice about who pays for state: the sensor, the gateway, or the radio.
This matters in 2026 because the constrained end of IoT has not gone away. Battery-powered 802.15.4 meshes, NB-IoT trackers and LoRaWAN-backhauled sensors still live on coin cells and energy harvesters, while the cloud side has consolidated around MQTT brokers. Engineers keep asking whether to bridge those worlds with MQTT-SN, speak CoAP natively, or just run MQTT 5 and accept the cost.
You will leave with a byte-by-byte cost of one publish in each protocol, a clear model of how an MQTT-SN gateway and sleeping clients behave, how CoAP Observe and OSCORE change the picture, and a decision matrix tied to the transport you actually have.
What this covers: protocol mechanics and standards status, a worked byte count, the sleep and energy model, security overhead (DTLS 1.3, OSCORE), failure modes, and a decision flow by transport.
Context and Background
Three protocols dominate the conversation, and they come from different lineages. MQTT (Message Queuing Telemetry Transport) is a publish/subscribe protocol that assumes a reliable ordered byte stream, which in practice means TCP, optionally wrapped in TLS. MQTT 3.1.1 and MQTT 5.0 are OASIS standards. The broker holds all session state: subscriptions, queued messages, retained messages. Clients keep a long-lived connection open and send a PINGREQ whenever the keep-alive interval would otherwise expire.
MQTT-SN (MQTT for Sensor Networks) keeps the publish/subscribe model but rewrites the wire format for small, lossy, datagram-oriented networks. It runs over any datagram service, most commonly UDP, and was originally designed with 802.15.4 and ZigBee-class radios in mind. The specification itself notes that the physical layer of such networks tops out around 128 bytes per packet, with roughly half consumed by MAC framing and security, and a 250 kbit/s ceiling in the 2.4 GHz band. Its defining ideas are short topic identifiers instead of topic strings, a gateway that translates to MQTT, and an explicit sleeping-client state.
CoAP (Constrained Application Protocol, RFC 7252) is not publish/subscribe at all. It is a REST-style request/response protocol with a compact binary header, built over UDP, with optional reliability through confirmable messages. Publish/subscribe-like behaviour is layered on top through the Observe extension (RFC 7641), and device management through LwM2M. We cover the CoAP stack in more depth in our CoAP constrained application protocol deep dive.
The standards status you should know before you commit
A point that surprises many teams: MQTT-SN is not an approved standard of any body. The OASIS MQTT-SN subcommittee page states that MQTT-SN, though publicly available, “is not recognized or approved by a standards body such as OASIS”, and that the subcommittee accepts MQTT-SN Version 1.2 as an input specification with the aim of refining it and integrating it with MQTT. Version 1.2 is the widely implemented document, dating from the IBM and Eurotech era; the OASIS page I read gives no specification dates beyond a 2020 copyright notice.
I also found a contributed MQTT-SN draft hosted in the OASIS documents area whose wire layout differs from 1.2: it uses a “Topic Alias Type” field, places QoS bits at different positions, defines a Publish Out of Band message (0x11) that supersedes QoS -1, and the document itself describes QoS -1 as kept for backward compatibility. I could not tie that draft to a ratified version, so treat it as work in progress. Everything byte-level in this article uses the 1.2 layout, which is what current open-source stacks implement. The Zephyr RTOS documentation, for example, states support for MQTT-SN version 1.2 over UDP.
Why this decision keeps being re-litigated
Two forces keep the debate alive. First, MQTT 5 added features (topic aliases, session expiry, receive maximum) that close part of the gap with MQTT-SN, as long as you can afford TCP. Second, 6LoWPAN header compression and DTLS 1.3 changed the cost of “just use IP”, so the old argument that IP stacks are too heavy for a Class 1 node is weaker than it was a decade ago. The residual question is not whether a node can run the stack. It is how many times per day the radio must wake up and how many bytes it must move to keep the stack’s illusion alive.
For constrained device classes, RFC 7228 defines the vocabulary: Class 0 devices are far below 10 KiB of RAM and cannot run a full IP stack on their own, Class 1 sits around 10 KiB of RAM and 100 KiB of flash, and Class 2 is roughly 50 KiB of RAM and 250 KiB of flash. Those are the right mental anchors for the rest of this post, and they explain why a UDP-based, gateway-assisted design remains attractive at the bottom of the range.
The Reference Architecture: Where Each Protocol Puts the State
For constrained sensors, MQTT-SN trades broker-side convenience for smaller packets and a gateway-managed session, MQTT 5 keeps the simplest cloud integration but requires TCP and a standing connection, and CoAP removes the session entirely in favour of stateless request/response with optional Observe subscriptions. The deciding factor is almost always the link layer and the sleep schedule, not the feature list.

Figure 1: Reference topology. MQTT-SN nodes and CoAP nodes sit on the datagram side of a border device; the MQTT broker and the CoAP resource directory sit on the IP side.
The figure shows the structural difference. An MQTT-SN node never talks to a broker. It talks to a gateway, which holds a real MQTT connection on its behalf. A CoAP node talks to whoever hosts the resource, usually an application server or a proxy, and registers itself with a resource directory so others can find it. A plain MQTT node talks straight to the broker, so the border device is just a router. The three arrangements place the long-lived, expensive connection state in three different boxes.
MQTT-SN: short IDs, a gateway, and a sleep state
The MQTT-SN 1.2 packet header is two bytes for any packet up to 255 bytes: a one-byte length covering the whole packet and a one-byte message type. If the first byte is 0x01, the length becomes three bytes and can reach 65,535, but the newer draft I read stresses that packets of 255 bytes or less must use the short form, and there is no fragmentation, so the network’s own maximum packet size is the real limit. In practice on 802.15.4 you never leave the short form.
A PUBLISH in 1.2 carries a flags byte, a two-byte topic identifier, a two-byte message identifier, and then data. The flags byte holds DUP, a two-bit QoS (0, 1, 2, or the special value that means QoS -1), Retain, Will, CleanSession and a two-bit TopicIdType. That last field is what makes the protocol small. A normal topic ID is a 16-bit alias negotiated with the gateway through a REGISTER/REGACK exchange. A predefined topic ID is a 16-bit value both sides know from configuration, so no registration is needed. A short topic name is a literal two-character topic carried in the topic field, again with no registration.
The cost model follows directly. A device that publishes to a handful of topics registers each once per session, then every publish pays two bytes for the topic instead of seventeen or more. A device that never sleeps for long can hold those mappings in RAM. A device that reboots every few minutes either re-registers (extra round trips) or uses predefined IDs and skips the exchange altogether.
QoS -1 deserves its own sentence because it is the feature that makes MQTT-SN unusual. A client may publish with QoS -1 without first establishing a connection, but only with a predefined topic ID or a short topic name. There is no CONNECT, no CONNACK, no ACK. The node wakes, transmits one datagram, and sleeps. The cost is that you get no delivery confirmation and no session. For a once-an-hour reading where the next reading supersedes the last, that is precisely the right trade. Be aware that QoS -1 support is uneven: the Zephyr client documentation lists QoS 0, 1 and 2 as supported and QoS -1 as not supported, and also lists predefined topic IDs as unsupported.
The MQTT-SN gateway: transparent versus aggregating
The spec describes two gateway styles. A transparent gateway opens one MQTT connection per MQTT-SN client, so the broker sees every sensor as its own client with its own client ID, will and subscriptions. An aggregating gateway multiplexes many clients over a single MQTT connection, which saves broker connections and gateway memory but hides individual sensors from the broker. Aggregation also means the broker cannot enforce per-device authentication or ACLs on its own.
The Eclipse Paho project ships an MQTT-SN transparent gateway: a Linux daemon that accepts MQTT-SN over UDP or XBee and converts to MQTT for brokers such as Eclipse Mosquitto. The Paho page describes only the transparent type and does not state which spec version it implements, and it has no prebuilt binaries, so you build from source. Treat gateway choice as a procurement risk: the open-source ecosystem is small, and commercial brokers sometimes embed their own MQTT-SN listener instead.
Discovery is optional. A gateway can broadcast ADVERTISE (message type 0x00) periodically, a client can ask with SEARCHGW (0x01), and gateways answer with GWINFO (0x02). The ADVERTISE duration field is two bytes of seconds, which tops out near 18 hours. Many embedded clients skip discovery entirely. Zephyr’s MQTT-SN library, for example, does not implement ADVERTISE, SEARCHGW or GWINFO, so you configure the gateway address by hand, and it also lacks forwarder encapsulation (message type 0xFE), which is how a forwarder relays frames between a radio network and a remote gateway.
CoAP: stateless exchanges and the Observe pattern
CoAP’s fixed header is four bytes: two bits of version, two bits of message type (confirmable, non-confirmable, acknowledgement, reset), four bits of token length, an eight-bit code, and a sixteen-bit message ID. A token of zero to eight bytes follows, then options encoded as delta/length pairs, then an optional 0xFF payload marker. Resource paths travel as Uri-Path options, one per segment, so there is no alias table: the name is spelled out in every request unless you choose short resource names.
CoAP Observe, defined in RFC 7641, is the publish/subscribe bridge. A client sends a GET with the Observe option set to 0 to register. The server then sends notifications whenever the resource state changes, each carrying an Observe sequence number in the Observe option. Notifications can be confirmable or non-confirmable; the RFC requires the server to send a confirmable notification at least every 24 hours so a vanished client is eventually detected. The client’s registration is bound to its token, which is why that token is repeated in every notification.
MQTT 5 over TCP: bigger, but the best-tooled
MQTT 5 added a Topic Alias property, which gives an MQTT client a similar saving to MQTT-SN’s topic IDs once the first packet has established the alias. It also added Session Expiry Interval and Receive Maximum. On a constrained link, though, TCP itself is the cost: a three-packet handshake, a 20-byte minimum header on every segment, retransmission timers tuned for wired networks, and no way to keep a connection alive across a deep sleep without either keep-alive traffic or relying on the broker’s session persistence and accepting a reconnect on every wake.
None of this makes MQTT 5 wrong. If your node already has TCP/IP because it is a Wi-Fi or LTE-M device, the incremental cost of MQTT is small, and the tooling advantage is large: every cloud platform, every broker and every observability stack speaks it. Our comparison of Sparkplug B versus plain MQTT topic design shows how much of the operational value lives in conventions layered above MQTT rather than in the wire format.
Deeper Analysis: One Temperature Reading, Byte by Byte
Abstract comparisons hide the real differences, so here is a concrete case. A node publishes the string 23.4 (4 bytes of ASCII payload) to the topic plant1/line2/temp (17 bytes). Every figure below is computed from the packet layouts by hand and reproduced by the script that follows; none comes from a benchmark, and they cover the application-layer packet only, before UDP, TCP, IP and link headers are added.
The application-layer byte count
| Protocol and mode | Packet layout | Bytes |
|---|---|---|
| MQTT 3.1.1 PUBLISH, QoS 0 | 1 fixed header, 1 remaining length, 2 topic length, 17 topic, 4 payload | 25 |
| MQTT 5 PUBLISH, QoS 0 | same plus 1 byte property length | 26 |
| MQTT 5 PUBLISH, QoS 0, topic alias already set | 1 + 1 + 2 (empty topic) + 1 property length + 3 alias property + 4 payload | 12 |
| MQTT 3.1.1 PUBLISH, QoS 1 | QoS 0 packet plus 2 packet identifier | 27 |
| MQTT-SN 1.2 PUBLISH, QoS 0, registered topic ID | 1 length, 1 type, 1 flags, 2 topic ID, 2 message ID, 4 payload | 11 |
| MQTT-SN 1.2 PUBLISH, QoS -1, predefined ID | same layout, no session | 11 |
CoAP NON PUT, path /plant1/line2/temp |
4 header, 18 Uri-Path options, 1 marker, 4 payload | 27 |
CoAP NON PUT, path /t |
4 header, 2 Uri-Path option, 1 marker, 4 payload | 11 |
| CoAP Observe notification, 2-byte token | 4 header, 2 token, 3 Observe option, 1 Content-Format, 1 marker, 4 payload | 15 |
Read the table for the pattern, not the winner. With a 17-byte topic, MQTT-SN and CoAP are within a few bytes of each other once either side shortens its names, and MQTT 5 with a topic alias is within one byte of them. MQTT 3.1.1 is the only one that scales linearly with topic length on every packet. The savings from MQTT-SN’s registration or CoAP’s short names are real, but they are small next to what the transport underneath adds.
What the transport adds
The same payload now has to cross a link. On UDP you add an 8-byte header. On TCP you add at least 20 bytes. IPv6 adds 40 bytes uncompressed; on a 6LoWPAN link, RFC 6282 header compression can shrink the IPv6 header to a couple of bytes for link-local communication and UDP to roughly four bytes in the best case, though real traffic that crosses a border router compresses worse. TCP has no equivalent compression profile in that RFC.
Consider the IEEE 802.15.4 frame budget. RFC 4944 gives the arithmetic: a 127-byte maximum frame, up to 25 bytes of MAC overhead, leaving 102 bytes; with AES-CCM-128 link-layer security that overhead grows and about 81 bytes remain for everything above. Inside 81 bytes you must fit IP, transport, security and application. A TCP header of 20 bytes plus a 25-byte MQTT packet is 45 bytes before IP. A UDP header of roughly 4 bytes compressed plus an 11-byte MQTT-SN packet is 15 bytes. The first fits, barely, with a tiny payload. The second leaves room for a real reading, and, more importantly, avoids fragmentation, which on a lossy mesh multiplies the loss rate because every fragment must survive.

Figure 2: Frame budget on an IEEE 802.15.4 link. Each layer consumes bytes from a 127-byte frame before the application sees any of them.
The diagram reads top to bottom as a subtraction. Whatever remains after the MAC header, optional link security, the compressed IP header and the transport header is the application budget. MQTT over TCP spends the largest share in the transport box; MQTT-SN and CoAP over UDP spend the least. The budget also tells you where DTLS lands: link-layer security and DTLS both eat bytes, and stacking them is rarely necessary.
Verifying the numbers yourself
The script below builds each packet and prints its length, so you can swap in your own topic and payload. It uses no third-party libraries.
import struct
topic = b"plant1/line2/temp"
payload = b"23.4"
def mqtt311_publish(topic, payload, qos=0, pid=1):
var = struct.pack(">H", len(topic)) + topic
if qos:
var += struct.pack(">H", pid)
body = var + payload
return bytes([0x30 | (qos << 1), len(body)]) + body # len < 128
def mqtt5_publish(topic, payload, alias=None):
props = b"" if alias is None else b"\x23" + struct.pack(">H", alias)
t = topic if alias is None else b"" # alias already registered
var = struct.pack(">H", len(t)) + t + bytes([len(props)]) + props
body = var + payload
return bytes([0x30, len(body)]) + body
def mqttsn_publish(topic_id, payload, qos=0, msg_id=0, ttype=0b00):
flags = ((qos & 0b11) << 5) | ttype # MQTT-SN 1.2 flag layout
length = 7 + len(payload) # must be <= 255 for 1-byte form
return struct.pack(">BBBHH", length, 0x0C, flags, topic_id, msg_id) + payload
def coap_put_nonconfirmable(path_segments, payload, mid=0x1234):
hdr = bytes([0x50, 0x03]) + struct.pack(">H", mid) # ver1 NON tkl0, PUT
opts, prev = b"", 0
for seg in path_segments:
delta = 11 - prev # Uri-Path = option 11
assert delta < 13 and len(seg) < 13
opts += bytes([(delta << 4) | len(seg)]) + seg
prev = 11
return hdr + opts + b"\xff" + payload
print("MQTT 3.1.1 QoS0 :", len(mqtt311_publish(topic, payload)))
print("MQTT 3.1.1 QoS1 :", len(mqtt311_publish(topic, payload, qos=1)))
print("MQTT 5 no alias :", len(mqtt5_publish(topic, payload)))
print("MQTT 5 alias :", len(mqtt5_publish(topic, payload, alias=1)))
print("MQTT-SN :", len(mqttsn_publish(0x0001, payload)))
print("CoAP long path :", len(coap_put_nonconfirmable([b"plant1", b"line2", b"temp"], payload)))
print("CoAP short path :", len(coap_put_nonconfirmable([b"t"], payload)))
Expected output is 25, 27, 26, 12, 11, 27 and 11 in that order. If you raise the topic length past 127 bytes the MQTT remaining-length field grows to two bytes, which the helper deliberately does not handle, and the CoAP helper only supports option lengths below 13 for the same reason of brevity.
Session lifecycle and sleeping clients
Bytes per publish are the visible cost. The hidden one is how often a node must talk to keep the relationship alive. This is where sleeping clients separate the protocols.
An MQTT-SN client moves through four states from the gateway’s point of view: active, asleep, awake and lost. In the active state it behaves like an ordinary MQTT client. To sleep, it sends DISCONNECT with an optional two-byte duration field; the gateway then marks it asleep and buffers any messages arriving for it from the broker. When the client wakes, it sends a PINGREQ that includes its client ID, the gateway flushes the buffered messages, and then answers with PINGRESP, which tells the client that the buffer is empty and it may sleep again. If the duration passes without a wake-up, the gateway treats the client as lost. The client can also send a normal CONNECT to resume.
The CONNECT packet adds a detail from the draft I read: a “Default Awake Messages” field of 0 to 15 that caps how many buffered messages are delivered per wake, with 0 leaving it to the gateway. I did not verify whether that field exists in 1.2 itself, so check your gateway’s documentation rather than assuming. The same draft states a gateway treats a session as lost if it hears nothing within 1.5 times the keep-alive interval, a rule that mirrors MQTT’s own.

Figure 3: MQTT-SN lifecycle with a sleeping client. Registration happens once; the gateway buffers downlink traffic during sleep and releases it on the PINGREQ that ends the sleep.
Compare that with MQTT over TCP. A sleeping MQTT client has two options. It can keep the TCP connection open, which means keep-alive traffic and a radio that cannot enter its deepest sleep state for longer than the keep-alive interval. Or it can close the connection, sleep, and reconnect on wake: a TCP handshake, an MQTT CONNECT of about 22 to 23 bytes with a short client ID (2 fixed, 6 protocol name, 1 level, 1 flags, 2 keep-alive, 2 plus the ID length), a CONNACK, and then the publish and a teardown. With a persistent session on the broker, subscriptions survive, but the reconnect cost recurs on every wake. For an uplink-only sensor that is wasteful; for a node that must receive commands it is often the least bad option.
CoAP offers a different answer: don’t hold a session, and don’t ask the network to buffer. A sleepy CoAP node wakes, sends a NON or CON request, and sleeps. If it needs to receive commands it polls a mailbox resource, or it registers with a resource directory and an LwM2M server uses queue mode, in which the device announces when it is awake. Our LwM2M device management architecture guide explains how queue mode lets a server hold requests until a sleepy device checks in. CoAP Observe, by contrast, assumes the server can reach the client at any time, which is the opposite of a sleeper, so use it only for nodes that are always listening, such as mains-powered routers.
Reliability: three retransmission models
All three protocols can acknowledge. They do it at different layers, and that changes failure behaviour.
MQTT relies on TCP for transport reliability and adds its own QoS 1 and 2 handshakes above it. On a lossy radio link TCP’s retransmission timer, tuned for stable round-trip estimates, can back off to seconds, and both MQTT and TCP may retransmit the same data. MQTT-SN has its own acknowledgements for QoS 1 and 2, plus a retry timer and retry counter for control messages such as CONNECT and REGISTER; the spec defines named timers (for instance T_RETRY and N_RETRY, and T_ADV, T_SEARCHGW, T_GWINFO and T_WAIT for gateway discovery), but I could not retrieve the appendix values, so check the specification text for recommended defaults rather than copying numbers from forum posts.
CoAP’s reliability is specified precisely in RFC 7252. A confirmable message is retransmitted with exponential backoff: the initial timeout is a random value between ACK_TIMEOUT (2 seconds) and ACK_TIMEOUT times ACK_RANDOM_FACTOR (1.5), so 2 to 3 seconds, doubled each time, up to MAX_RETRANSMIT (4) retries. That gives a worst-case MAX_TRANSMIT_SPAN of 45 seconds and an EXCHANGE_LIFETIME of 247 seconds, the period during which message IDs must not be reused. NSTART limits a client to one outstanding interaction per peer by default, which protects a shared radio channel from bursts. Non-confirmable messages skip all of it.
For energy, the retransmission schedule matters more than header bytes. A CoAP CON that is lost four times keeps the radio on or periodically waking for tens of seconds. A designer who needs bounded wake time sets the application to give up earlier than the protocol would, or sends NON and handles loss at the data level, for example by sending a monotonically increasing counter so the server can detect gaps.
An energy model you can adapt
Energy arguments on the web are often a mush of percentages. Here is a transparent model with illustrative figures. The only hard number is the radio rate: IEEE 802.15.4 at 2.4 GHz runs at 250 kbit/s, so one byte takes 32 microseconds on air. The synchronization header adds 6 bytes (preamble, start-of-frame delimiter, length).
Take an 11-byte MQTT-SN PUBLISH over a bare MAC with short addressing, roughly 11 bytes of MAC header and checksum. The frame is about 6 + 11 + 11 = 28 bytes, around 0.9 ms. The MQTT 3.1.1 equivalent over TCP/IPv6/6LoWPAN is more like 6 + 11 + 3 (compressed IP) + 20 (TCP) + 25 = 65 bytes, about 2.1 ms, plus a TCP acknowledgement coming back, during which the receiver must be listening. Suppose, as an assumption and not a measurement, that transmit draws 10 mA and a receive window draws 8 mA, and the radio needs a 20 ms listening window after each uplink to catch a downlink or acknowledgement. Then one uplink costs 10 mA times 2.1 ms is 21 microcoulombs, and the listening window costs 8 mA times 20 ms, which is 160 microcoulombs. The listening window is nearly eight times the transmission.
At one publish every five minutes, there are 288 per day. The window costs 288 times 160 microcoulombs, about 0.046 coulombs per day or 12.8 microamp-hours per day, while the transmissions themselves cost under 2 microamp-hours. Against a deep-sleep floor of, say, 2 microamps (24 hours times 2 microamps is 48 microamp-hours per day), the listening window becomes the second largest term. A CR2032 cell has a nominal capacity in the region of 225 mAh, so these terms are small individually, which is the point: the protocol decisions that matter are the ones that change how many windows exist and how long they stay open, not the ones that shave six bytes.
The conclusion generalizes. Any design that eliminates a mandatory acknowledgement window (QoS -1 in MQTT-SN, NON in CoAP) or batches several readings per wake saves more energy than any header optimization. Any design that forces periodic keep-alives (MQTT over TCP, or a NAT binding that must be refreshed) adds a floor you cannot optimize away. Keep these figures as a template: replace the 10 mA, 8 mA and 20 ms with your radio datasheet’s numbers and your measured window.
Security overhead: DTLS 1.3, OSCORE and the cost of a handshake
Security is where the three protocols diverge most, and where many constrained deployments quietly give up. The honest summary is that the bytes per message are modest; the handshake and the state are what hurt.
The best public comparison I found is the IETF Internet-Draft on CoAP security protocols (draft-ietf-iotops-security-protocol-comparison, version 03 of October 2023, now expired, so treat it as engineering estimates rather than a standard). Using an 8-byte authentication tag, a 6-byte plaintext and one-byte connection identifier, it estimates the per-message overhead as 11 bytes for DTLS 1.3 (12 with a connection ID), 14 bytes for TLS 1.3, 13 bytes for an OSCORE request (14 with a particular sender ID) and 11 bytes for an OSCORE response. On a message whose application packet is 11 to 15 bytes, that roughly doubles the frame, but the absolute number is small next to the fixed 6-byte physical header and the MAC header.
The handshake is another story. For DTLS 1.3 with raw public keys and ephemeral Diffie-Hellman, the same draft estimates 185, 454 and 255 bytes in the three flights, 894 bytes in total, against 615 bytes for TLS 1.3 under comparable assumptions. With pre-shared keys and no ephemeral exchange the DTLS 1.3 flights fall to 136, 153 and 56 bytes. The draft’s figure for EDHOC (Ephemeral Diffie-Hellman Over COSE) with raw public key and key-identifier authentication is 37, 45 and 19 bytes for its three messages, 101 bytes in total. EDHOC is the key exchange designed to produce an OSCORE security context, and the draft’s authors highlight how much lighter it is than a full DTLS handshake. All of these figures exclude fragmentation and lower-layer headers, and they depend on key type and certificate choices.
What do these numbers mean in practice? A DTLS handshake of roughly 900 bytes does not fit in one 802.15.4 frame; over a link with about 81 usable bytes per frame it needs a dozen frames, any of which can be lost, and DTLS retransmission timers then stretch the radio-on time. A node that wakes once an hour and has to re-handshake each time pays this cost every wake. DTLS 1.3 mitigates that with a connection ID (RFC 9146) and session resumption, so a sleepy node keeps a security context across sleep and across NAT rebinding. The state to store is small, but it must survive power cycles, which means flash writes and wear management.
OSCORE (RFC 8613) takes a different route. It protects CoAP at the application layer, encrypting the CoAP message payload and selected options with an AEAD (authenticated encryption with associated data) algorithm and carrying a small OSCORE option with a sequence number and sender ID. Because it is bound to CoAP messages rather than to a UDP flow, it survives proxies and caches, can be used over any transport including non-IP ones, and needs no handshake if you pre-provision the context. Its weakness is exactly that reliance on a persistent sequence number: if a node loses its counter on a power cycle, replay protection breaks. The standard answer is to persist the counter in non-volatile memory with a safety margin.
MQTT’s standard answer is TLS over TCP or username/password in the clear, with client certificates or tokens carried in the MQTT 5 enhanced authentication exchange. MQTT-SN has no specified security layer in 1.2; the draft I read lists a PROTECTION message type (0xFF) and an AUTH message type (0x03), but real deployments protect the UDP leg with DTLS or with link-layer encryption on the radio network, and then rely on the gateway to be trusted. That trust boundary is the largest architectural caveat: the gateway sees everything in plaintext and holds the broker credentials. Think about whether the gateway belongs in the same physical enclosure as the field devices, and how its keys are rotated.
Block-wise transfers and large payloads
Sensor readings fit in a frame; firmware images do not. CoAP handles this in the protocol: block-wise transfer, specified in RFC 7959, splits a representation into blocks whose size is a power of two between 16 and 1024 bytes, negotiated through the Block1 and Block2 options. Each block is its own request/response, so a 50 KiB image at 64-byte blocks needs about 800 round trips, each of which can be retried independently. That is slow but robust, and it needs no extra state beyond a block counter.
MQTT-SN offers nothing comparable. There is no fragmentation at the protocol level and a packet cannot exceed what the link carries. Firmware update over MQTT-SN means your application layer chunks the image, numbers the chunks and requests retransmission, or you move the transfer to CoAP, LwM2M or a different channel entirely. MQTT over TCP can stream large payloads, but TCP segmentation and MQTT’s one-packet-per-message model means a large message ties up the connection, and many brokers cap packet size.
Which Transport Fits Which Protocol
The link layer decides more than the application protocol does. Below is how each common constrained transport interacts with the three options.
IEEE 802.15.4 with Thread or 6LoWPAN
Thread and other 6LoWPAN networks carry IPv6 natively, so UDP and CoAP are first-class and the 6LoWPAN compression is built for this exact case. CoAP is the natural fit and the one Thread’s commissioning and diagnostic tooling assumes. MQTT-SN also runs well, with a gateway or border router translating. MQTT over TCP works but pays for TCP headers that RFC 6282 does not compress, and TCP state interacts badly with sleepy end devices that a parent router polls.
Zigbee and other non-IP mesh networks
Classic Zigbee does not carry IP. The MQTT-SN specification’s own framing, with its attention to small packets and a forwarder encapsulation message, targets this kind of network: a forwarder on the border device wraps raw radio frames into UDP for a remote gateway. If you control both ends and have a non-IP link, MQTT-SN with forwarder encapsulation is the closest match among the three; CoAP needs an IP layer (or an adaptation such as a custom shim) that the radio stack may not provide.
NB-IoT and LTE-M
Cellular low-power networks carry IP, and the module owns the sleep modes: power saving mode (PSM) and extended discontinuous reception (eDRX), configured through timers negotiated with the network. The binding between module and network does not map neatly onto a TCP keep-alive. A long PSM sleep generally outlasts a carrier NAT’s idle timeout (deployment-specific and typically minutes to hours; check your operator), so a TCP connection or DTLS session bound to an IP and port will be gone when the device wakes unless the connection ID or a resumption ticket rescues it. UDP-based CoAP or MQTT-SN with DTLS 1.3 connection ID, or simple QoS 0 or NON messages, fit this reality better than a persistent MQTT session. Some NB-IoT devices still use MQTT over TCP successfully by reconnecting on each wake and relying on broker-side session persistence; it costs more airtime but keeps the cloud integration simple.
LoRaWAN
LoRaWAN is not an IP network, and its application payloads are small: tens of bytes at the slowest spreading factors up to a couple of hundred bytes at the fastest, depending on region and data rate (see the regional parameters document for your band). MQTT-SN does not run on it natively, because the LoRaWAN network server delivers raw payloads to an application. CoAP can be carried over LoRaWAN with static context header compression (SCHC, RFC 8724), which compresses headers down to a few bytes by sharing a rule set between device and gateway. In most LoRaWAN deployments, though, the pragmatic design is a binary payload decoded by the application server and then published into MQTT by the network server integration. We compare the long-range radios themselves in our Wi-Fi HaLow, LoRaWAN and LTE-M selection guide.
Walk-through: Talking to a Gateway and Observing a Resource
Two short clients make the mechanics concrete. The first speaks MQTT-SN 1.2 to a UDP gateway using only the Python standard library. It follows the exact exchange in Figure 3: CONNECT, REGISTER, PUBLISH, DISCONNECT with a sleep duration. Replace the address with your gateway; the Paho transparent gateway is a common local target.
import socket, struct
GW = ("192.0.2.10", 1884) # your MQTT-SN gateway (UDP)
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(5)
def tx(msg_type, body=b""):
s.sendto(bytes([2 + len(body), msg_type]) + body, GW)
def rx():
d, _ = s.recvfrom(256)
return d[1], d[2:] # (type, body)
# CONNECT: flags(CleanSession=0x04), protocolId=1, keepalive=60, clientId
tx(0x04, bytes([0x04, 0x01]) + struct.pack(">H", 60) + b"node-0042")
assert rx()[0] == 0x05, "no CONNACK"
# REGISTER topic -> gateway returns a 16-bit topic id
tx(0x0A, struct.pack(">HH", 0, 1) + b"plant1/line2/temp")
t, b = rx() # REGACK: topicId(2) msgId(2) rc(1)
topic_id, msg_id, rc = struct.unpack(">HHB", b)
assert t == 0x0B and rc == 0, "register rejected"
# PUBLISH QoS 0 with the registered id: 7 header bytes + payload
tx(0x0C, bytes([0x00]) + struct.pack(">HH", topic_id, 0) + b"23.4")
# Sleep for 300 s: DISCONNECT with a 2-byte duration
tx(0x18, struct.pack(">H", 300))
print("asleep, topic id", topic_id)
Notice what is missing: no TCP handshake, no TLS, no subscription. Notice also what you now own. If the gateway restarts, your topic ID is stale, and the next publish to it will come back with a REGACK-style error or be silently dropped, depending on the gateway. A robust client re-registers on any error and on every cold boot, or it uses predefined IDs shared through configuration.
The second client observes a CoAP resource with aiocoap, a widely used Python implementation. A server that supports Observe will push each change.
import asyncio
from aiocoap import Context, Message, GET
async def main():
ctx = await Context.create_client_context()
req = Message(code=GET, uri="coap://[2001:db8::1]/temp", observe=0)
request = ctx.request(req)
first = await request.response # initial 2.05 Content
print("initial:", first.payload)
async for notif in request.observation: # RFC 7641 notifications
print("update:", notif.payload)
asyncio.run(main())
On the wire, the first GET carries the Observe option with value 0 and a token. Every subsequent notification reuses that token. If the client forgets the registration, for example after a reboot, it simply stops answering confirmable notifications; the server’s reset-handling logic then removes it. For a battery node the lesson is to keep Observe on the server side of the relationship: let a mains-powered aggregator observe the sleepy node’s resources through a resource directory or an LwM2M server, and expose the aggregated feed to the cloud over MQTT.

Figure 4: CoAP Observe in operation. Registration, change-driven notifications, the Max-Age freshness window and deregistration through a reset.
A detail in that figure deserves a mechanism-level explanation. Each notification carries a Max-Age option (default 60 seconds in CoAP when absent), and an observing client must treat its cached value as stale after that window and re-register. A server that sends notifications rarely should set Max-Age to cover its worst-case silence, or clients will keep re-registering. Notifications also carry the 24-bit Observe sequence number; the RFC’s comparison rule lets a client discard reordered notifications that arrive late over UDP, which matters on a mesh where packets can overtake one another.
Trade-offs, Gotchas, and What Goes Wrong
MQTT-SN has no standards body behind it. Version 1.2 is stable and widely implemented, but nobody governs it, the newer draft differs on the wire, and implementations disagree on optional features. Zephyr omits discovery, predefined topic IDs and QoS -1, so two compliant-looking stacks may not interoperate on the features you wanted. Pin a gateway and a client library pair, and write an interoperability test that exercises registration, sleep and wake against the exact versions you ship.
The gateway is a single point of trust and of failure. It owns the topic ID table, the sleep buffers and, for transparent mode, one broker connection per sensor. If it crashes, every sensor’s topic mapping is lost. Buffered messages for sleeping nodes live in the gateway’s RAM, so a restart during a long sleep cycle drops them. Run gateways in pairs only if you have a story for state, which the spec does not provide; most deployments accept that a lost gateway means re-registration across the fleet and plan for the storm of REGISTER traffic when it returns.
Topic ID drift. The 16-bit topic ID is negotiated per session. A client that caches IDs across reboots without confirming them can publish data under the wrong topic if the gateway assigns IDs in a different order. Always treat the REGACK as the source of truth, or use predefined IDs from a signed configuration file.
UDP and NAT. UDP bindings in carrier-grade NAT can expire in tens of seconds to a few minutes, with wide variation by operator. A sleeping node that wakes after the binding has expired will send into a void. Either use DTLS 1.3 with a connection ID, so the server recognizes the session despite a new source port, or accept that the first packet after a long sleep may need to be retried through a fresh binding.
CoAP congestion and confirmable storms. RFC 7252 defaults NSTART to 1 and applies exponential backoff, but a fleet that all reboots after a power outage produces synchronized confirmable requests. Add random start jitter, which costs nothing and prevents the thundering herd. Observe adds a second hazard: a server with thousands of observers re-sending confirmable notifications to dead clients wastes air time until the reset or timeout path cleans them up.
Security that the gateway can undo. If you encrypt hop by hop (DTLS on the radio side, TLS on the broker side), the gateway terminates both and sees plaintext. For sensitive data this is a design decision, not an accident. If end-to-end confidentiality matters, application-layer payload encryption or OSCORE through a CoAP proxy preserves it, at the price of a persistent sequence-number state on the node.
Unverified or deployment-specific points in this article. I could not retrieve the 1.2 appendix of recommended timer values, so I give no numbers for them. Energy figures are explicit assumptions. The security overhead numbers are estimates from an expired Internet-Draft. The NAT timeout and LoRaWAN payload ranges depend on your operator and region.
Practical Recommendations
Start from the link layer and the wake schedule, then pick the protocol. The matrix below summarizes how the options score for common situations. It is a judgement from the mechanics above, not a benchmark.
| Situation | MQTT 5 over TCP | MQTT-SN over UDP via gateway | CoAP over UDP |
|---|---|---|---|
| Wi-Fi or LTE-M node with mains or large battery, cloud first | Best fit, least integration work | Unnecessary gateway | Fine if you need REST semantics |
| 802.15.4 or Thread sleepy sensor, uplink only | Poor: TCP state and headers | Good, QoS -1 or QoS 0 with predefined IDs | Best fit with NON and short paths |
| Non-IP mesh (Zigbee-class) with a border device | Not possible without IP | Closest match, with forwarder encapsulation | Needs an IP adaptation layer |
| NB-IoT with PSM and long sleeps | Workable if you reconnect each wake | Good with DTLS 1.3 connection ID | Good with DTLS or OSCORE |
| Device must receive commands while asleep | Persistent session, reconnect each wake | Gateway buffers until wake | LwM2M queue mode or mailbox polling |
| Firmware or bulk transfer | Possible, large packets | Not supported, chunk it yourself | Block-wise transfer, RFC 7959 |
| LoRaWAN backhaul | Via network server integration | Not native | Possible with SCHC compression |
| End-to-end security across a proxy | TLS ends at the broker | Gateway sees plaintext | OSCORE preserves it |
My own opinion, labelled as such: for greenfield battery sensors on IP-capable low-power radios in 2026, CoAP with non-confirmable uplinks plus an LwM2M or resource-directory layer for management is the better-founded choice than MQTT-SN, simply because it is standardized by the IETF, has a security story that spans OSCORE and EDHOC, and does not depend on a single non-standard gateway. MQTT-SN earns its place when you inherit a fleet or a radio that has no IP, when you want publish/subscribe semantics at the edge, or when your cloud side is built entirely around MQTT and you want one topic namespace from sensor to dashboard. Our guide to OPC UA versus MQTT with Sparkplug B covers what to do on the cloud side of that boundary once the data is in a broker.
A short checklist
- Measure the real usable frame size on your radio with security on, before you choose anything. Compute the application budget the way Figure 2 does.
- Decide whether the node ever needs to receive. If not, prefer QoS -1 in MQTT-SN or NON in CoAP and skip sessions entirely.
- Put a number on wake events per day. Remove acknowledgement windows and keep-alives before you trim headers.
- Choose short resource names or predefined topic IDs so the name cost per packet is two bytes, not seventeen.
- Pick the security layer by the sleep model: DTLS 1.3 with connection ID or resumption for sessions, OSCORE for proxy-safe end-to-end, and persist counters across power loss.
- Pin gateway and client library versions, then test REGISTER, sleep, wake and gateway restart as part of CI.
- Add random jitter to every boot-time and scheduled transmission to avoid fleet-wide synchronization.
- Log what you could not verify: for each protocol feature you depend on, note the document and version it came from.
Frequently Asked Questions
Is MQTT-SN an official standard?
No. The OASIS MQTT-SN subcommittee page says MQTT-SN, though publicly available, is not recognized or approved by a standards body such as OASIS, and that it accepts Version 1.2 as an input specification for refinement and integration with MQTT. Version 1.2 is nevertheless the de facto reference that open-source stacks implement. MQTT 3.1.1 and MQTT 5.0, by contrast, are OASIS standards, and CoAP is an IETF standard defined in RFC 7252.
Can an MQTT-SN device talk directly to an MQTT broker?
Not without a gateway or a broker that embeds an MQTT-SN listener. MQTT-SN uses a different packet format, runs over datagrams, and uses topic IDs the broker does not understand. A gateway translates between the two, either one MQTT connection per sensor in transparent mode or a shared connection in aggregating mode. The Eclipse Paho transparent gateway is an open-source example that bridges MQTT-SN over UDP to brokers such as Mosquitto.
Is CoAP Observe the same as MQTT publish/subscribe?
No, though it serves a similar purpose. Observe lets a client register interest in one resource and receive notifications when its state changes, with the server holding the registration, identified by a token. There is no broker, no topic hierarchy and no fan-out through an intermediary unless you add a proxy. Each observed resource is a separate registration, and sleepy clients are poorly served because the server must be able to reach them.
Which protocol uses the least battery?
It depends on the wake pattern, not the protocol name. In the worked model above, the listening window after each uplink consumed several times the energy of the transmission itself. Protocols that avoid acknowledgement windows (MQTT-SN QoS -1, CoAP NON) and avoid keep-alives have the advantage. MQTT over TCP carries a connection and keep-alive floor. Measure on your own hardware, because radio, firmware and network settings change the ranking.
How much does DTLS 1.3 add on a constrained link?
An IETF Internet-Draft comparing CoAP security protocols estimates 11 bytes per record for DTLS 1.3 with an 8-byte tag, and about 894 bytes of handshake for raw public keys with ephemeral Diffie-Hellman. Pre-shared-key handshakes are smaller. The draft is expired and the numbers are estimates excluding fragmentation, so verify against your own library. The connection ID extension (RFC 9146) lets a sleeping device keep its session across address changes.
Can I run these protocols over LoRaWAN or Zigbee?
LoRaWAN is not IP, so MQTT-SN and CoAP do not run natively. CoAP can be carried with SCHC header compression, but most deployments decode binary payloads at the network server and publish to MQTT. Zigbee is also non-IP; MQTT-SN’s forwarder encapsulation was designed for relaying frames from such networks to a gateway, though your radio stack must provide a datagram service. Thread and other 6LoWPAN networks carry IPv6 and suit CoAP and UDP-based MQTT-SN directly.
Further Reading
- Sparkplug B vs plain MQTT topics for topic design and state management on the broker side.
- CoAP constrained application protocol deep dive for confirmable messaging and the CoAP option model.
- LwM2M IoT device management architecture for queue mode and sleepy device management.
- OPC UA vs MQTT Sparkplug B for the cloud and plant integration side.
- Wi-Fi HaLow vs LoRaWAN vs LTE-M selection guide for the radio layer.
- External: RFC 7252, The Constrained Application Protocol, RFC 7641, Observing Resources in CoAP, OASIS MQTT-SN subcommittee, and the Zephyr MQTT-SN client documentation.
By Riju — about
