Your .NET BackgroundService is running. The pod is healthy. CPU and memory look normal. There are no obvious errors.
But the queue keeps growing. So where is the problem?
In this article, we’ll follow that problem using a simple (DLCP) approach.
Detect → Locate → Correct → Prevent
Detect: identify that the system is falling behind.
Locate: find where processing capacity is being lost.
Correct: fix the bottleneck and protect the worker from slow dependencies.
Prevent: add the right limits, metrics, and safeguards so the same failure is caught before it becomes an incident.
We won’t start with the code and guess what went wrong. We’ll start with the symptom and work backwards through the system.
At 10:15 AM, everything looks normal.
The health endpoint returns 200. Then someone notices the queue.
Queue depth: 2,400Thirty minutes later:
Queue depth: 11,800Another fifteen minutes:
Queue depth: 52,381The BackgroundService is still running. So why aren’t the messages being processed?
This is where these incidents get interesting.
A background worker can be perfectly alive as a process and still be completely useless as a message processor.
Start with four questions: Detect. Locate. Correct. Prevent.
That’s the path we’ll follow.
Detect
The first mistake is looking at the wrong signal. For a normal API, we might start with:
HTTP 5xx
CPU
Memory
Pod status
Request latencyThose are useful. But a message processor has another metric that matters more:
Is the queue actually moving? Suppose we see:
Queue depth: 52,381
Messages processed/sec: 18
Oldest message age: 24 minutes
Worker pods: 4
Pod status: Running
CPU: 12%
Memory: 41%Now the problem is obvious. The application is alive. The system isn’t making enough progress. That’s an important distinction. A process can be healthy while the business operation it performs is unhealthy.
For a background worker, I want to know at least:
Queue depth, Oldest message age, Messages processed per second, Processing duration, In-flight messages, Failure rate, Retry count, Last successful processing time
The queue depth tells us there is a problem.
The oldest message tells us how long that problem has existed.
Processing rate tells us whether we’re catching up or falling further behind.
Those three metrics alone can tell a very different story.
Locate
Now we know the worker isn’t keeping up. The next question is:
Where is the time going? Let’s start with the architecture.

The worker itself is simple. A simplified implementation might look like this:
public sealed class OrderWorker(
IMessageQueue queue,
IOrderProcessor processor) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var message = await queue.ReceiveAsync(stoppingToken);
await processor.ProcessAsync(message, stoppingToken);
}
}
}The processor does two things:
public async Task ProcessAsync(
OrderMessage message,
CancellationToken cancellationToken)
{
await _paymentClient.ChargeAsync(message.OrderId, cancellationToken);
await _orderRepository.MarkAsPaidAsync(message.OrderId, cancellationToken);
}Nothing obviously wrong. So we follow the request.
The payment call normally takes:
200 msDuring the incident:
30 sec
45 sec
60 sec
90 secNow we have something. The worker isn’t spending its time processing orders. It’s spending its time waiting for the payment service.
Locate the Bottleneck
This is where concurrency matters. Concurrency simply means doing multiple operations at the same time instead of waiting for one to finish before starting the next one.
Suppose the worker has 10 messages in flight, meaning it can process up to 10 messages at the same time.
On a normal day, the payment API responds in around 200 ms.
If all 10 slots process messages concurrently:
Concurrency: 10
Processing time: 200 msAll 10 messages take roughly 200 ms to complete:
10 messages
───────────── = 50 messages/second
200 msSo the worker can theoretically process around 50 messages per second, assuming the queue, database, and other dependencies can keep up.
If those 10 messages were processed sequentially, the calculation would be different:
10 messages × 200 ms = 2,000 ms = 2 secondsThat would give us:
10 messages ÷ 2 seconds = 5 messages/secondThat’s why concurrency matters. We’re not waiting for one message to finish before starting the next one.
Then something changes.
The payment service starts having problems. Requests that normally take around 200 ms now take as long as 60 seconds. Those same 10 concurrent slots now look like this:
Worker
│
├── Order 101 → Payment API → waiting
├── Order 102 → Payment API → waiting
├── Order 103 → Payment API → waiting
├── Order 104 → Payment API → waiting
├── ...
└── Order 110 → Payment API → waitingAll 10 slots are occupied for roughly 60 seconds. The throughput becomes:
Concurrency: 10
Processing time: 60 seconds
10 messages
──────────────
60 seconds
= 0.17 messages/secondSo we went from roughly:
Normal: ~50 messages/sec
Incident: ~0.17 messages/secThat’s a massive drop.
Why Scaling Made It Worse
The obvious response to a growing queue is:
Add more pods. So we go from:
2 pods to 10 podsNow we potentially have:
10 pods × 20 concurrent operations = 200 in-flight requestsThat sounds better. But where are those requests going?
The same payment API.

Suppose the payment service can safely handle 50 concurrent requests. We just sent it 200. Now the payment service gets evem slower. Slower responses keep worker slots occupied longer. More messages accumulate. The queue grows again.

This is an important lesson in distributed systems:
Scaling one component does not necessarily increase the capacity of the system.







Join the conversation! Your thoughts help the community grow.