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
ReportBatchItemFailureson 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, emptyReceiveMessagecalls 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
- AWS SDK for .NET: SQS documentation
- SQS Developer Guide
- Using Lambda with SQS
- SQS pricing
- AWS SQS overview: Standard vs FIFO, dead-letter queues, and when to use SQS vs SNS vs EventBridge
Looking for hands-on help? View my .NET on AWS services β