Amazon SQS With .NET: A Practical Guide

distributed-systemsdotnet
Amazon SQS With .NET: A Practical Guide

Amazon SQS is a managed queue service that .NET applications consume by polling, processing, and explicitly deleting messages. Use long polling, a visibility timeout that covers processing, and a dead letter policy for repeated failures. FIFO queues add per-group ordering and send deduplication, but consumers must still tolerate redelivery.

What Is Amazon SQS?

Amazon Simple Queue Service (SQS) buffers work between producers and consumers without a broker cluster to operate. Standard and FIFO queues have different ordering and throughput characteristics.

SQS is a natural fit on AWS when eliminating broker operations matters. Unlike self-hosted RabbitMQ, there is no cluster, disk, or broker upgrade to manage. The trade-off is a simpler routing model with no exchanges or routing keys.

SQS comes in two flavors: Standard (best-effort ordering, at-least-once delivery) and FIFO (ordering within message groups and a send-deduplication window).

Setting Up the SDK

Create a .NET worker with the AWS SDK v4:

dotnet new console -n SqsDemo --framework net10.0
cd SqsDemo
dotnet add package AWSSDK.SQS --version 4.0.100.17
dotnet add package Microsoft.Extensions.Hosting --version 10.0.0

Use an existing Standard queue for this first example, configure SQS:OrdersQueueUrl, and supply AWS credentials through the SDK's credential chain (for example, a local profile or workload role). The identity needs permission to receive, delete, and send messages on that queue. Put the following in Program.cs; add the publisher and consumer classes from the following sections in separate files with the same imports:

using Amazon;
using Amazon.SQS;
using Amazon.SQS.Model;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.Text.Json;

var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddSingleton<IAmazonSQS>(_ => new AmazonSQSClient(new AmazonSQSConfig
{
    RegionEndpoint = RegionEndpoint.USEast1
}));
builder.Services.AddSingleton<OrderPublisher>();
builder.Services.AddScoped<IOrderHandler, LoggingOrderHandler>();
builder.Services.AddHostedService<OrderConsumerService>();
using var host = builder.Build();
await host.RunAsync();

public sealed record OrderCreated(Guid OrderId, Guid CustomerId);

public interface IOrderHandler
{
    Task HandleAsync(OrderCreated order, CancellationToken ct);
}

public sealed class LoggingOrderHandler(ILogger<LoggingOrderHandler> logger) : IOrderHandler
{
    public Task HandleAsync(OrderCreated order, CancellationToken ct)
    {
        logger.LogInformation("Order received: {OrderId}", order.OrderId);
        return Task.CompletedTask;
    }
}

The handler logs only; replace it with idempotent business work before using it in production. The AWS client is thread-safe and should be reused across calls. For an emulator, explicitly replace its endpoint and credentials in local configuration; do not commit production access keys.

Sending Messages

public class OrderPublisher
{
    private readonly IAmazonSQS _sqs;
    private readonly string _queueUrl;

    public OrderPublisher(IAmazonSQS sqs, IConfiguration configuration)
    {
        _sqs = sqs;
        _queueUrl = configuration["SQS:OrdersQueueUrl"]
            ?? throw new InvalidOperationException("Missing SQS:OrdersQueueUrl.");
    }

    public async Task SendOrderAsync(OrderCreated order, CancellationToken ct = default)
    {
        var message = new SendMessageRequest
        {
            QueueUrl = _queueUrl,
            MessageBody = JsonSerializer.Serialize(order),
            MessageAttributes = new Dictionary<string, MessageAttributeValue>
            {
                ["EventType"] = new()
                {
                    DataType = "String",
                    StringValue = "OrderCreated"
                },
                ["Version"] = new()
                {
                    DataType = "String",
                    StringValue = "1.0"
                }
            }
        };

        await _sqs.SendMessageAsync(message, ct);
    }
}

Receiving and Processing Messages

SQS uses a pull model, so your consumer polls for messages:

public class OrderConsumerService : BackgroundService
{
    private readonly IAmazonSQS _sqs;
    private readonly string _queueUrl;
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<OrderConsumerService> _logger;

    public OrderConsumerService(
        IAmazonSQS sqs,
        IConfiguration configuration,
        IServiceScopeFactory scopeFactory,
        ILogger<OrderConsumerService> logger)
    {
        _sqs = sqs;
        _queueUrl = configuration["SQS:OrdersQueueUrl"]
            ?? throw new InvalidOperationException("Missing SQS:OrdersQueueUrl.");
        _scopeFactory = scopeFactory;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var response = await _sqs.ReceiveMessageAsync(
                new ReceiveMessageRequest
                {
                    QueueUrl = _queueUrl,
                    MaxNumberOfMessages = 1, // Keep this sequential example within its visibility budget
                    VisibilityTimeout = 120,
                    WaitTimeSeconds = 20, // Long polling
                    MessageAttributeNames = ["All"],
                    MessageSystemAttributeNames = ["All"]
                }, stoppingToken);

            foreach (var message in response.Messages ?? [])
            {
                await ProcessMessageAsync(message, stoppingToken);
            }
        }
    }

    private async Task ProcessMessageAsync(
        Message message, CancellationToken ct)
    {
        await using var scope = _scopeFactory.CreateAsyncScope();
        using var processing = CancellationTokenSource.CreateLinkedTokenSource(ct);
        processing.CancelAfter(TimeSpan.FromSeconds(90));

        try
        {
            var order = JsonSerializer.Deserialize<OrderCreated>(
                message.Body) ?? throw new JsonException("Expected an order object.");

            var handler = scope.ServiceProvider
                .GetRequiredService<IOrderHandler>();
            await handler.HandleAsync(order, processing.Token);
            processing.Token.ThrowIfCancellationRequested();

            // Delete message after successful processing
            await _sqs.DeleteMessageAsync(
                _queueUrl, message.ReceiptHandle, ct);

            _logger.LogInformation(
                "Processed order {OrderId}", order?.OrderId);
        }
        catch (OperationCanceledException) when (ct.IsCancellationRequested)
        {
            throw;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to process message {MessageId}",
                message.MessageId);
            // Message returns to queue after visibility timeout
        }
    }
}
SQS message lifecycle: a message is received, then either deleted on success, returned to the queue when the visibility timeout expires, or moved to a dead letter queue after exceeding maxReceiveCount

AWS SDK v4 returns null collections for some empty responses; the ?? [] handles that case. This sequential worker receives one message at a time with a 120-second visibility timeout and requests cancellation after 90 seconds. That leaves time for deletion when dependencies honor cancellation; it cannot stop a dependency that ignores the token. An unrecoverable polling failure escapes ExecuteAsync, allowing the host to stop rather than leaving an apparently healthy worker that no longer polls.

Key points:

  • Long polling (WaitTimeSeconds = 20): Reduces empty responses and cost
  • Delete after processing: Unlike RabbitMQ's ack, you explicitly delete processed messages
  • Visibility timeout: If you don't delete within the timeout, the message becomes visible again for retry

Because delivery is at-least-once, the same message can arrive twice: after a crash between processing and deletion, or after a visibility timeout expires mid-processing. Make your handlers idempotent; the idempotent consumer pattern covers how.

Batch Operations

A batch accepts at most ten entries and can partially fail even when the HTTP request succeeds. Add this method to OrderPublisher to send bounded batches and surface individual failures:

public async Task SendBatchAsync(IEnumerable<OrderCreated> orders, CancellationToken ct)
{
    foreach (var batch in orders.Chunk(10))
    {
        var response = await _sqs.SendMessageBatchAsync(new SendMessageBatchRequest
        {
            QueueUrl = _queueUrl,
            Entries = batch.Select((order, index) => new SendMessageBatchRequestEntry
            {
                Id = index.ToString(),
                MessageBody = JsonSerializer.Serialize(order)
            }).ToList()
        }, ct);

        if (response.Failed is { Count: > 0 })
        {
            var failures = string.Join(", ", response.Failed.Select(f => $"{f.Id}:{f.Code}"));
            throw new InvalidOperationException($"SQS batch partially failed: {failures}");
        }
    }
}

Persist successful and failed entry IDs if the caller needs automatic recovery. Retry only failed entries where possible; retrying an entire Standard batch can duplicate successful sends. DeleteMessageBatch has the same ten-entry limit and per-entry failure reporting, so only include receipts for successfully processed messages and inspect its Failed collection too.

FIFO Queues

For ordering within a message group and send deduplication, use a FIFO queue. Add this alternative method to OrderPublisher and pass an existing queue URL ending in .fifo:

public async Task SendFifoMessageAsync(
    OrderCreated order, string fifoQueueUrl, CancellationToken ct)
{
    var message = new SendMessageRequest
    {
        QueueUrl = fifoQueueUrl, // Must end with .fifo
        MessageBody = JsonSerializer.Serialize(order),
        MessageGroupId = order.CustomerId.ToString(), // Messages in same group are ordered
        MessageDeduplicationId = order.OrderId.ToString() // Deduplicates sends within the 5-minute window
    };

    await _sqs.SendMessageAsync(message, ct);
}

MessageGroupId partitions messages, and ordering is guaranteed within a group. Different groups process in parallel, similar to Kafka partitions.

FIFO send deduplication is not an atomic transaction with your database. If processing succeeds but deletion fails, the consumer can receive the message again and must handle it idempotently.

Two FIFO constraints:

  • Deduplication only covers a 5-minute window. A duplicate sent 6 minutes later goes through.
  • Throughput is limited per message group. If every message shares one MessageGroupId, you've serialized the whole queue.

Dead Letter Queues

Configure a dead letter queue for messages that fail repeatedly:

static async Task ConfigureDeadLettersAsync(IAmazonSQS sqs, string queueUrl)
{
    // Create DLQ
    var dlqResponse = await sqs.CreateQueueAsync(new CreateQueueRequest
    {
        QueueName = "orders-dlq"
    });

    var dlqArn = (await sqs.GetQueueAttributesAsync(
        dlqResponse.QueueUrl,
        ["QueueArn"])).Attributes["QueueArn"];

    // Configure main queue to use DLQ
    await sqs.SetQueueAttributesAsync(new SetQueueAttributesRequest
    {
        QueueUrl = queueUrl,
        Attributes = new Dictionary<string, string>
        {
            ["RedrivePolicy"] = JsonSerializer.Serialize(new
            {
                deadLetterTargetArn = dlqArn,
                maxReceiveCount = 3 // Redrive after the receive count exceeds the threshold
            })
        }
    });
}

Call this provisioning helper separately with management permissions; the runtime consumer need not have them. This example is for Standard queues. A FIFO source requires a FIFO DLQ.

Visibility Timeout Handling

For work longer than the initial timeout, extend visibility with ChangeMessageVisibilityAsync before the current window expires. Start renewal immediately after receipt, renew comfortably inside the timeout, and observe every renewal failure. A one-minute timer cannot protect a message with the default thirty-second visibility window.

Coordinate the renewal task with processing: cancel and await it before deleting the message, and stop or cancel processing if visibility cannot be renewed. Never start an unobserved Task.Run renewal loop that can outlive deletion. The SQS visibility-timeout documentation explains extension limits and redelivery behavior. For this guide's runnable worker, keep work below its 90-second processing budget or split it into smaller resumable jobs.

Using MassTransit With SQS

MassTransit provides scoped consumers, error queues, and SNS/SQS publish-subscribe topology. Install the MassTransit.AmazonSQS transport at the same version as the other MassTransit packages and follow its SQS configuration guide. Let the transport choose its compatible AWS SDK dependencies instead of mixing SDK major versions into the raw-client example above.

Use the workload's AWS credential chain instead of hard-coding access keys, and configure message retry middleware explicitly. My SQS and SNS guide with MassTransit explains the broker topology.

Summary

Use long polling and batch operations to reduce empty requests and API cost. Delete a message only after successful processing, configure a DLQ for repeated failures, and keep consumers idempotent because Standard queues deliver at least once. Choose FIFO only when per-group ordering and deduplication are worth its throughput constraints.

Frequently Asked Questions

What is the difference between SQS Standard and FIFO queues?

Standard queues offer nearly unlimited throughput with at-least-once delivery and best-effort ordering. FIFO queues guarantee ordering within a message group and deduplicate messages within a 5-minute window, at the cost of a throughput cap per message group.

How does SQS know a message was processed successfully?

It does not, until you tell it. You must explicitly call DeleteMessage after processing. If you do not delete within the visibility timeout, the message reappears on the queue and gets redelivered.

What is the SQS visibility timeout?

When a consumer receives a message, SQS hides it from other consumers for the visibility timeout (default 30 seconds). Finish and delete within that window, or extend it with ChangeMessageVisibility for long-running work.

Does Amazon SQS support publish-subscribe?

Not by itself; SQS is point-to-point. The standard pattern is SNS-to-SQS fan-out: publish to an SNS topic and subscribe multiple SQS queues to it. Libraries like MassTransit set this topology up for you.

Should I use the raw AWS SDK or MassTransit for SQS in .NET?

The raw SDK is fine for a single queue and simple handlers. MassTransit adds consumers, retries, error queues, serialization, and SNS topology on top, which pays off quickly as the number of message types grows.

  • Azure Service Bus Topics, Subscription Filters, and Dead Letter Queues in .NET

    Go beyond basic queues in Azure Service Bus: publish to topics, route messages with SQL and correlation filters on the broker, and build a dead letter queue handling strategy in .NET.

  • Backpressure in .NET: Handling Producers That Outrun Consumers

    Backpressure slows producers when consumers cannot keep up. Use bounded .NET channels to wait for capacity, drop replaceable updates, or reject excess requests with 503. The examples distinguish admission to memory from durable processing and show how to size a queue for a burst.

  • The CAP Theorem for Working Developers

    During a network partition, distributed systems cannot guarantee both linearizable operations and availability to every non-failed node. Learn how that limit affects PostgreSQL replication, read routing, and the guarantees your .NET application needs for each operation.

Browse all Distributed Systems guides →
Loading comments...

Whenever you're ready, there are 5 ways I can help you:

  1. Pragmatic Self-Hosting: Learn to deploy and run your apps on a VPS with Dokploy and Tailscale. Keep administration private, automate releases, and practice restoring backups, with source code and a runbook you can reuse.
  2. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  3. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  4. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  5. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

One practical .NET tip, every Saturday.

Join 66,000+ engineers who read it. Free, and you can unsubscribe any time.