Skip to main content
SEMastery

Lightweight In-Memory Message Bus Using .NET Channels

Build a fast, thread-safe in-memory message bus in .NET using System.Threading.Channels, with a friendly pub/sub design, full code, and the safety warnings you need.

15 min readUpdated May 8, 2026

The dabbawala and the lunchbox tray

Think about a busy office in Mumbai. At lunchtime, dozens of people want their hot tiffin delivered. But the cooks at home do not run to each office themselves. They hand the lunchbox to a dabbawala, who drops it on a long tray at the station. Other dabbawalas pick the boxes off that tray and carry them to the right desks.

Notice the clever part. The cook is fast because she only has to hand over one box and walk away. She never waits for the delivery to finish. The tray sits in the middle. It holds boxes safely until someone is free to carry them.

In .NET we can build the exact same tray. The cook is one part of your program that says "something happened". The lunchbox is a message. The tray is a Channel. The delivery person is a background worker that reads the tray and does the slow work.

This is called an in-memory message bus. In this post we will build one step by step, in simple words. We will make it a tiny pub/sub system, so one message can reach many handlers. And then, like a careful teacher, I will show you the danger. Because that tray at the station has no lock and no roof. If a strong wind blows, the boxes fly away and are gone. Let us learn both the magic and the warning together.

What is a message bus, really?

A message bus is just a middleman. One part of your code wants to say "an order was placed" without knowing or caring who listens. Other parts of your code want to react to that, like sending an email or updating a report. The bus sits between them.

This gives us two nice things:

  • The sender does not wait. It drops the message and moves on. The user gets a quick reply.
  • The sender and the handlers do not know each other. You can add a new handler later without touching the sender. This is called loose coupling.
A message bus sits in the middle. The sender never talks to handlers directly.

A full message bus like RabbitMQ or Azure Service Bus runs as a separate program, often on another machine. That is powerful but heavy. Sometimes you only need to pass a message from one part of the same app to another part. For that, a whole broker is too much. This is where System.Threading.Channels shines.

Meet System.Threading.Channels

System.Threading.Channels is built into modern .NET. You do not install anything. It gives you a Channel<T>, which is a fast, thread-safe queue. Think of it as a pipe. One end is the writer, the other end is the reader.

  • The writer (the producer) calls WriteAsync to put an item in.
  • The reader (the consumer) calls ReadAsync or ReadAllAsync to take items out.

The channel does all the hard parts for you. It is thread-safe, so many writers and readers can use it at once without you writing any locks. And it is async-friendly, so a reader can wait politely for the next item without burning the CPU.

Here is the smallest possible example. One task writes three numbers, another reads them.

using System.Threading.Channels;
 
// Create an unbounded channel of integers.
Channel<int> channel = Channel.CreateUnbounded<int>();
 
// Producer: write 3 items, then say we are done.
_ = Task.Run(async () =>
{
    for (int i = 1; i <= 3; i++)
    {
        await channel.Writer.WriteAsync(i);
    }
    channel.Writer.Complete(); // No more items will come.
});
 
// Consumer: read every item until the writer says it is done.
await foreach (int number in channel.Reader.ReadAllAsync())
{
    Console.WriteLine($"Got {number}");
}

The lovely line is await foreach. The reader simply waits for items. When the writer calls Complete, the loop ends on its own. No flags, no Thread.Sleep, no mess.

Producer and consumer through a Channel

Writer
Channel
Reader

Steps

1

Writer

WriteAsync drops an item

2

Channel

Thread-safe buffer holds the items

3

Reader

ReadAllAsync pulls items out

The writer never touches the reader. The channel sits safely between them.

Bounded vs unbounded: pick the safe one

When you create a channel, you choose its size. This choice matters a lot.

Channel typeHow bigWhat happens when fullRisk
UnboundedNo limitNever full, writer never waitsCan eat all your memory if the reader is slow
BoundedFixed limitWriter waits or drops itemsSafe memory, but writer may slow down

An unbounded channel feels easy because the writer never blocks. But it hides a trap. If your producer is fast and your consumer is slow, the queue grows and grows until your app runs out of memory and crashes. That is a very bad day.

A bounded channel is the grown-up choice for real apps. You set a maximum number of items. When the queue is full, you decide what to do with the new item. This is set with BoundedChannelFullMode:

  • Wait — the writer waits until there is space. This is back-pressure. It is usually what you want.
  • DropOldest — throw away the oldest item to make room.
  • DropNewest — throw away the item being written.
  • DropWrite — silently ignore the new item.
var options = new BoundedChannelOptions(capacity: 100)
{
    FullMode = BoundedChannelFullMode.Wait, // Push back when full.
    SingleReader = false,
    SingleWriter = false
};
 
Channel<OrderPlaced> channel = Channel.CreateBounded<OrderPlaced>(options);

The SingleReader and SingleWriter flags are a promise to .NET. If you promise that only one thread will ever read (or write), .NET can use a faster, simpler design inside. If you are not sure, leave them false. A wrong promise here leads to nasty bugs.

Back-pressure: a full bounded channel makes the fast producer slow down and wait.

Building the in-memory message bus

Now we build the real thing. We want a small bus with two simple methods: one to publish a message and one to subscribe to it. We will wrap a channel inside a clean class.

First, let us define what a "message" looks like. We will keep it simple with a base marker.

// A marker so we know a type is a bus message.
public interface IMessage;
 
// An example message: an order was placed.
public sealed record OrderPlaced(Guid OrderId, decimal Amount) : IMessage;

Now the bus itself. It holds one channel. The Publish method writes to it. The bus also exposes the reader so a background worker can pull messages out later.

public interface IMessageBus
{
    ValueTask PublishAsync<T>(T message, CancellationToken ct = default)
        where T : IMessage;
}
 
public sealed class ChannelMessageBus : IMessageBus
{
    private readonly Channel<IMessage> _channel;
 
    public ChannelMessageBus()
    {
        var options = new BoundedChannelOptions(1_000)
        {
            FullMode = BoundedChannelFullMode.Wait,
            SingleReader = true,   // one background worker reads.
            SingleWriter = false   // many request threads write.
        };
        _channel = Channel.CreateBounded<IMessage>(options);
    }
 
    public ValueTask PublishAsync<T>(T message, CancellationToken ct = default)
        where T : IMessage
        => _channel.Writer.WriteAsync(message, ct);
 
    // The worker uses this to read messages.
    public ChannelReader<IMessage> Reader => _channel.Reader;
}

That is the whole bus. Notice how tiny it is. The channel does all the heavy lifting. PublishAsync returns almost instantly because it only drops the message on the tray.

The publish path inside the bus

Web request
PublishAsync
Channel writer
Returns 200 OK

Steps

1

Web request

User places an order

2

PublishAsync

Drop message on the channel

3

Channel writer

Item buffered in memory

4

Returns 200 OK

User gets a fast reply

A request publishes and returns at once. The slow work waits for the worker.

The background worker that reads the bus

A message bus needs someone to read it. In ASP.NET Core, the right tool is a BackgroundService. It runs from the moment the app starts until it shuts down. Inside, it sits in a loop and reads every message from the channel.

public sealed class MessageBusWorker : BackgroundService
{
    private readonly ChannelMessageBus _bus;
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<MessageBusWorker> _logger;
 
    public MessageBusWorker(
        ChannelMessageBus bus,
        IServiceScopeFactory scopeFactory,
        ILogger<MessageBusWorker> logger)
    {
        _bus = bus;
        _scopeFactory = scopeFactory;
        _logger = logger;
    }
 
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // ReadAllAsync waits politely for each new message.
        await foreach (IMessage message in _bus.Reader.ReadAllAsync(stoppingToken))
        {
            try
            {
                using IServiceScope scope = _scopeFactory.CreateScope();
                await DispatchAsync(message, scope.ServiceProvider, stoppingToken);
            }
            catch (Exception ex)
            {
                // One bad message must not kill the whole worker.
                _logger.LogError(ex, "Failed to handle {Type}", message.GetType().Name);
            }
        }
    }
 
    private static async Task DispatchAsync(
        IMessage message, IServiceProvider services, CancellationToken ct)
    {
        // Find all handlers for this exact message type and run them.
        Type handlerType = typeof(IMessageHandler<>).MakeGenericType(message.GetType());
        IEnumerable<object?> handlers = services.GetServices(handlerType);
 
        foreach (object? handler in handlers)
        {
            var task = (Task)handlerType
                .GetMethod("HandleAsync")!
                .Invoke(handler, new object[] { message, ct })!;
            await task;
        }
    }
}

Two things here are very important, and beginners often miss them.

First, the scope. A BackgroundService is a singleton. It lives forever. But many services, like a database context, are scoped and only live for one job. So we create a fresh IServiceScope for each message. This gives each handler its own clean, short-lived dependencies, just like a web request would.

Second, the try/catch. If a handler throws and we do not catch it, the exception escapes the await foreach loop. That ends the loop. That stops the worker. And then no future messages are ever handled. Wrapping each message in try/catch keeps the worker alive even when one message fails.

Adding pub/sub with handlers

A real bus lets many handlers react to one message. Let us define a handler interface and wire everything into dependency injection.

public interface IMessageHandler<in T> where T : IMessage
{
    Task HandleAsync(T message, CancellationToken ct);
}
 
// Two handlers that both care about OrderPlaced.
public sealed class SendEmailHandler : IMessageHandler<OrderPlaced>
{
    public async Task HandleAsync(OrderPlaced message, CancellationToken ct)
    {
        await Task.Delay(500, ct); // pretend an email takes time.
        Console.WriteLine($"Email sent for order {message.OrderId}");
    }
}
 
public sealed class UpdateReportHandler : IMessageHandler<OrderPlaced>
{
    public Task HandleAsync(OrderPlaced message, CancellationToken ct)
    {
        Console.WriteLine($"Report updated: +{message.Amount}");
        return Task.CompletedTask;
    }
}

Now register all of it in Program.cs. The bus is a singleton because there is only one tray. The worker is a hosted service. The handlers are scoped.

builder.Services.AddSingleton<ChannelMessageBus>();
builder.Services.AddSingleton<IMessageBus>(sp =>
    sp.GetRequiredService<ChannelMessageBus>());
builder.Services.AddHostedService<MessageBusWorker>();
 
builder.Services.AddScoped<IMessageHandler<OrderPlaced>, SendEmailHandler>();
builder.Services.AddScoped<IMessageHandler<OrderPlaced>, UpdateReportHandler>();

From a controller or a minimal API endpoint, publishing is now one line. Notice the request returns immediately, long before the slow email is sent.

app.MapPost("/orders", async (IMessageBus bus) =>
{
    var order = new OrderPlaced(Guid.NewGuid(), 250m);
    await bus.PublishAsync(order);   // fast: just drops on the tray.
    return Results.Accepted();       // user gets a quick reply.
});

This is the dabbawala in code. The endpoint hands over the box and walks away. The worker carries it to both handlers later. Below is the full picture of one message moving through the system.

A message flows from the request, into the channel, and out to every handler.

When this is a great idea

This pattern is wonderful for work where speed matters more than guarantees. Good fits are jobs where, if one message is lost during a rare crash, nobody is harmed.

  • Warming a cache after data changes.
  • Writing non-critical logs or metrics.
  • Sending a "nice to have" notification, like a welcome ping.
  • Doing quick cleanup that will happen again anyway on the next request.

In all of these, the worst case of a lost message is small. The user is not hurt. So the speed and simplicity of an in-memory bus is a clear win.

When the in-memory bus is the right tool

Event happens
Drop on channel
Worker handles it
Loss is harmless

Steps

1

Event happens

e.g. cache needs warming

2

Drop on channel

publish and return

3

Worker handles it

background thread

4

Loss is harmless

safe to skip on crash

Use it only when a lost message during a crash causes no real harm.

The warning: why you should be careful

Here is the part many tutorials skip. That lunchbox tray at the station has no lock and no roof. Let me list the real dangers plainly, because knowing them is what makes you a good engineer.

1. It lives only in memory. If your app restarts, deploys, or crashes, every message still waiting in the channel is gone forever. There is no disk and no recovery. The user already got "202 Accepted", so they believe their email is coming. It never will. The message simply vanished.

2. It only works inside one process. A channel cannot send a message to another machine or another instance of your app. If you run three copies of your service behind a load balancer, a message published in copy one stays inside copy one. The other two never see it.

3. There is no built-in retry. A real broker remembers a message until a handler confirms success, and retries on failure. A channel forgets the moment it hands the item over. If the handler fails, the message is lost unless you write your own retry logic.

4. There is no ordering guarantee across handlers. And if you use multiple readers, the order of processing can surprise you.

NeedIn-memory channelReal broker (RabbitMQ, Azure Service Bus)
Survives a crashNoYes
Works across machinesNoYes
Automatic retryNoYes
Dead-letter for bad messagesNoYes
Setup effortTiny, built inLarger, separate service
The hidden danger: a crash empties the channel and the waiting messages are lost.

What to do for important work

If losing a message would upset a customer or lose money, you need durability. The trusted pattern is the Outbox Pattern. The idea is simple: instead of dropping the message on a memory tray, you save it into your database inside the same transaction as your business data. Because it is on disk, a crash cannot lose it. A separate process then reads the outbox table and pushes the messages to a real broker, retrying until each one is safely delivered.

A short sketch of the durable write looks like this:

public async Task PlaceOrderAsync(Order order)
{
    using var transaction = await _db.Database.BeginTransactionAsync();
 
    _db.Orders.Add(order);
    // Save the message to disk, in the SAME transaction.
    _db.OutboxMessages.Add(new OutboxMessage(new OrderPlaced(order.Id, order.Amount)));
 
    await _db.SaveChangesAsync();
    await transaction.CommitAsync(); // both saved together, or neither.
}

The simple rule to remember: use the in-memory channel bus for fast, throwaway work, and the Outbox Pattern for work you cannot afford to lose. Many real systems use both. They use a channel for warming caches and an outbox for sending order confirmations. Knowing which is which is the whole skill.

A note on libraries and licences

You might wonder why we wrote this by hand instead of using a library. Two popular .NET messaging libraries, MediatR and MassTransit, both moved to a commercial licence in 2025. They still have free tiers, but you must check the terms before using them in a paid product. The nice thing about System.Threading.Channels is that it is part of .NET itself. It is free, fast, and has no licence worries. For a small in-process bus, hand-writing it is often the cleanest choice anyway.

Quick recap

  • A message bus is a middleman. The sender drops a message and walks away, like a cook handing a tiffin to a dabbawala.
  • System.Threading.Channels gives you a fast, thread-safe, in-memory queue built into .NET, with no extra packages.
  • A bounded channel is safer than an unbounded one because it protects your app from running out of memory. Use BoundedChannelFullMode.Wait for back-pressure.
  • A BackgroundService reads the channel with ReadAllAsync and runs your handlers. Always create a scope per message and wrap each message in try/catch so one failure does not kill the worker.
  • This pattern is great for fast, throwaway work like cache warming and metrics.
  • It is not reliable. A crash, restart, or deploy loses every waiting message. It does not work across machines and has no built-in retry.
  • For important work, use the Outbox Pattern with a real broker so messages survive on disk.
  • MediatR and MassTransit are now commercially licensed. Plain channels stay free and simple.

References and further reading

Related Patterns