# Getting Started With NATS JetStream in .NET

> NATS almost never comes up when .NET developers talk about message queues, and that's a shame. It's a tiny, fast, single-binary broker, and JetStream adds durable, at-least-once delivery on top. Here's why it's worth a look and how to wire it into an ASP.NET Core app.

Published: 2026-06-27. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/getting-started-with-nats-jetstream-in-dotnet

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.

When .NET developers need a message queue, they reach for [**RabbitMQ**](https://milanjovanovic.tech/blog/event-driven-architecture-in-dotnet-with-rabbitmq), [**Azure Service Bus**](https://milanjovanovic.tech/blog/messaging-made-easy-with-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**](https://github.com/nats-io/nats.net) is a pleasure to work with.

## Core NATS vs JetStream

NATS has two layers, and the difference matters.

**Core [NATS](https://nats.io)** 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](https://docs.nats.io/nats-concepts/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](https://milanjovanovic.tech/blogs/mnw_200/core_nats_vs_jetstream.png)

## 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](https://docs.nats.io/nats-concepts/jetstream/streams) 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:

```yaml
# 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:

```bash
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:

```csharp
// 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**](https://milanjovanovic.tech/blog/minimal-apis-dotnet) 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:

```csharp
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](https://milanjovanovic.tech/blogs/mnw_200/nats_job_pipeline.png)

## 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:

```csharp
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](https://milanjovanovic.tech/blogs/mnw_200/ack_after_side_effect.png)

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**](https://milanjovanovic.tech/blog/the-idempotent-consumer-pattern-in-dotnet-and-why-you-need-it).
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**](https://milanjovanovic.tech/blog/nats-jetstream-job-queue-dotnet).

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.
