TL;DR:
- NATS is a lightweight, high-throughput messaging system written in Go, capable of millions of messages per second with sub-millisecond latency
- JetStream (NATS’s persistence layer) adds durable subscriptions, message replay, and at-least-once delivery without requiring a separate system like Kafka
- NATS’s leaf node architecture makes it well-suited for edge/cloud hierarchies where edge nodes need to sync with central brokers
IoT and edge systems generate a lot of messages — sensor readings, status updates, control commands, configuration pushes. Getting those messages from devices to the systems that process them, and getting commands back down to devices, requires a messaging layer that’s fast, reliable, and manageable.
MQTT dominates in industrial IoT, Kafka is common in data pipelines, and cloud IoT platforms (AWS IoT Core, Azure IoT Hub) handle millions of devices with managed infrastructure. But there’s a gap in the middle — projects that have outgrown simple MQTT brokers but don’t need the operational weight of Kafka — where NATS fits particularly well.
What Makes NATS Different
NATS was built at Synadia (originally Apcera) as a high-performance messaging system for cloud-native workloads. The server is a single Go binary with no external dependencies. No ZooKeeper, no JVM, no complex configuration file. On a modest server it handles millions of messages per second. The client libraries cover Go, Rust, Python, JavaScript, Java, C, and more — the full IoT and edge language range.
The core model is publish-subscribe with subjects — hierarchical string identifiers that route messages. Wildcards make the routing flexible:
sensor.building1.floor3.temperature # Specific sensor
sensor.building1.*.temperature # All temp sensors on building 1, any floor
sensor.> # Everything from all sensors
This subject hierarchy maps naturally to device taxonomies — by location, type, or tenant — and makes routing and filtering cheap.
JetStream: Persistence Without Kafka
The base NATS protocol is fire-and-forget — if a subscriber isn’t connected when a message arrives, the message is gone. For IoT telemetry where devices publish at irregular intervals and your processing pipeline might restart, you need persistence.
JetStream is NATS’s built-in persistence layer. It adds:
- Streams: durable logs that retain messages for a configurable period or count
- Consumers: subscriptions that track position and deliver with at-least-once guarantees
- Message replay: consumers can start from any position in a stream
- Work queues: distribute message processing across multiple workers with acknowledgment
Setting up a JetStream stream for IoT telemetry:
import (
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
nc, _ := nats.Connect("nats://localhost:4222")
js, _ := jetstream.New(nc)
// Create a stream that retains 7 days of sensor data
stream, _ := js.CreateOrUpdateStream(ctx, jetstream.StreamConfig{
Name: "SENSORS",
Subjects: []string{"sensor.>"},
Retention: jetstream.LimitsPolicy,
MaxAge: 7 * 24 * time.Hour,
Storage: jetstream.FileStorage,
Replicas: 3, // Replicated across 3 NATS servers
})
Publishing telemetry from a device:
// Device publishes reading
js.Publish(ctx, "sensor.building1.floor3.temperature", []byte(`{
"value": 22.4,
"unit": "celsius",
"timestamp": "2026-06-13T10:15:00Z"
}`))
Processing in a worker:
consumer, _ := js.CreateOrUpdateConsumer(ctx, "SENSORS", jetstream.ConsumerConfig{
Name: "telemetry-processor",
FilterSubject: "sensor.building1.>",
AckPolicy: jetstream.AckExplicitPolicy,
})
msgs, _ := consumer.Messages()
for msg := range msgs {
processTelemetry(msg.Data())
msg.Ack() // Acknowledge only after successful processing
}
If the processor crashes before acknowledging, the message is redelivered. This at-least-once guarantee is what most IoT processing pipelines actually need.
The Leaf Node Architecture for Edge/Cloud Hierarchies
NATS’s standout feature for edge computing is the leaf node. A leaf node is a NATS server that connects to a central NATS cluster but handles local routing independently. Messages published on the leaf node are available locally (for edge processing) and automatically forwarded to the hub if there are subscribers there.
This matches the typical edge architecture: edge devices talk to a local NATS server, edge compute processes what it can locally, and selected data flows to the central cloud cluster for aggregation and analysis.
Devices → Leaf Node (factory floor) → NATS Cluster (data centre / cloud)
↕ ↕
Local processing Cloud processing
(low latency, offline-capable) (analytics, storage)
The leaf node configuration is minimal:
# leafnode.conf (edge server)
port: 4222
leafnodes {
remotes = [
{
url: "nats://hub.example.com:7422"
credentials: "/etc/nats/edge-creds.creds"
}
]
}
The edge NATS server works independently if connectivity to the hub drops. Devices on the local network continue publishing and receiving messages. When connectivity restores, JetStream handles catch-up — outstanding messages are forwarded to the hub, and hub messages queued for the edge are delivered. This resilience to network partitions is exactly what factory floors, remote sites, and vehicles need.
Request-Reply for Command and Control
Beyond telemetry (device → cloud), IoT systems need command and control (cloud → device). NATS’s request-reply pattern handles this cleanly:
// Cloud: send command and wait for acknowledgment
response, err := nc.Request(
"device.d1a2b3.command",
[]byte(`{"action": "restart", "delay_seconds": 5}`),
5 * time.Second,
)
if err != nil {
log.Printf("Command timed out or failed: %v", err)
return
}
log.Printf("Device acknowledged: %s", response.Data)
// Device: handle command and reply
nc.Subscribe("device.d1a2b3.command", func(msg *nats.Msg) {
var cmd Command
json.Unmarshal(msg.Data, &cmd)
executeCommand(cmd)
msg.Respond([]byte(`{"status": "accepted", "ts": "2026-06-13T10:15:00Z"}`))
})
The reply is routed back to the original requester via an automatically generated reply subject. No request IDs to manage, no separate response topic to configure.
Security
NATS supports multiple authentication mechanisms: username/password, token, TLS client certificates, and NKeys (Ed25519 key pairs). For edge deployments, NKeys are particularly clean — each device has a public/private key pair, and the server validates identity without managing a password database.
Subject-based permissions let you enforce that a device can only publish to its own subjects and subscribe to its command channel:
# Per-device credentials
publish:
- "sensor.building1.d1a2b3.>"
- "device.d1a2b3.status"
subscribe:
- "device.d1a2b3.command"
- "config.>"
A compromised device can’t publish to other devices’ subjects or subscribe to data it shouldn’t see.
NATS vs MQTT for IoT
MQTT is the established standard for IoT messaging. If your devices already speak MQTT, NATS isn’t a replacement — it’s an alternative for new systems or for the backend processing layer behind an MQTT broker.
Where NATS has the edge over MQTT:
- Performance: NATS throughput is significantly higher than most MQTT brokers at scale
- Request-reply: MQTT is pub-sub only; request-reply requires workarounds
- JetStream vs MQTT persistence: NATS’s persistence model is more flexible
- Operational simplicity: A single NATS binary vs managing an MQTT broker + potentially Kafka for streaming
Where MQTT wins:
- Device compatibility: Nearly every IoT device and sensor supports MQTT out of the box
- QoS levels: MQTT’s QoS 0/1/2 semantics are standardised across vendors
- Ecosystem: Enormous library of MQTT-compatible devices, tools, and documentation
For greenfield edge systems where you control both the device firmware and the backend, NATS is worth serious evaluation. For connecting existing device estates that already speak MQTT, consider a bridge rather than a replacement.