Why MQTT for IoT
MQTT (Message Queuing Telemetry Transport) is a lightweight publish/subscribe protocol designed for constrained devices and unreliable networks. SGT Systems uses MQTT as the default southbound transport for gateway-to-cloud telemetry because it consistently outperforms HTTP for the patterns that matter in field IoT: many small messages, bidirectional control, intermittent connectivity, and battery-powered nodes.
MQTT is now standardised at OASIS as version 3.1.1 (effectively the universal baseline) and version 5 (which adds reason codes, user properties, message expiry, shared subscriptions, and several other features). New deployments at SGT default to MQTT 5 where the broker and clients both support it; legacy fleets remain on 3.1.1.
MQTT vs HTTP for IoT
| Concern | MQTT | HTTP/REST |
|---|---|---|
| Connection model | Long-lived TCP | Request/response, short-lived |
| Header overhead | ~2 bytes minimum | Hundreds of bytes |
| Server push | Native (subscribe) | Polling or WebSockets |
| Delivery guarantees | QoS 0 / 1 / 2 | None at the protocol layer |
| Offline handling | Persistent sessions, queued messages | Caller must retry |
| Discovery of state | Retained messages, LWT | Must build out-of-band |
| Cellular cost | Very low | High (handshakes per request) |
For a fleet of 5,000 gateways each emitting a 40-byte reading every 10 seconds, MQTT typically uses one tenth the bandwidth of equivalent HTTPS POSTs once TCP and TLS handshakes are amortised across the long-lived connection. On metered cellular this directly translates to a 10x reduction in data plan costs and a meaningful reduction in radio-on time, which in turn extends battery life on solar or battery powered nodes.
Topic Hierarchy Convention
MQTT topics are slash-delimited and case-sensitive. A consistent hierarchy is the single most important design decision in an MQTT deployment — once devices in production are publishing to a topic, changing it is expensive. SGT Systems recommends the following pattern:
sgt/<site>/<device>/<metric>
Examples:
sgt/dhaka-warehouse-1/gw-001/power_kwsgt/dhaka-warehouse-1/gw-001/temperature_csgt/chittagong-port/cold-room-3/door_statesgt/dhaka-warehouse-1/gw-001/$status(reserved$prefix for meta-topics)sgt/dhaka-warehouse-1/gw-001/cmd/reboot(commands flow cloud → device on a separate subtree)
mosquitto_sub -t 'sgt/+/gw-001/#'). The metric is the leaf so downstream consumers can subscribe with selective wildcards. Reserving a cmd/ subtree separates telemetry from control, which simplifies ACL design.
Wildcards: + matches a single level, # matches one or more levels (must be at the end). Avoid # in production subscribers — it's almost always a sign you want a more specific filter and are about to be surprised by traffic volume.
Topic length matters: MQTT permits topics up to 64 KB, but every byte is on the wire on every publish. Keep segments short and stable. Avoid encoding fast-changing data (timestamps, sequence numbers) in the topic itself — that defeats retained-message and ACL semantics.
QoS Levels Explained
| QoS | Guarantee | Mechanism | Use case |
|---|---|---|---|
| 0 | At most once | Fire-and-forget | High-frequency telemetry where loss is tolerable. |
| 1 | At least once | PUBLISH + PUBACK; broker may redeliver | Most telemetry and event traffic. Receivers must be idempotent. |
| 2 | Exactly once | Four-step handshake (PUBLISH, PUBREC, PUBREL, PUBCOMP) | Billing events, control commands where duplicates are unacceptable. |
QoS 2 roughly doubles broker CPU and quadruples message round-trips compared to QoS 1. Use it only where genuinely required. In practice 95% of SGT deployments use QoS 1 throughout, with payload-level deduplication keys (a UUID per logical reading) on the receive side. This gives effectively exactly-once semantics at a fraction of the cost.
The effective QoS of a delivery is the minimum of the publisher's QoS and the subscriber's subscription QoS. A publisher sending QoS 2 to a subscriber that asked for QoS 0 results in QoS 0 delivery — the broker does not "upgrade" the subscription.
Authentication
TLS certificates (mTLS)
The strongest deployment uses mutual TLS: the broker presents a server cert signed by a trusted CA, and each device presents a client cert whose Common Name (CN) is the device ID. The broker maps CN to an ACL entry. This eliminates the need to ship long-lived secrets to every device, and revoking a compromised device is a CRL/OCSP update rather than a fleet-wide password rotation.
# Mosquitto excerpt
listener 8883
cafile /etc/mosquitto/certs/ca.crt
certfile /etc/mosquitto/certs/server.crt
keyfile /etc/mosquitto/certs/server.key
require_certificate true
use_identity_as_username true
acl_file /etc/mosquitto/acl.conf
tls_version tlsv1.2
Example ACL file pinning a device to its own topic subtree:
# /etc/mosquitto/acl.conf
user gw-001
topic readwrite sgt/dhaka-warehouse-1/gw-001/#
user gw-002
topic readwrite sgt/dhaka-warehouse-1/gw-002/#
# Ingest worker can read everything but write nothing
user ingest
topic read sgt/#
Username + password over TLS
Acceptable for development and for fleets where provisioning client certs is operationally hard. Always pair with TLS so credentials are not transmitted in cleartext. Rotate passwords on a schedule and use a per-device username so a single leak does not compromise the whole fleet.
Last Will & Testament (LWT)
The LWT is a message the broker publishes on behalf of a client when it detects an ungraceful disconnect. This is how the platform knows a device went offline without explicit polling.
Convention: each device sets an LWT on its $status topic with payload offline, retained, and immediately on connect publishes the same topic with payload online, also retained. Subscribers see the latest status by default and live transitions in real time.
client.will_set(
topic = "sgt/dhaka-warehouse-1/gw-001/$status",
payload = "offline",
qos = 1,
retain = True,
)
client.connect(broker, 8883, keepalive=60)
client.publish("sgt/dhaka-warehouse-1/gw-001/$status", "online", qos=1, retain=True)
The LWT only fires on ungraceful disconnects (keepalive timeout, TCP RST, network drop). A clean MQTT DISCONNECT packet suppresses it — which is what you want on a planned shutdown, where the device should publish offline itself before disconnecting cleanly.
Common Brokers
| Broker | License | Sweet spot | Notes |
|---|---|---|---|
| Mosquitto | EPL/EDL | Single-node, < 50k clients | Tiny footprint, ideal for edge gateways and small deployments. |
| EMQX | Apache 2.0 / commercial | Clusters, 100k–10M clients | Erlang-based, great clustering, built-in rule engine, MQTT 5 support. |
| HiveMQ | Commercial / CE | Enterprise, regulated industries | Strong tooling, professional support, deep MQTT 5 features. |
| NanoMQ | MIT | Edge / embedded | Extremely small footprint, ideal on Raspberry Pi class hardware. |
| VerneMQ | Apache 2.0 | Telco-grade clusters | Erlang, mature clustering, plugin architecture. |
Sizing rule of thumb: a single modern broker node handles roughly 50,000–100,000 concurrent connections per vCPU and 10,000–30,000 messages per second per vCPU, depending on QoS, payload size, and TLS overhead. Cluster horizontally beyond that. Persistent sessions, retained messages, and high QoS all push these numbers down — benchmark with realistic workloads before sizing.
Sample paho-mqtt subscriber
import json
import ssl
import paho.mqtt.client as mqtt
BROKER = "mqtt.sgtsystems.com"
PORT = 8883
TOPIC = "sgt/+/+/power_kw"
def on_connect(client, userdata, flags, rc):
if rc == 0:
print("connected")
client.subscribe(TOPIC, qos=1)
else:
print(f"connect failed: rc={rc}")
def on_disconnect(client, userdata, rc):
print(f"disconnected rc={rc}; paho will auto-reconnect")
def on_message(client, userdata, msg):
try:
payload = json.loads(msg.payload)
except ValueError:
payload = msg.payload.decode("utf-8", errors="replace")
print(f"{msg.topic} qos={msg.qos} retained={msg.retain} -> {payload}")
client = mqtt.Client(client_id="ingest-worker-7", clean_session=False)
client.username_pw_set(username="ingest", password="REDACTED")
client.tls_set(
ca_certs="/etc/sgt/certs/ca.crt",
cert_reqs=ssl.CERT_REQUIRED,
tls_version=ssl.PROTOCOL_TLSv1_2,
)
client.on_connect = on_connect
client.on_disconnect = on_disconnect
client.on_message = on_message
client.reconnect_delay_set(min_delay=1, max_delay=120)
client.connect(BROKER, PORT, keepalive=60)
client.loop_forever()
clean_session=False? With a persistent session and a stable client_id, the broker queues QoS 1/2 messages while your subscriber is offline and delivers them on reconnect. Use a unique, stable client ID per logical subscriber.
Bridging and Shared Subscriptions
Two features make MQTT scale into multi-region and multi-cloud architectures:
- Bridging: one broker forwards a topic subtree to another broker. Use it to relay edge-broker traffic into a central cloud broker, or to mirror critical alarms into a backup region. Configured in
mosquitto.confwithconnection,topic in, andtopic outdirectives. - Shared subscriptions (MQTT 5, also EMQX/HiveMQ as a vendor extension on 3.1.1): multiple subscribers join a group with the
$share/<group>/topicprefix; the broker load-balances each message across one member of the group. This is how you horizontally scale an ingest pipeline behind a single topic.
Common Pitfalls
- Wildcard subscribers melting under load. A
#subscription pulls every message in the broker. In a 10k-device fleet this is tens of thousands of messages per second. Filter as narrowly as possible. - Sharing client IDs. Two clients with the same ID will boot each other in a constant reconnect loop. Always generate unique IDs (UUID or device serial).
- Forgetting retain on status topics. Without retain, a subscriber that connects after the device published
onlinesees nothing and assumes the device is missing. - Publishing JSON without a schema. Define a payload contract per metric topic and version it.
{"ts": "...", "v": 12.7, "u": "kW"}is a reasonable minimum. - Running QoS 2 everywhere. Doubles broker load with rarely any real-world benefit over QoS 1 + idempotent receivers.
- Ignoring keepalive. Set
keepaliveto roughly 1.5x your expected NAT timeout. 60s is a safe default; 30s for cellular. - Not throttling retries on connect failure. A fleet of 10k devices simultaneously hammering a freshly-restarted broker is a self-DDoS. Use random jitter on reconnect delay.
- Persisting too much. A persistent session with a slow consumer can fill the broker's disk. Set per-client queue limits and message expiry.
Monitoring
At minimum, instrument: connected clients count, in-flight messages, retained message count, queued QoS 1/2 messages, dropped messages, and per-topic publish rate. Mosquitto exposes $SYS/ topics for this; EMQX and HiveMQ expose Prometheus endpoints out of the box. Alert on retained-message growth (often a sign of a broken consumer) and on dropped messages (a sign of overload).
Need help?
If you're sizing a broker, debugging mTLS, or designing a topic hierarchy for a new deployment, the SGT Systems integrations team can help. Email support@sgtsystems.com or reach out via the contact page.