NATS is a messaging system that runs as a single Go binary, and JetStream is the persistence layer that turns a subject into a durable queue with at-least-once delivery.
In .NET you add the NATS.Net client, publish from an endpoint, and consume in a BackgroundService.
The handler has to be idempotent, because a crash before the ack causes a redelivery.
Coder Agents is the agentic sidekick that already runs on your trusted infrastructure. Add a label like "coder" to a Github issue and watch your agent read the context, write the code, and open a pull request for your review. Explore it here.
AI has made generating applications incredibly fast, but only 8% of technical leaders describe their current AI governance as strong. Read the Retool 2026 State of AI Governance Report to explore how enterprises are tackling the shadow IT problem while continuing to empower their developers.
When .NET developers need a message queue, they reach for RabbitMQ, Azure Service Bus, or a Postgres table.
NATS almost never comes up. That's a shame: it's quietly become one of my favorite tools for this.
NATS is a messaging system written in Go that runs as a single binary with no external dependencies. JetStream, its durable layer, turns it into a real queue with at-least-once delivery. And the .NET client is a pleasure to work with.
Core NATS vs JetStream
NATS has two layers, and the difference matters.
Core NATS is fire-and-forget pub/sub. You publish to a subject, and whoever is subscribed at that moment gets it. If no one is listening, the message is gone, which suits live notifications but not a work queue.
JetStream is the persistence layer on top. It captures messages published to a subject into a stream on disk, so a consumer can read them later, even after a restart. That persistence is what turns a subject into a durable queue.
Why It's Worth a Look
A few things stood out coming from the usual brokers:
- Tiny. The official server image is about 18 MB, a single Go binary with no ZooKeeper or Erlang to babysit.
- Fast. Core NATS pushes millions of small messages per second on a single node. JetStream adds disk persistence, so it's slower, but still comfortably in the hundreds of thousands per second.
- Cheap to run. A server idles in tens of megabytes of RAM, so it runs right next to your app.
- Flexible per stream. Each stream sets its own storage and retention, so one server can host a cache and a strict work queue side by side.
Set It Up
You need the server and two NuGet packages.
Run the server with JetStream enabled.
-js turns it on, and -sd points it at a directory so streams survive a restart:
# docker-compose.yml
nats:
image: nats:2.14-alpine
command: ['-js', '-sd', '/data']
ports: ['4222:4222']
volumes:
- nats-data:/data
restart: unless-stopped
Add the client and its dependency-injection integration:
dotnet add package NATS.Net
dotnet add package NATS.Extensions.Microsoft.DependencyInjection
Then wire it into Program.cs.
AddNatsClient registers one multiplexed, self-reconnecting connection, and the next line exposes a JetStream context to inject anywhere:
// Program.cs
builder.Services.AddNatsClient(nats =>
nats.ConfigureOptions(opts => opts with { Url = "nats://localhost:4222" }));
builder.Services.AddSingleton(sp =>
sp.GetRequiredService<INatsConnection>().CreateJetStreamContext());
Publish a Job
With the JetStream context in DI, a Minimal API endpoint publishes in one call.
Job is a plain record, and NATS.Net serializes it to JSON for you, so you work with typed messages, no extra setup.
EnsureSuccess throws if the stream didn't store the message:
app.MapPost("/jobs", async (CreateJob request, INatsJSContext js, CancellationToken ct) =>
{
var job = new Job(Guid.NewGuid(), request.Payload);
PubAckResponse ack = await js.PublishAsync("jobs.work", job, cancellationToken: ct);
ack.EnsureSuccess();
return Results.Accepted($"/jobs/{job.Id}");
});
Process Jobs in a Worker
A BackgroundService is the natural home for the consumer.
It creates the stream and durable consumer on startup, then pulls messages in a loop.
Every running instance shares the workers consumer, so they compete for jobs and each runs once:
public class JobWorker(INatsJSContext js) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
await js.CreateStreamAsync(new StreamConfig("JOBS", ["jobs.work"])
{
Retention = StreamConfigRetention.Workqueue, // a queue: acked messages are removed
Storage = StreamConfigStorage.File // durable: survives a restart
}, ct);
var consumer = await js.CreateOrUpdateConsumerAsync("JOBS", new ConsumerConfig("workers")
{
AckPolicy = ConsumerConfigAckPolicy.Explicit,
AckWait = TimeSpan.FromSeconds(30), // must exceed your worst-case processing time
MaxDeliver = 5 // drop a poison message after 5 tries
}, ct);
await foreach (var msg in consumer.ConsumeAsync<Job>(cancellationToken: ct))
{
await ProcessAsync(msg.Data, ct); // the side effect
await msg.AckAsync(cancellationToken: ct); // then ack
}
}
}
Register it with builder.Services.AddHostedService<JobWorker>().
The worker is a singleton, so resolve scoped dependencies like DbContext through IServiceScopeFactory.
Two stream settings shape how the queue behaves.
Storage is File (on disk, survives restarts) or Memory (faster, but gone on restart).
Retention controls when a message leaves the stream:
Limits(the default) keeps every message until it hits an age, size, or count limit. The stream is a replayable log, and reading a message doesn't remove it.Workqueuedrops a message the moment a consumer acks it, so the stream itself is the queue. Messages are delivered in publish order, oldest first (FIFO).Interestkeeps a message only while a consumer still needs it, then drops it once every interested consumer acks.
For a job queue: Workqueue on File, as in the worker above.
Acknowledge After the Side Effect
Look closely at the worker loop: it processes first, then acks. That order is the rule that makes JetStream reliable, and most quickstarts skip it.
Acknowledge the message after the side effect, never before.
JetStream gives you at-least-once delivery. If a worker runs a job and crashes before acking, JetStream redelivers it. But ack before the work is finished, and a crash leaves the job marked done with nothing to show for it.
The flip side is that a job can run more than once, so your handler has to be idempotent. The usual fix is to track the messages you've already handled and skip duplicates, in the same transaction as the side effect. I covered the full pattern in The Idempotent Consumer Pattern in .NET. At-least-once delivery only holds up when the handler reading the stream is idempotent.
Summary
NATS JetStream gives you a durable, at-least-once work queue from a single 18 MB binary, and it slots into an ASP.NET Core app cleanly: publish from an endpoint, process in a BackgroundService, ack after the work is done.
I went in skeptical, half-expecting to miss RabbitMQ. It won me over: easy to operate, no surprises, and it clusters with Raft-based replication when a bigger load calls for it. It's now the first thing I reach for when I need a queue and don't want to think much about the broker. It runs my own production job queue today, and I wrote up that design (two streams, competing consumers, publish-before-ack) in how I use NATS JetStream as a job queue in .NET.
If you haven't tried it, spin up the container and publish a message. That's all there is to getting started.
Thanks for reading.
And stay awesome!
Frequently Asked Questions
What is the difference between Core NATS and JetStream?
Core NATS is fire-and-forget pub/sub: if no one is subscribed when you publish, the message is gone. JetStream is the persistence layer on top that captures messages into a stream on disk, giving you durable, at-least-once delivery even after a restart.
Is NATS a good message queue for .NET applications?
Yes. The server is a single Go binary with an image around 18 MB, it idles in tens of megabytes of RAM, and the official NATS.Net client integrates cleanly with ASP.NET Core dependency injection and serializes typed messages to JSON automatically.
How fast is NATS JetStream?
Core NATS pushes millions of small messages per second on a single node. JetStream adds disk persistence, so it is slower, but still handles hundreds of thousands of messages per second, which is plenty for most job queues.
How do you enable JetStream on a NATS server?
Start the server with the -js flag to turn JetStream on and -sd pointing at a storage directory so streams survive a restart. In Docker Compose that is a one-line command on the official nats image, plus a volume for the data directory.
What retention policies do JetStream streams support?
Limits (the default) keeps messages until an age, size, or count limit, making the stream a replayable log. Workqueue removes a message as soon as a consumer acks it, delivering oldest first. Interest keeps a message only while an interested consumer still needs it.
When should a JetStream consumer acknowledge a message?
After the side effect completes, never before. If a worker crashes before acking, JetStream redelivers the message. Acking first means a crash leaves the job marked done with nothing to show for it. Since redelivery causes duplicates, the handler must be idempotent.



