How to resubmit dead-lettered messages in Azure Service Bus without losing any

By Factodus · updated

A dead-letter queue (DLQ) is where Azure Service Bus parks messages that couldn’t be delivered or processed. Once you have fixed what made them fail, you usually want them processed after all. Service Bus has no “move back” command, so every way of resubmitting comes down to the same two steps: send a copy to the queue or topic, then remove the original from the DLQ. How you order and check those two steps decides whether you can lose messages or only duplicate them.

Short version. Fix the cause first, or the messages will come straight back. In the portal, Service Bus Explorer’s Re-send selected messages sends copies but leaves the originals in the DLQ. In code, receive from the DLQ in peek-lock mode, send a copy, and complete the original only after the send succeeded. That never loses a message, but a crash between the two steps can resend one, so consumers should be idempotent. Watch out for duplicate detection, which can silently drop the copy, and for subscriptions, where a resend to the topic reaches every subscription.

1. Fix the cause before you resubmit

Read the dead-letter reason first (see how to peek a dead-letter queue). Resubmitting a message that failed with MaxDeliveryCountExceeded before the consumer bug is fixed just runs it through another full round of failed deliveries and back into the DLQ. Messages dead-lettered with TTLExpiredException expire again unless consumers now keep up. Session ID is null messages are dead-lettered again by a session-enabled queue until someone adds the session ID.

2. The portal: Re-send selected messages

In the Azure portal, open the queue or subscription, then Service Bus Explorer → Peek Mode (or Receive Mode) → DeadLetter, select messages and press Re-send selected messages. You can edit the body and properties of each message before sending. It is the approach Microsoft suggests, and for a handful of messages it is the fastest. Keep two things in mind:

  • The original stays in the DLQ. Re-send sends a copy and deletes nothing. To remove the originals, receive them in peek-lock mode and Complete them. A purge is not a shortcut here: it removes everything, including messages you haven’t resent yet.
  • From a subscription’s DLQ the copy goes to the topic, so every subscription whose filter matches receives it, not only the one it failed in.

3. In code: send first, complete second

The safe loop receives dead letters in peek-lock mode, which locks them without removing them. It sends each copy and completes the original only after the send returned. If anything fails in between, the original is still in the DLQ.

using Azure.Identity;
using Azure.Messaging.ServiceBus;

await using var client = new ServiceBusClient(
    "<namespace>.servicebus.windows.net", new DefaultAzureCredential());

await using ServiceBusReceiver dlq = client.CreateReceiver(
    "orders", new ServiceBusReceiverOptions { SubQueue = SubQueue.DeadLetter });
await using ServiceBusSender target = client.CreateSender("orders");

int resubmitted = 0;
while (true)
{
    IReadOnlyList<ServiceBusReceivedMessage> batch =
        await dlq.ReceiveMessagesAsync(maxMessages: 50, maxWaitTime: TimeSpan.FromSeconds(5));
    if (batch.Count == 0) break;

    foreach (ServiceBusReceivedMessage dead in batch)
    {
        var copy = new ServiceBusMessage(dead);
        copy.ApplicationProperties["OriginalDeadLetterReason"] = dead.DeadLetterReason;

        await target.SendMessageAsync(copy);
        await dlq.CompleteMessageAsync(dead); // only after the send succeeded
        resubmitted++;
    }
}
Console.WriteLine($"Resubmitted {resubmitted} messages");

What the copy carries: new ServiceBusMessage(receivedMessage) keeps the body, MessageId, SessionId, CorrelationId, Subject, ContentType, time to live and application properties. It drops what the broker owns: the sequence number, enqueued time, scheduled enqueue time, delivery count, and the DeadLetterReason and DeadLetterErrorDescription properties. That is why the sample copies the reason into a property of its own.

Why this order: if the process dies after the send and before the complete, the original stays in the DLQ and the next run sends it again. You get a duplicate, never a loss. The reverse order (complete first, then send) loses the message whenever the send fails. The same applies to locks: 50 sends take well under the default one-minute lock, but if a lock expires anyway, the complete throws, the original remains and the next run resends it.

4. Duplicate detection can swallow the copy

If the target queue or topic has duplicate detection enabled, Service Bus drops any message whose MessageId it has already seen within the detection window, which can be up to seven days. The copy keeps the original MessageId. A message that is resubmitted soon after it was first sent disappears without an error. If that matters, give the copy a new MessageId and keep the old one in a property:

copy.ApplicationProperties["OriginalMessageId"] = dead.MessageId;
copy.MessageId = Guid.NewGuid().ToString();

This is a trade-off. Consumers that deduplicate by MessageId will then treat the copy as a new message.

5. Order and sessions

A resubmitted message gets a new sequence number, so it is processed after everything that arrived while it sat in the DLQ. In a session-enabled queue the copy keeps its SessionId, which it needs because such a queue dead-letters messages without one, and it lands at the end of that session. If your consumers rely on the order within a session, resubmitting an old event behind newer ones can undo state. Check such messages by hand.

6. Subscriptions: you can’t send to one subscription

Messages are sent to topics, never to subscriptions. A dead letter from subscription invoices resubmitted to its topic reaches every subscription whose filter matches it, including ones that processed it the first time. The options:

  • Make every consumer of the topic idempotent, so the extra copies do nothing. This is the cleanest fix.
  • Resubmit into a dedicated queue that only the failing consumer reads.
  • Add a property such as ReplayFor = 'invoices' and have the other subscriptions’ filters exclude it. This works but touches every subscription.

From VS Code

Queue Studio for Service Bus (our extension) runs this whole procedure as one command. In Pro, Move All Back on a dead-letter queue sends the messages to the queue they came from, to the topic for a subscription, or to any queue or topic you pick. Each batch is sent first and removed from the DLQ only after the send succeeded. Progress is shown and the move can be cancelled. The guarantee is the same as the loop above: nothing is lost, and an interrupted move can leave a duplicate.