Azure Queue Storage: Quarantine Poison Messages with PopReceipt and DequeueCount
Azure Queue Storage has no built-in dead-letter queue. Use DequeueCount as a retry budget and PopReceipt as a delete ticket to quarantine poison messages safely.
14 Feb 2026, 07:00 UTC

The crash that doesn't go away
A worker dequeues a message from Azure Queue Storage, starts processing it, and hits an unhandled exception. The process dies. Thirty seconds later the message is visible again. Another worker picks it up, hits the same bad payload, and dies. Without intervention, a single malformed message can stall your pipeline indefinitely.
Azure Queue Storage is at-least-once: it guarantees a message will be delivered, but not that it will be processed successfully exactly once. The platform gives you the primitives to handle this—PopReceipt, visibility timeouts, and DequeueCount—but it does not provide a built-in dead-letter queue. You have to build the quarantine path yourself.
The practical takeaway: treat DequeueCount as your retry budget and PopReceipt as your delete ticket. When the budget runs out, move the payload to an application-level poison queue and delete the original.
PopReceipt is a single-use ticket
When you receive a message, the response includes the payload and a PopReceipt. Think of the receipt as a single-use ticket tied to that specific receive operation. Deleting the message requires the exact receipt that was issued. If you try to delete with an old or wrong receipt, the operation fails.
The receipt also changes when you update the message. Calling UpdateMessageAsync to extend the visibility timeout or modify the content returns a new PopReceipt. You must persist that new receipt before your next delete or update. If an update fails, stop processing that message and let it become visible again rather than assuming the extension succeeded—another worker may already own it.
DequeueCount is your retry budget
Every time a message is received, the platform increments DequeueCount. This is your retry counter. A value of 1 or 2 usually means a transient failure worth retrying. A value climbing past your threshold means the payload itself is likely poison.
The decision logic is simple: small DequeueCount values get retried with an extended visibility timeout. High values get moved to an application-level poison queue and deleted from the source queue. Queue Storage will not do this for you automatically.
Worked example: retry, then quarantine
The following pattern uses the current Azure.Storage.Queues SDK. If you are on the legacy Microsoft.Azure.Storage.Queue package, the types and method names differ—verify signatures before copying code.
// Run in your worker process. Requires a QueueClient
// with receive/delete/update permissions.
var message = await queueClient.ReceiveMessageAsync(visibilityTimeout: TimeSpan.FromSeconds(30));
if (message.Value == null) return;
try
{
await ProcessAsync(message.Value.Body);
await queueClient.DeleteMessageAsync(message.Value.MessageId, message.Value.PopReceipt);
}
catch (TransientException)
{
if (message.Value.DequeueCount < 3)
{
// Extend visibility and record the attempt.
await queueClient.UpdateMessageAsync(
message.Value.MessageId,
message.Value.PopReceipt,
message.Value.Body,
visibilityTimeout: TimeSpan.FromSeconds(60));
}
else
{
// Quarantine: move payload to poison queue first.
await poisonQueueClient.SendMessageAsync(message.Value.Body);
await queueClient.DeleteMessageAsync(message.Value.MessageId, message.Value.PopReceipt);
}
}
Notice the ordering: the poison-queue send happens before the source delete. If the worker crashes between those two calls, the message reappears and gets retried—but the poison queue already has a copy. That is acceptable duplication for a quarantine path. The reverse order risks losing the message entirely.
Trade-offs and limitations
Extending visibility improves the odds that a slow worker finishes, but it also delays poison detection and increases duplicate processing. Cap both total retries and total elapsed time, then quarantine.
Queue Storage does not guarantee strict FIFO ordering, so avoid designs that depend on exact message order. The visibility timeout is not an exclusive lock; another worker can receive the same message after it expires. Your handlers must be idempotent because at-least-once delivery means duplicates are expected, not exceptional.
Because the poison queue is application logic, it needs its own monitoring, retention policy, and replay tooling. A poison queue that fills silently is just a slower outage.
Verify the behavior before you rely on it
- Create a test queue and a worker that receives a message, logs
DequeueCountandPopReceipt, then throws before deleting. Confirm the message reappears only after the visibility timeout. - Run a second test where the worker calls
UpdateMessageAsyncbefore the timeout. Verify the message stays invisible and the returnedPopReceiptchanges. - Set a low poison threshold and verify the worker moves the payload to the poison queue and deletes the original only after the threshold is reached.
- Check queue metrics and poison-queue depth during the tests to confirm the retry and quarantine paths are observable in monitoring.
Start with a low threshold in production, watch DequeueCount distributions for a week, and tune from there. The goal is not zero retries—it is bounded retries with a visible exit ramp.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.