Getting Started With NATS JetStream in .NET

Getting Started With NATS JetStream in .NET

5 min read··

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.

Core NATS drops a message when no subscriber is online; JetStream persists it to a file-backed stream and delivers it later

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}");
});
A producer publishes to a work-queue stream, and a pool of workers competes on one durable pull consumer

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.
  • Workqueue drops a message the moment a consumer acks it, so the stream itself is the queue. Messages are delivered in publish order, oldest first (FIFO).
  • Interest keeps 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.

A worker fetches a job, runs it, persists the result, and only then acks; a crash before the ack causes a redelivery

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.

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!


Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 74,000+ engineers who are improving their skills every Saturday morning.