A couple of weeks ago, I posted about consuming Kafka events as background jobs in .NET. Today, let's look at RabbitMQ.
Writing a basic RabbitMQ consumer in C# looks easy on tutorials: you create a connection, open a channel, attach an EventingBasicConsumer or AsyncDefaultBasicConsumer, and handle messages.
Then you run it in production, and edge cases start piling up:
-
Long-running processing blocks the consumer. If a message takes 30 seconds to process (like calling a slow third-party API or generating a PDF), RabbitMQ's
consumer_timeout(default 30 minutes in newer versions, or much less if configured) can fire and drop your channel. Worse, prefetching messages while one thread is stuck can starve other consumers. - The Ack dilemma. If you ack the message as soon as it arrives, you lose data when the worker crashes halfway through. If you hold the ack until everything finishes, an unexpected crash re-delivers the message, causing duplicate side effects unless you hand-roll deduplication everywhere.
- Storage/database blips crash the channel. If your database is restarting, your handler throws an unhandled exception. In RabbitMQ, an unhandled exception inside the consumer callback often closes the AMQP channel. You have to write reconnection and channel recreation logic yourself.
-
Broken trace context. Your incoming API request had a W3C
traceparent. Once it went through RabbitMQ, your background logs and spans in OpenTelemetry / Jaeger are completely detached.
Instead of keeping connections open while doing heavy processing, a much cleaner pattern is to treat RabbitMQ as an ingress trigger: pull the message, persist it into durable job storage (Postgres, SQL Server, Redis, Mongo), ack it immediately to the broker, and let a dedicated background job executor handle the heavy lifting, retries, and dead-lettering.
Here is how we do that with NexJob.RabbitMQ.
1. Setting Up the Trigger
Install the package:
dotnet add package NexJob.RabbitMQ
dotnet add package NexJob.PostgreSQL # or Redis, SqlServer, Mongo, InMemory
In your Program.cs, wire up NexJob with your preferred storage, and register the RabbitMQ trigger bound to a job type:
using NexJob;
using NexJob.RabbitMQ;
var builder = WebApplication.CreateBuilder(args);
// Storage provider (Postgres here, but any NexJob provider works)
builder.Services.AddNexJobPostgres(builder.Configuration.GetConnectionString("NexJob")!);
// Register NexJob core and bind the RabbitMQ trigger to our job handler
builder.Services.AddNexJob()
.AddRabbitMqTrigger<ProcessOrderJob>(options =>
{
options.HostName = builder.Configuration["RabbitMQ:Host"] ?? "localhost";
options.Port = 5672;
options.UserName = builder.Configuration["RabbitMQ:User"] ?? "guest";
options.Password = builder.Configuration["RabbitMQ:Pass"] ?? "guest";
options.QueueName = "incoming-orders";
options.TargetQueue = "orders"; // internal NexJob queue priority/partition
options.PrefetchCount = 20;
});
var app = builder.Build();
app.Run();
2. Writing the Job Handler
The job handler implements IJob<string>. The incoming RabbitMQ payload is passed directly as the input string (typically JSON):
using System.Text.Json;
using NexJob;
public sealed class ProcessOrderJob(ILogger<ProcessOrderJob> logger, IPaymentGateway paymentGateway)
: IJob<string>
{
public async Task ExecuteAsync(IJobContext context, string input, CancellationToken ct)
{
var order = JsonSerializer.Deserialize<OrderPayload>(input)!;
logger.LogInformation("Processing order {OrderId} (Attempt {Attempt})",
order.OrderId, context.Attempt);
// Run your business logic safely.
// If this throws or crashes, RabbitMQ is already freed up.
// NexJob manages retry delays, backoff, and dead-lettering.
await paymentGateway.ChargeAsync(order.CustomerId, order.Amount, ct);
}
}
public sealed record OrderPayload(Guid OrderId, Guid CustomerId, decimal Amount);
3. What Happens Under the Hood
When a message hits incoming-orders, here is the exact sequence:
[ RabbitMQ Queue ]
│
▼
[ NexJob RabbitMQ Trigger ]
│ 1. Reads W3C 'traceparent' header -> injects into JobRecord.TraceParent
│ 2. Uses broker's MessageId as IdempotencyKey (duplicate delivery protection)
│ 3. Calls IScheduler.EnqueueAsync(job)
▼
[ Durable Storage (Postgres/Redis/etc.) ]
│
├─ If Commit OK: Trigger immediately sends BasicAck to RabbitMQ.
│ RabbitMQ's job is done. The broker channel stays clean and free.
│
└─ If Storage Down: Trigger Nacks with requeue after 1s backoff.
If message has no valid JobType: Trigger Nacks with requeue=false (moves to DLX).
The 4 Guarantees You Get for Free:
- Broker Ack only after durable commit: The message is only acknowledged to RabbitMQ after the job record is safely written to the database. If your app dies before the commit, RabbitMQ still has the message.
-
Native Idempotency: The trigger uses RabbitMQ's native
BasicProperties.MessageIdas the job'sIdempotencyKey. If a network hiccup causes RabbitMQ to redeliver the message, NexJob detects the duplicate and ignores it instead of double-charging your customer. -
Trace Propagation: If the message producer passed a
traceparentheader, NexJob parses it and links the background execution to that exact distributed trace. In your dashboard or telemetry logs, you see the entire lifecycle end-to-end. -
Channel Isolation: If
ProcessOrderJobthrows an exception, fails payment, or times out, it does not close your RabbitMQ channel or cause connection thrashing. It follows your NexJob retry policy (e.g., 3 retries with exponential backoff) and lands in the dead-letter queue in your dashboard if all retries fail.
4. What About Publishing? (The Outbox Producer)
Handling inbound messages is half the story. The other common failure point is publishing to RabbitMQ from your API handlers.
If you publish directly to RabbitMQ inside an HTTP endpoint:
- What if the broker is restarting or temporarily unreachable?
- What if RabbitMQ accepts the TCP connection but fails to persist the message to disk before acknowledging?
NexJob.RabbitMQ includes a resilient Outbox Producer with RabbitMQ Publisher Confirms built-in.
Registering the Producer:
builder.Services.AddNexJob()
.AddRabbitMqProducer(options =>
{
options.HostName = builder.Configuration["RabbitMQ:Host"] ?? "localhost";
options.DefaultExchange = "orders.exchange";
options.ConfirmTimeout = TimeSpan.FromSeconds(5);
});
Enqueuing Messages via IScheduler:
Instead of dealing with IConnection or IChannel pools in your controllers or services, inject IScheduler:
[ApiController]
[Route("orders")]
public class OrdersController(IScheduler scheduler) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> CreateOrder([FromBody] CreateOrderRequest request, CancellationToken ct)
{
var orderEvent = new OrderCreatedEvent(Guid.NewGuid(), request.Amount);
// Enqueues as a reliable background job.
// Persisted to database first, then delivered to RabbitMQ with Publisher Confirms.
await scheduler.EnqueueRabbitMqAsync(
exchange: "orders.exchange",
routingKey: "order.created",
value: orderEvent,
correlationId: orderEvent.OrderId.ToString(),
cancellationToken: ct);
return Accepted(new { orderEvent.OrderId });
}
}
How this works:
-
scheduler.EnqueueRabbitMqAsyncsaves a small job record in your database. - NexJob's background dispatcher picks it up, opens a channel with
ConfirmSelect()enabled, and publishes the message. - The job succeeds only after RabbitMQ responds with an ACK (confirming it saved the message).
- If RabbitMQ responds with a NACK or times out, the job fails, backs off, and retries automatically. You never drop messages due to broker blips.
5. Monitoring in the Dashboard
If you use NexJob.Dashboard or NexJob.Dashboard.Standalone, you don't need to guess what is happening:
- You can see incoming trigger jobs, their retry counts, and execution latency.
- The W3C
traceparentandMessageIdare visible and copyable right from the job details page. - Outbox publishing jobs show up with their delivery attempts and publisher confirm status.
Summary
Handling messaging in production is rarely about the protocol; it's about handling partial failures cleanly.
By using NexJob as the intermediary:
- RabbitMQ handles queue distribution and routing.
- NexJob handles persistence, retries, dead-lettering, and distributed tracing.
- Your application code stays clean: just an
IJob<string>implementation and normalISchedulercalls.
If you want to check out the project or see more samples, here is the repo:
👉 GitHub: oluciano/NexJob
Have you hit issues with long-running consumers in RabbitMQ before? How do you currently decouple queue consumption from job execution in your .NET services?
Top comments (0)