Home β€Ί .NET on AWS β€Ί Using SQS with .NET

Using SQS with .NET

Message queuing with Amazon SQS from .NET: sending, receiving, Lambda consumers, FIFO queues, and CDK setup.

SQS from .NET

SQS is the simplest messaging primitive on AWS. A queue. You send messages, something else receives them, and once processed they're deleted. No complex routing, no pub/sub, just reliable delivery with automatic retries.

From .NET, you have two consumption patterns: Lambda event source mapping (serverless, auto-scaling) or a long-running consumer using IHostedService (ECS/EC2, pull-based). Both work well, and the choice depends on your architecture.

Sending Messages

Single message

using Amazon.SQS;
using Amazon.SQS.Model;
using System.Text.Json;

var sqsClient = new AmazonSQSClient();

var order = new ProcessOrderCommand
{
    OrderId = "ORD-12345",
    CustomerId = "CUST-789",
    Amount = 149.99m,
};

await sqsClient.SendMessageAsync(new SendMessageRequest
{
    QueueUrl = "https://sqs.us-east-1.amazonaws.com/123456789/order-processing",
    MessageBody = JsonSerializer.Serialize(order, AppJsonContext.Default.ProcessOrderCommand),
    MessageAttributes = new Dictionary<string, MessageAttributeValue>
    {
        ["MessageType"] = new()
        {
            DataType = "String",
            StringValue = nameof(ProcessOrderCommand),
        },
    },
});

Batch send (up to 10 messages)

var entries = orders.Select((order, i) => new SendMessageBatchRequestEntry
{
    Id = i.ToString(),
    MessageBody = JsonSerializer.Serialize(order, AppJsonContext.Default.ProcessOrderCommand),
}).ToList();

foreach (var batch in entries.Chunk(10))
{
    var response = await sqsClient.SendMessageBatchAsync(new SendMessageBatchRequest
    {
        QueueUrl = queueUrl,
        Entries = batch.ToList(),
    });
    
    if (response.Failed.Count > 0)
    {
        foreach (var failure in response.Failed)
        {
            logger.LogError("Failed to send message {Id}: {Code}", failure.Id, failure.Code);
        }
    }
}

FIFO queue messages

await sqsClient.SendMessageAsync(new SendMessageRequest
{
    QueueUrl = fifoQueueUrl,
    MessageBody = JsonSerializer.Serialize(order, AppJsonContext.Default.ProcessOrderCommand),
    MessageGroupId = order.CustomerId, // messages in same group are processed in order
    MessageDeduplicationId = order.OrderId, // prevents duplicate delivery within 5 min
});

Lambda Consumer

The most common pattern on serverless architectures. Lambda polls SQS automatically and invokes your function with batches:

using Amazon.Lambda.SQSEvents;

public class OrderProcessor
{
    private readonly IOrderService _orderService;
    
    public OrderProcessor(IOrderService orderService)
    {
        _orderService = orderService;
    }

    public async Task<SQSBatchResponse> Handler(SQSEvent sqsEvent, ILambdaContext context)
    {
        var batchItemFailures = new List<SQSBatchResponse.BatchItemFailure>();
        
        foreach (var record in sqsEvent.Records)
        {
            try
            {
                var command = JsonSerializer.Deserialize(
                    record.Body, AppJsonContext.Default.ProcessOrderCommand)!;
                
                await _orderService.ProcessAsync(command);
            }
            catch (Exception ex)
            {
                context.Logger.LogError(ex, "Failed to process message {MessageId}", record.MessageId);
                
                // Report this item as failed β€” it'll retry
                batchItemFailures.Add(new SQSBatchResponse.BatchItemFailure
                {
                    ItemIdentifier = record.MessageId,
                });
            }
        }
        
        return new SQSBatchResponse
        {
            BatchItemFailures = batchItemFailures,
        };
    }
}

Key detail: Return SQSBatchResponse with partial failures so only the failed messages retry. Without this, a single failure causes the entire batch to retry.

Long-Running Consumer (ECS/EC2)

For workloads that need more control. Connection pooling, complex initialization, or steady-state processing:

public class SqsConsumerService : BackgroundService
{
    private readonly IAmazonSQS _sqs;
    private readonly IOrderService _orderService;
    private readonly string _queueUrl;

    public SqsConsumerService(IAmazonSQS sqs, IOrderService orderService, IConfiguration config)
    {
        _sqs = sqs;
        _orderService = orderService;
        _queueUrl = config["SQS:QueueUrl"]!;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var response = await _sqs.ReceiveMessageAsync(new ReceiveMessageRequest
            {
                QueueUrl = _queueUrl,
                MaxNumberOfMessages = 10,
                WaitTimeSeconds = 20, // long polling β€” reduces empty responses
                VisibilityTimeout = 60,
            }, stoppingToken);

            var tasks = response.Messages.Select(async message =>
            {
                try
                {
                    var command = JsonSerializer.Deserialize(
                        message.Body, AppJsonContext.Default.ProcessOrderCommand)!;
                    
                    await _orderService.ProcessAsync(command);
                    
                    await _sqs.DeleteMessageAsync(_queueUrl, message.ReceiptHandle, stoppingToken);
                }
                catch (Exception ex)
                {
                    // Don't delete β€” message will become visible again after visibility timeout
                    Log.Error(ex, "Failed to process {MessageId}", message.MessageId);
                }
            });

            await Task.WhenAll(tasks);
        }
    }
}

Register it:

builder.Services.AddHostedService<SqsConsumerService>();

Standard vs FIFO Queues

Standard FIFO
Throughput Unlimited 300 msg/s (batching: 3000/s)
Ordering Best-effort Strict within message group
Delivery At-least-once Exactly-once
Use case High-volume processing Ordered transactions

Use Standard for most workloads. Design your handlers to be idempotent (processing the same message twice is safe).

Use FIFO when ordering within a group matters (e.g., events for a single customer must be processed in sequence) or when you absolutely cannot process duplicates.

CDK Setup (C#)

using Amazon.CDK;
using Amazon.CDK.AWS.SQS;
using Amazon.CDK.AWS.Lambda;
using Amazon.CDK.AWS.Lambda.EventSources;

// Dead letter queue
var dlq = new Queue(this, "OrderDLQ", new QueueProps
{
    QueueName = "order-processing-dlq",
    RetentionPeriod = Duration.Days(14),
});

// Main queue with DLQ
var queue = new Queue(this, "OrderQueue", new QueueProps
{
    QueueName = "order-processing",
    VisibilityTimeout = Duration.Seconds(60),
    DeadLetterQueue = new DeadLetterQueue
    {
        Queue = dlq,
        MaxReceiveCount = 3, // after 3 failures, move to DLQ
    },
});

// Lambda consumer
var processor = new Function(this, "OrderProcessor", new FunctionProps
{
    Runtime = Runtime.DOTNET_8,
    Handler = "OrderProcessor::OrderProcessor.Function::Handler",
    Code = Code.FromAsset("./src/OrderProcessor/publish"),
    Timeout = Duration.Seconds(60),
});

processor.AddEventSource(new SqsEventSource(queue, new SqsEventSourceProps
{
    BatchSize = 10,
    MaxBatchingWindow = Duration.Seconds(5),
    ReportBatchItemFailures = true, // enables partial batch failure reporting
}));

// Grant producer access
queue.GrantSendMessages(apiFunction);

Dead Letter Queue Handling

Messages that fail repeatedly end up in the DLQ. You need a strategy for handling them:

// Redrive DLQ messages back to the main queue (AWS SDK supports this natively now)
await sqsClient.StartMessageMoveTaskAsync(new StartMessageMoveTaskRequest
{
    SourceArn = dlqArn,
    DestinationArn = mainQueueArn,
    MaxNumberOfMessagesPerSecond = 50,
});

Or process them with a separate handler that does manual investigation/logging.

Tips

  • Always enable ReportBatchItemFailures on Lambda event source mappings. Without it, one bad message fails the whole batch.
  • Set VisibilityTimeout > your processing time. If your Lambda timeout is 60s, set visibility timeout to at least 60s so messages don't become visible again while still being processed.
  • Use long polling (WaitTimeSeconds = 20). Without it, empty ReceiveMessage calls still cost money.
  • Idempotency is not optional. SQS Standard delivers at-least-once. Your handler must handle duplicate messages safely. Use a DynamoDB conditional write or deduplication table.
  • Don't use SQS for pub/sub. If multiple services need the same event, use SNS β†’ SQS fan-out or EventBridge.

Further Reading

Looking for hands-on help? View my .NET on AWS services β†’

Designing message-driven systems?

Drop me a message β€” I typically respond within one business day.