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.
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 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
WriteAsyncto put an item in. - The reader (the consumer) calls
ReadAsyncorReadAllAsyncto 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
Steps
Writer
WriteAsync drops an item
Channel
Thread-safe buffer holds the items
Reader
ReadAllAsync pulls items out
Bounded vs unbounded: pick the safe one
When you create a channel, you choose its size. This choice matters a lot.
| Channel type | How big | What happens when full | Risk |
|---|---|---|---|
| Unbounded | No limit | Never full, writer never waits | Can eat all your memory if the reader is slow |
| Bounded | Fixed limit | Writer waits or drops items | Safe 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.
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
Steps
Web request
User places an order
PublishAsync
Drop message on the channel
Channel writer
Item buffered in memory
Returns 200 OK
User gets a fast reply
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.
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
Steps
Event happens
e.g. cache needs warming
Drop on channel
publish and return
Worker handles it
background thread
Loss is harmless
safe to skip on crash
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.
| Need | In-memory channel | Real broker (RabbitMQ, Azure Service Bus) |
|---|---|---|
| Survives a crash | No | Yes |
| Works across machines | No | Yes |
| Automatic retry | No | Yes |
| Dead-letter for bad messages | No | Yes |
| Setup effort | Tiny, built in | Larger, separate service |
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.Channelsgives 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.Waitfor back-pressure. - A
BackgroundServicereads the channel withReadAllAsyncand 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
- Channels — Microsoft Learn
- Lightweight In-Memory Message Bus Using .NET Channels — Milan Jovanović
- .NET Channels as an In-Memory Message Bus, Beware! — CodeOpinion
- Producer/consumer pipelines with System.Threading.Channels — Maarten Balliauw
Related Patterns
Building a Better MediatR Publisher With Channels (and Why You Shouldn't)
Build a custom MediatR INotificationPublisher using System.Threading.Channels for background events in .NET, then learn why a queue this simple can quietly lose your data.
The Outbox Pattern in .NET: Never Lose a Message Again
Learn the Outbox Pattern in .NET with simple, real-life examples. Save your data and your messages in one transaction so a broker outage can never lose an event. Includes EF Core code, diagrams, and best practices.
How to Use Domain Events to Build Loosely Coupled Systems in .NET
Learn how domain events keep .NET code loosely coupled. A simple analogy, full C# examples, diagrams, timing tips, and common mistakes explained for beginners.
Getting Started With NServiceBus in .NET: A Beginner's Guide
Learn NServiceBus in .NET from scratch: endpoints, commands, events, handlers, retries, and pub-sub. Simple words, real-life examples, code, and diagrams.
Simple Messaging in .NET with Redis Pub/Sub: A Beginner's Guide
Learn Redis Pub/Sub in .NET with StackExchange.Redis using simple words, a real-life analogy, clean async C# code, diagrams, and when to use it safely.
CQRS Pattern with MediatR in .NET: A Friendly Guide
Learn the CQRS pattern with MediatR in .NET using simple words, clear diagrams, and real C# code. Beginner friendly, with pitfalls and licensing notes.