Did you hear about NATS?
Seriously - have you?
When we think âmessaging,â the same names pop up:
⢠RabbitMQ
⢠Azure Service Bus
⢠Kafka
⢠Redis Pub/Sub
Theyâre all great, and they all have their place...
But theyâre not the whole story.
Thereâs an entire class of systems where those brokers either become too slow, too heavy, or too complex.
And thatâs exactly where NATS appears as a surprisingly simple answer.
So What Makes NATS Different?
While traditional brokers focus on features like durability, transactions, partitions, advanced routing, and so onâŚ
NATS focuses on something else entirely:
đ Tiny latency
đ Huge throughput
đ Simple operation
đ Scales like crazy
đ Works beautifully with distributed edge devices
If your system needs:
⢠thousands of small messages per second
⢠real-time reactions
⢠low-latency communication
⢠services loosely connected with zero overhead
⢠resilient connections over shaky networks
⢠tiny agents that can run anywhere
âŚthen NATS suddenly becomes very interesting.
A Different Kind of Problem: Real-Time Smart Agriculture
Let me give you a scenario where NATS actually makes perfect sense.
Imagine a modern agriculture system spread across a huge field.
You have:
⢠soil moisture sensors
⢠small climate sensors
⢠drone stations
⢠irrigation controllers
⢠a dashboard
⢠and some lightweight AI logic that helps make decisions
These sensors send data every few hundred milliseconds.
Some devices drop offline.
Some reconnect.
Some flood the system with bursts of readings.
Itâs not a big âenterprise event busâ job.
Itâs a high-frequency, real-time, lightweight messaging challenge.
And this is exactly the kind of system where RabbitMQ or Kafka start to feel too heavy and too slow.
This Is Where NATS Fits Perfectly
NATS is built for situations like this:
⢠Where messages are extremely frequent
⢠Where low latency really matters
⢠Where devices or services come and go
⢠Where you need both Pub/Sub and request-reply
⢠Where you want to scale easily without complexity
⢠Where you want something simple and fast, not a big distributed monster
If your message volume is small and you want durability or transactions, use RabbitMQ or Service Bus.
If you want big data analytics or huge event streams, Kafka is the king.
But if you need:
⢠speed
⢠simplicity
⢠easy fan-out
⢠real-time messaging
⢠millions of events per second
âŚthatâs NATS territory.
Letâs Build Something in .NET
Hereâs a realistic example using NATS for that smart agriculture system.
Weâll build:
⢠sensor simulators
⢠real-time analytics
⢠irrigation controllers (queue groups)
⢠device actuators
⢠and a requestâreply endpoint
1. Install the client
First, install the official .NET client:
12
dotnet add package NATS.Client.Core
Whatâs happening here?
⢠Youâll use NatsConnection from this package to publish and subscribe to messages.
2. Creating a Reusable NATS Connection
You generally want one connection per service, reused everywhere (like HttpClient)
123456789101112131415
public static class NatsConnectionFactory
{
public static NatsConnection Create()
{
return new NatsConnection(new NatsOpts
{
Url = "nats://localhost:4222",
Name = "smart-agriculture",
Reconnect = true,
MaxReconnect = 10,
ReconnectWait = TimeSpan.FromSeconds(1),
});
}
}
Explanation
⢠Url - where your NATS server lives (locally, Docker, Kubernetes, cloud, etc.).
⢠Reconnect = true - NATS will try to reconnect if the connection drops (important for sensors on a weak network).
⢠MaxReconnect + ReconnectWait - limits and spreads out reconnection attempts so your app doesnât go crazy if the server is temporarily down.
In a real app, youâd typically wrap this in DI as a singleton.
3. Defining the Messages (Contracts)
Letâs define simple message types for:
⢠soil moisture readings, and
⢠irrigation commands.
1234567891011
public record SoilMoistureReading(
string SensorId,
string FieldId,
double MoisturePercent,
DateTime TimestampUtc);
public record IrrigationCommand(
string FieldId,
bool EnableIrrigation,
double TargetMoisturePercent,
DateTime TimestampUtc);
Explanation
⢠SoilMoistureReading - a single reading from one sensor.
⢠FieldId - groups sensors by field/zone.
⢠IrrigationCommand - a decision: âturn irrigation on/off for this field, targeting X% moistureâ.
⢠Records are convenient because:
1. theyâre immutable,
2. serialize nicely,
3. and clearly express intent.
4. Simulating a Sensor (Publisher)
Now, letâs simulate a sensor sending readings every 500ms.
12345678910111213141516171819202122232425262728293031
public sealed class SoilSensorSimulator
{
private NatsConnection _connection;
private readonly string _sensorId;
private readonly string _fieldId;
private readonly Random _random = new();
public SoilSensorSimulator(NatsConnection connection, string sensorId, string fieldId)
{
_connection = connection;
_sensorId = sensorId;
_fieldId = fieldId;
}
public async Task RunAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
var reading = new SoilMoistureReading(
SensorId: _sensorId,
FieldId: _fieldId,
MoisturePercent: 15 + _random.NextDouble() * 20, // 15â35%
TimestampUtc: DateTime.UtcNow);
var subject = $"sensors.soil.moisture.{_fieldId}";
await _connection.PublishAsync(subject, reading, cancellationToken: ct);
Console.WriteLine($"[Sensor {_sensorId}] {reading.MoisturePercent:F1}% in field {reading.FieldId}");
await Task.Delay(500, ct);
}
}
}
Explanation
1. all soil sensors,
2. or a specific field,
3. or even a subset using wildcards.
⢠We send a new reading every 500ms to simulate a real sensor.
⢠In a real system:
1. this code might run on a device,
2. or youâd have multiple instances for multiple sensors.
5. Real-Time Analytics (Subscriber with Wildcard)
Now letâs build a consumer that listens to all soil moisture sensors and logs them.
12345678910111213141516171819202122232425262728
using Microsoft.Extensions.Hosting;
using NATS.Client.Core;
public sealed class SoilAnalyticsWorker : BackgroundService
{
private NatsConnection _connection;
public SoilAnalyticsWorker(NatsConnection connection)
{
_connection = connection;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Subscribe to all soil moisture readings from all fields
await foreach (var msg in _connection.SubscribeAsync<SoilMoistureReading>(
subject: "sensors.soil.moisture.*",
cancellationToken: stoppingToken))
{
var reading = msg.Data;
Console.WriteLine(
$"[Analytics] Field={reading.FieldId}, Sensor={reading.SensorId}, Moisture={reading.MoisturePercent:F1}%");
// Here you could:
// - update an in-memory average
// - push to a time-series DB
// - feed a dashboard, etc.
}
}
}
Explanation
⢠BackgroundService integrates nicely with ASP.NET Core / generic host.
⢠SubscribeAsync tells NATS to:
1. subscribe to this subject,
2. and deserialize payloads into SoilMoistureReading.
⢠We process messages in a simple await foreach loop. When the app stops, the loop ends via the stoppingToken.
6. Irrigation Controller with Queue Groups (Load Balancing)
What if we have multiple instances of an irrigation controller service?
We want only one instance to handle each reading and decide on irrigation, not all of them.
Thatâs exactly what NATS queue groups do.
1234567891011121314151617181920212223242526272829303132333435363738394041
public sealed class IrrigationControllerWorker : BackgroundService
{
private NatsConnection _connection;
private const string QueueGroupName = "irrigation-controllers";
public IrrigationControllerWorker(NatsConnection connection)
{
_connection = connection;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var msg in
_connection.SubscribeAsync<SoilMoistureReading>(
subject: "sensors.soil.moisture.*",
queueGroup: QueueGroupName,
cancellationToken: stoppingToken))
{
var reading = msg.Data;
Console.WriteLine(
$"[Controller] Field={reading.FieldId}, Moisture={reading.MoisturePercent:F1}%");
if (reading.MoisturePercent < 20)
{
var command = new IrrigationCommand(
FieldId: reading.FieldId,
EnableIrrigation: true,
TargetMoisturePercent: 25,
TimestampUtc: DateTime.UtcNow);
await _connection.PublishAsync(
"irrigation.commands",
command,
stoppingToken);
Console.WriteLine(
$"[Controller] Issued irrigation command for field {reading.FieldId}");
}
}
}
}
Explanation
⢠queueGroup: "irrigation-controllers" makes this subscription part of a queue group.
⢠If you run 3 instances of this worker:
1. NATS will load-balance messages across them.
2. Each message goes to exactly one instance in the group.
⢠This is perfect for scaling âdecisionâ services horizontally.
7. Irrigation Device (Command Subscriber)
Now we simulate a device that receives irrigation commands and acts on them.
12345678910111213141516171819202122232425262728
public sealed class IrrigationDeviceWorker : BackgroundService
{
private NatsConnection _connection;
private readonly string _deviceId;
public IrrigationDeviceWorker(NatsConnection connection, string deviceId)
{
_connection = connection;
_deviceId = deviceId;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var msg in _connection.SubscribeAsync<IrrigationCommand>(
subject: "irrigation.commands",
cancellationToken: stoppingToken))
{
var command = msg.Data;
Console.WriteLine(
$"[Device {_deviceId}] Apply irrigation â Field={command.FieldId}, Enable={command.EnableIrrigation}, Target={command.TargetMoisturePercent}%");
// Here you'd talk to actual hardware:
// - open valve
// - configure timer
// - log to local storage, etc.
}
}
}
Explanation
⢠This worker subscribes to a simple subject: irrigation.commands.
⢠Commands are broadcast - multiple devices can listen if needed.
⢠In reality, you might:
1. use per-device subjects,
2. or add addressing fields inside the command.
⢠The core idea: the controller and device are loosely coupled via NATS.
8. RequestâReply: Ask for a Fieldâs Status
Sometimes you donât want just a stream - you want to ask the system something:
âWhatâs the current moisture in Field-42?â
NATS supports this pattern natively with requestâreply.
8.1. Define request/response models and cache
123456789101112
public record FieldStatusRequest(string FieldId);
public record FieldStatusResponse(
string FieldId,
double AverageMoisturePercent,
DateTime LastUpdatedUtc);
public interface IFieldStatusCache
{
(double AverageMoisture, DateTime LastUpdatedUtc) GetStatusForField(string fieldId);
}
Explanation
⢠FieldStatusRequest - what the caller sends (just a FieldId).
⢠FieldStatusResponse - what the responder returns.
⢠IFieldStatusCache - simple abstraction; your analytics worker could update this cache as readings arrive.
8.2. The responder (service answering status requests)
1234567891011121314151617181920212223242526272829
public sealed class FieldStatusResponder : BackgroundService
{
private NatsConnection _connection;
private IFieldStatusCache _cache;
public FieldStatusResponder(NatsConnection connection, IFieldStatusCache cache)
{
_connection = connection;
_cache = cache;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var msg in
_connection.SubscribeAsync<FieldStatusRequest>(
subject: "fields.status.get",
cancellationToken: stoppingToken))
{
var (average, lastUpdated) = _cache.GetStatusForField(msg.Data.FieldId);
var response = new FieldStatusResponse(
FieldId: msg.Data.FieldId,
AverageMoisturePercent: average,
LastUpdatedUtc: lastUpdated);
await msg.ReplyAsync(response, stoppingToken);
}
}
}
Explanation
⢠We subscribe to fields.status.get.
⢠For each incoming request:
1. we look up the field in the cache,
2. create a response,
3. and send it back using msg.ReplyAsync.
⢠NATS handles the internal reply subject and correlation.
8.3. The client (e.g., your ASP.NET Core endpoint)
123456789101112131415161718192021
public sealed class FieldStatusClient
{
private NatsConnection _connection;
public FieldStatusClient(NatsConnection connection)
{
_connection = connection;
}
public async Task<FieldStatusResponse?> GetStatusAsync(string fieldId, CancellationToken ct = default)
{
var request = new FieldStatusRequest(fieldId);
var msg = await _connection.RequestAsync<FieldStatusRequest, FieldStatusResponse>(
subject: "fields.status.get",
data: request,
cancellationToken: ct);
return msg.Data;
}
}
Explanation
⢠RequestAsync<TRequest, TResponse> sends a request and waits for the reply.
⢠Under the hood:
1. NATS creates a unique reply subject,
2. the responder replies to it,
3. the client gets the response mapped to FieldStatusResponse
⢠This pattern is very handy for internal RPC between services without needing gRPC/HTTP.
You could easily expose this in ASP.NET Core:
123456
app.MapGet("/fields/{fieldId}/status", async (string fieldId, FieldStatusClient client, CancellationToken ct) =>
{
var status = await client.GetStatusAsync(fieldId, ct);
return status is null ? Results.NotFound() : Results.Ok(status);
});
When NATS Makes Sense (And When It Doesnât)
You donât have to replace RabbitMQ or Kafka everywhere.
NATS is a good fit when:
⢠You have lots of small, frequent messages
⢠Latency and responsiveness really matter
⢠Youâre dealing with IoT, sensors, or edge devices
⢠You want simple, fast, real-time messaging
⢠You prefer minimal operational overhead
⢠You like the idea of Pub/Sub, queue groups, and request-reply in one system
NATS is not ideal when:
⢠You need heavy analytics over huge event streams â Kafka is better
⢠You rely on advanced broker features (transactions, sessions, dead-lettering, etc.) â RabbitMQ / Azure Service Bus
⢠You want exactly-once semantics (in practice: Kafka territory)
Wrapping up
Most developers never think about NATS simply because it doesnât dominate headlines the way Kafka or RabbitMQ do.
NATS doesnât try to be everything.
It doesnât come with a huge learning curve.
It doesnât require a cluster of heavyweight brokers.
It just gives you:
⢠a ridiculously fast messaging system,
⢠extremely simple APIs,
⢠and the flexibility to build real-time systems without complexity.
If your next project involves devices, sensors, real-time updates, automation, or anything where speed and simplicity matter more than heavy enterprise features, give NATS a try.
It might surprise you how far you can go with something this small and this fast.
That's all from me for today.