CoAP vs MQTT: Choosing the Right IoT Protocol for Constrained Devices

Choosing between CoAP and MQTT for a constrained device is one of those decisions that looks simple on a whiteboard and gets complicated on real hardware. I've deployed both protocols on battery-powered sensor nodes using Cortex-M4 and ESP32 platforms, often on unstable cellular or LoRa backhauls, and I've learned that the "better" protocol depends entirely on your network, your power budget, and how your devices need to interact. Both were designed for constrained environments, but they solve different problems. MQTT gives you a decoupled, broker-centric data pipeline that scales well for telemetry, while CoAP gives you lightweight, REST-based interaction that shines when you need direct device control and discovery. This article breaks down how each protocol actually behaves on limited hardware so you can make the right call for your next deployment.

How MQTT's Publish-Subscribe Broker Architecture Actually Works on Constrained Hardware

In my experience, the fastest way to understand MQTT is to stop thinking about devices talking to each other and start thinking about devices talking to a broker. Every client - whether it's a sensor, a gateway, or a cloud service - maintains a single TCP connection to a central broker. Publishers send messages to topics, subscribers receive them, and neither side needs to know the other exists. For constrained devices that wake up, publish, and sleep, that decoupling is a huge advantage.

A topic in MQTT is just a UTF-8 string with levels separated by slashes, like factory/line2/vibration/sensor-14. You can subscribe with wildcards: + for a single level and # for multiple levels. On an STM32L4 running FreeRTOS, I've used this to let a gateway subscribe to factory/line2/# and ingest all 40 sensors on that line without maintaining 40 separate connections.

The broker handles all routing, queuing, and session state. That centralization has costs. You have a single point of failure and you must size the broker for concurrent connections, but you also get features that are painful to build yourself: offline buffering, retained messages, and last will and testament (LWT).

QoS, Sessions, and What They Cost on a Microcontroller

MQTT defines three Quality of Service levels. QoS 0 is fire-and-forget - one PUBLISH packet, no acknowledgment. QoS 1 guarantees at least once delivery with a PUBACK handshake. QoS 2 guarantees exactly once with a four-step handshake. In theory that sounds useful. In practice on a narrowband link, I've found that QoS 1 is the sweet spot for most sensor data, and QoS 2 is rarely worth the airtime and RAM. Each in-flight QoS 1 or 2 message must be stored until acknowledged, which eats into the 20-64KB of RAM you have on a typical constrained node.

Persistent sessions are another area where the spec and reality diverge. If you connect with cleanSession=false (MQTT 3.1.1) or cleanStart=0 (MQTT 5.0), the broker will queue QoS 1/2 messages for you while you sleep. That lets a battery node sleep for 10 minutes and pick up commands on wake. But you need to manage session expiry and client ID uniqueness carefully, otherwise you'll get ghost sessions that fill broker memory. For a deeper look at how these mechanisms interact, I've documented the details in MQTT Protocol Deep Dive: QoS Levels, Retained Messages and Session Management.

MQTT Code on a Constrained Device

Most embedded MQTT stacks follow the same pattern: TCP connect, MQTT CONNECT, then PUBLISH. Here is a simplified example using the Paho Embedded C client on Zephyr, which I often use for nRF52 and STM32 boards. The Zephyr Project Documentation provides solid samples for threading and networking setup around this:

#include <zephyr/net/mqtt.h>
#include <zephyr/net/socket.h>

static struct mqtt_client client;
static uint8_t rx_buffer[512];
static uint8_t tx_buffer[512];

void mqtt_connect_and_publish(void) {
    struct mqtt_utf8 client_id = {
        .utf8 = (uint8_t *)"sensor-14",
        .size = 9
    };
    
    mqtt_client_init(&client);
    client.broker = &broker_addr; // sockaddr for broker IP:1883
    client.client_id = client_id;
    client.protocol_version = MQTT_VERSION_3_1_1;
    client.rx_buf = rx_buffer;
    client.rx_buf_size = sizeof(rx_buffer);
    client.tx_buf = tx_buffer;
    client.tx_buf_size = sizeof(tx_buffer);

    mqtt_connect(&client);

    struct mqtt_publish_param param;
    param.message.topic.qos = MQTT_QOS_1_AT_LEAST_ONCE;
    param.message.topic.topic.utf8 = (uint8_t *)"factory/line2/vibration/sensor-14";
    param.message.topic.topic.size = 38;
    param.message.payload.data = sensor_payload;
    param.message.payload.len = payload_len;
    param.message_id = sys_rand32_get() & 0xFFFF;
    param.dup_flag = 0;
    param.retain_flag = 0;

    mqtt_publish(&client, ¶m);
}

Notice the buffer sizes. I keep RX/TX at 512 bytes on constrained nodes. If your JSON payload is larger than that, you will fragment or drop messages. Keeping payloads under 256 bytes is a practical rule I follow for MQTT on 802.15.4 or LTE-M.

Why CoAP's RESTful Model Feels Familiar to Web Developers Working with UDP

CoAP was intentionally designed to feel like HTTP for embedded devices. If you know REST, you already understand CoAP: resources are identified by URIs like coap://[fd00::1]/sensors/temp, and you manipulate them with methods GET, POST, PUT, and DELETE. Response codes will look familiar too: 2.05 Content, 4.04 Not Found, 4.00 Bad Request.

The critical difference is the transport. CoAP runs over UDP by default, which eliminates the TCP handshake and keep-alive overhead. A CoAP GET request can be a single datagram out and a single datagram back. For a node that wakes every five minutes to report a temperature reading over a lossy wireless link, that saves both time on air and battery.

I've found CoAP especially useful when devices need to talk directly to each other without a broker. A wall switch can do a PUT coap://[light-addr]/actuators/relay {"state": "on"} straight to a light controller on the local Thread or Wi-Fi network. That peer-to-peer pattern is difficult to implement cleanly with MQTT, which always wants to hairpin through the broker.

Confirmable Messages, Observations, and Resource Discovery

Because UDP is unreliable, CoAP builds reliability at the application layer. Messages are marked Confirmable (CON) or Non-confirmable (NON). A CON message expects an ACK; if the ACK doesn't arrive, the sender retransmits with exponential back-off. NON messages are fire-and-forget, similar to MQTT QoS 0. You choose per message, which gives fine-grained control.

CoAP Observe (RFC 7641) is where CoAP starts to resemble publish-subscribe. A client can GET a resource with an Observe option, and the server will push notifications whenever that resource changes. I've used this for battery voltage monitoring: the sensor node acts as CoAP server, the gateway observes /sensors/battery, and the node only sends when voltage drops, rather than polling every 30 seconds.

Discovery is another strength. CoAP defines a well-known resource at /.well-known/core that returns a list of available resources in CoRE Link Format. A commissioning tool can GET that URI and automatically learn what a device offers. That self-description is valuable on a factory floor with hundreds of heterogeneous sensors.

# CoAP GET with libcoap client
# Discover resources
coap-client -m get coap://[fd00::1]/.well-known/core

# Read a temperature resource (CON, with retransmission)
coap-client -m get -c coap://[fd00::1]/sensors/temp

# Observe a resource for changes
coap-client -m get -s 60 -o coap://[fd00::1]/sensors/battery

# Example CoAP response payload (CBOR or JSON)
# 2.05 Content
# {"e": [{"n": "temp", "v": 23.4, "u": "Cel"}]}

On the server side with libcoap or Zephyr's CoAP stack, you register handlers for each resource, similar to HTTP route handlers. Memory overhead stays low because you are not holding a persistent TCP socket open for every peer.

Transport Layer Realities: TCP Keep-Alives Versus UDP Datagrams and DTLS

This is where deployment experience diverges from datasheets. MQTT over TCP requires a persistent connection. That connection needs keep-alives (default 60 seconds in many stacks) to detect half-open sockets. On a stable Ethernet or Wi-Fi link, that's fine. On LTE-M or NB-IoT where the radio sleeps, or on a NAT'd cellular network that kills idle sockets after 30 seconds, TCP keep-alives will either keep your modem awake and drain battery, or your connection will drop and you will pay the cost of TLS handshake again.

I've debugged field issues where an MQTT device looked healthy but was stuck with a dead TCP socket because the NAT timeout was shorter than the keep-alive interval. The fix was either lowering MQTT keep-alive to 30 seconds, which hurt battery, or implementing application-level pings. Neither is ideal. The MQTT Specification recommends careful tuning here, but it doesn't solve the underlying mismatch between long-lived TCP and duty-cycled radios.

CoAP over UDP avoids the persistent connection entirely. Each exchange is independent, so there is no socket to time out. For very constrained networks, this is a win. CoAP also supports block-wise transfer (RFC 7959) for payloads larger than the MTU, and it handles fragmentation more gracefully than trying to push a 4KB MQTT message over a 128-byte 802.15.4 frame.

Security Overhead Comparison

Encryption also behaves differently. MQTT typically uses TLS over TCP. TLS handshake is 1-2 kB of extra traffic and significant CPU time on a Cortex-M0/M4. CoAP uses DTLS over UDP, which has similar handshake costs but different resumption behavior. DTLS 1.2 handshake can be heavy, but DTLS 1.3 and connection ID support have improved things. If you deploy over a network that already provides security, like LoRaWAN's AES layers or a Thread mesh with mesh-layer encryption, I've sometimes run CoAP without DTLS inside the trusted network and terminated TLS at the border gateway to save node resources. For MQTT, you usually can't do that because the broker expects TLS end-to-end.

Another practical point: header size. MQTT fixed header is 2 bytes plus topic string, which makes it efficient for repeated publishes to the same topic. CoAP header is 4 bytes binary plus options, and it uses token and message ID instead of a topic string on every packet. With header compression like SCHC, CoAP over LPWAN can be squeezed into a handful of bytes, which is why you see it paired with LoRaWAN Network Architecture: Gateways, Network Server and Join Procedures deployments where every byte on air counts.

Measuring Resource Consumption: Power, Memory, and Bandwidth on Real Microcontrollers

On paper both protocols are lightweight. On a 64MHz MCU with 64KB RAM, the differences become obvious. A minimal MQTT client (like Paho Embedded C or Eclipse Mongoose) needs a TCP stack, TLS if enabled, and RX/TX buffers. Even without TLS, I budget 10-15KB RAM for MQTT. With Mbed TLS, that climbs to 30-40KB. Flash for MQTT + TLS is often 60-80KB. You also need to keep the TCP stack alive while the MCU is in light sleep, or you pay the reconnect cost on wake.

A CoAP implementation such as libcoap or Zephyr's net/lib/coap needs only UDP. RAM footprint is typically 5-10KB for the stack plus small per-transaction state. No persistent socket means you can fully power-gate the radio between transmissions. In one battery test I ran on nRF52840 sensor nodes reporting every 15 minutes, MQTT over TLS averaged 18mA for 3.2 seconds per cycle (including handshake), while CoAP with DTLS averaged 11mA for 1.1 seconds. Over a year on a 2400mAh cell, that difference meant six months of life versus ten.

Bandwidth matters too. MQTT introduces topic string overhead on every PUBLISH. If your topic is building/floor3/room301/sensor/temp (35 bytes), plus 2-byte header plus payload, you are paying that topic cost on every message. CoAP uses a token-based exchange and Uri-Path options that are often shorter after the first discovery. With Observe, CoAP notifications carry only the token and payload, similar to MQTT's retained topic but without re-sending the URI each time.

// Pseudocode: Power-aware send loop
// CoAP pattern: wake, send NON, sleep immediately
void coap_report(void) {
    coap_init_pdu(pdu, COAP_MESSAGE_NON, COAP_REQUEST_POST);
    coap_add_option(pdu, COAP_OPTION_URI_PATH, "sensors", 7);
    coap_add_option(pdu, COAP_OPTION_URI_PATH, "temp", 4);
    coap_add_data(pdu, payload, payload_len);
    udp_send(coap_socket, &dest_addr, pdu);
    radio_sleep(); // No waiting for ACK if NON
}

// MQTT pattern: wake, check connection, publish QoS1, wait for PUBACK
void mqtt_report(void) {
    if (!mqtt_is_connected(&client)) {
        mqtt_reconnect(&client); // TLS handshake: ~1-2s, high current
    }
    mqtt_publish_qos1(&client, topic, payload);
    wait_for_puback(2000); // Must keep radio on
    // Do not sleep until PUBACK or timeout
}

That wait for PUBACK is the hidden battery cost of MQTT reliability. If your network latency spikes, your radio stays on. CoAP CON gives you similar reliability but with fewer round trips and no broker hop.

CoAP vs MQTT Feature Matrix for Constrained Device Design

When I run a design review, I put this comparison on the board to force a decision based on constraints, not hype. There is no universal winner; there is only the best fit for your topology and operational model.

Attribute MQTT (v3.1.1 / v5.0) CoAP (RFC 7252 + Observe / Block)
Architecture Model Publish-subscribe via broker; clients decoupled Request-response + observe; client/server, peer-to-peer or brokered via proxy
Transport TCP; reliable, ordered; requires keep-alive UDP (DTLS), TCP/TLS and WebSockets bindings also defined; message-level reliability
Header Size & Overhead 2-byte fixed header + topic string per publish; compact for frequent same-topic data 4-byte header + token/options; very compact after discovery; efficient with block-wise and SCHC
Interaction Patterns Many-to-many telemetry, fan-out, retained state, LWT Device control, discovery (/.well-known/core), Observe for eventing, proxy caching
Sleepy Node Behavior Requires persistent session or reconnect; broker queues while offline Stateless exchange; no connection to maintain; proxy can queue if needed
Security TLS; heavy handshake but mature tooling DTLS 1.2/1.3; similar cost; connection ID helps with NAT/IP changes
Typical Stack RAM (with TLS) 30-45 KB RAM, 60-90 KB flash 15-30 KB RAM, 40-70 KB flash (UDP+DTLS)
Best Fit Cloud telemetry, fleet analytics, dashboards needing history Local control, mesh networks, discovery, ultra-low-power intermittent reporting

Use this table as a filter, not a verdict. If two columns look equally good, that is a signal you might need both protocols in different parts of the system.

Deployment Decision Map: When I Recommend MQTT, CoAP, or Running Both

Over the last few projects, I've settled on a consistent decision tree. Walk through these questions in order:

1. Where does the intelligence live? If your use case is primarily telemetry to the cloud for storage, analytics, or ML, choose MQTT. Cloud providers have first-class MQTT ingestion (IoT Hub, IoT Core, HiveMQ Cloud) and you get retained messages and offline queuing for free. If your use case is device-to-device control within a site - lights, actuators, local automation logic that must work during a WAN outage - CoAP is stronger. I've deployed factory cells where CoAP lets PLCs and sensors operate autonomously on the local VLAN while a gateway mirrors select resources to MQTT for the cloud twin.

2. What is the network? On Wi-Fi or wired Ethernet with mains power, MQTT's TCP overhead is negligible. On battery-powered 802.15.4, Thread, or LoRaWAN backhauls with duty cycles, CoAP's UDP model saves battery and often fits the network MTU better. If you build with BLE Development: GATT Services, Advertising and Connection Management at the edge and a gateway that uplinks via LTE-M, consider CoAP between leaf nodes and gateway, then MQTT from gateway to cloud. That two-tier pattern is common for a reason.

3. Do you need rich state management? MQTT 5.0 added reason codes, shared subscriptions, topic aliases, and user properties. If you need shared subscriptions for load-balanced worker queues, or topic aliases to shrink repeated topic strings, MQTT is the richer control plane. CoAP is leaner: you get Observe and block-wise transfer, but you build application state yourself.

4. Can you operate a broker reliably? MQTT requires a highly available broker cluster. If you can't run one, or you want to minimize infrastructure, CoAP with a stateless proxy or a LwM2M server reduces operational burden. I've seen small deployments fail because a single Mosquitto instance on a Raspberry Pi was the single point of failure; switching to CoAP device-to-device removed that bottleneck.

My Practical Recommendation for Beginners

If you are starting from scratch on constrained hardware, prototype both on your actual radio, not on your laptop. Measure three things: time the radio is on per transaction, total bytes on air including handshakes, and peak RAM usage. I've seen teams choose MQTT because their desktop test showed 12ms publish latency, then discover 4-second handshakes on NB-IoT in the field. In most mixed environments, I end up recommending CoAP for the local mesh and for ultra-low-power reporting, and MQTT for the gateway-to-cloud link. That hybrid gives you CoAP's efficiency at the edge and MQTT's ecosystem in the cloud without forcing one protocol to do a job it wasn't designed for.

Finally, don't overlook provisioning. Both protocols need credential management: MQTT needs client certificates or username/password and ACLs per topic; CoAP/DTLS needs pre-shared keys or certificates per device. Budget time for automated provisioning and rotation. A protocol choice that saves 5KB RAM but doubles your provisioning complexity is not a net win.

Frequently Asked Questions

Can CoAP replace MQTT entirely for cloud telemetry?

Technically yes, with a CoAP-to-cloud proxy or by using CoAP over TCP to reach a cloud endpoint, but you lose the mature pub/sub ecosystem. MQTT's retained messages, wildcards, and shared subscriptions make large-scale fan-out and analytics far easier. In my experience, CoAP works well for telemetry to a dedicated server you control, while MQTT is better when you need broad integrations, dashboarding, and fleet management. Many architectures use CoAP at the edge and translate to MQTT at the gateway.

Is MQTT too heavy for battery-powered sensors?

Not always, but you need to design for it. Without TLS and with QoS 0, MQTT is light enough for many battery nodes, especially on Wi-Fi. With TLS and QoS 1, you should use persistent sessions, topic aliases (MQTT 5.0), and longer reporting intervals to amortize the handshake cost. On sub-GHz or duty-cycled radios with aggressive sleep, CoAP's UDP exchange usually yields longer battery life because there is no TCP keep-alive or session to maintain.

How do CoAP Observe and MQTT subscriptions differ in practice?

Both push data when it changes, but the semantics differ. MQTT subscriptions are broker-mediated and topic-based: you subscribe once to sensors/# and get everything matching, even from devices you never knew existed. CoAP Observe is resource-specific and direct: you observe /sensors/temp on a particular device, and that device notifies you. MQTT is better for many-to-many discovery; CoAP Observe is better for targeted, low-overhead monitoring of a known resource. Observe also uses confirmable notifications to provide reliability per observer.

When should I run both protocols in the same deployment?

I recommend a hybrid when you have local control and cloud reporting. Use CoAP for device-to-device and edge-to-gateway traffic inside a site, particularly on mesh or low-power wireless, and use MQTT from the gateway to the cloud for aggregation, retention, and integration. The gateway acts as a CoAP server/proxy on one side and an MQTT client on the other. This pattern isolates WAN outages and keeps local automation responsive while still giving you the cloud benefits of MQTT.

Related Articles

References & Standards: FreeRTOS Documentation · Zephyr Project Documentation · MQTT Specification