Amazon SQS #
Amazon Simple Queue Service (SQS) is a fully managed message queue service from AWS. There are no servers to manage, no clusters to configure — you just create a queue, send messages, and consume them. SQS is great for decoupling components in an AWS cloud architecture: Lambda triggered by SQS, ECS tasks processing from a queue, or microservices communicating without direct coupling. In Rust, the official AWS SDK (aws-sdk-sqs) provides complete async access. This article covers both queue types (Standard and FIFO), complete operations, and common architectural patterns in the AWS ecosystem.
The Two SQS Queue Types #
flowchart LR
subgraph Standard Queue
S["Standard\nAt-least-once delivery\nOrder not guaranteed\nUnlimited throughput\nCheaper"]
end
subgraph FIFO Queue
F["FIFO\nExactly-once delivery\nOrder guaranteed per group\nMax 3000 msg/sec\nMore expensive"]
end| Aspect | Standard Queue | FIFO Queue |
|---|---|---|
| Order | Best-effort | Strict per Message Group ID |
| Delivery | At-least-once (can duplicate) | Exactly-once |
| Throughput | Unlimited | 3,000 msg/sec (high throughput: 70,000) |
| Price | Cheaper | ~10x more expensive |
| Queue name | Any | Must end in .fifo |
| When | Default for most cases | When order and deduplication matter |
Installation #
[dependencies]
aws-config = { version = "1", features = ["behavior-version-latest"] }
aws-sdk-sqs = "1"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Client Setup #
use aws_sdk_sqs::Client;
async fn buat_client() -> Client {
// Reads configuration from the environment:
// AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION
// or from ~/.aws/credentials when local
let config = aws_config::load_from_env().await;
Client::new(&config)
}
// For local development with LocalStack
async fn buat_client_lokal() -> Client {
use aws_sdk_sqs::config::Builder;
use aws_types::region::Region;
let config = Builder::new()
.endpoint_url("http://localhost:4566") // LocalStack endpoint
.region(Region::new("us-east-1"))
.credentials_provider(aws_credential_types::provider::SharedCredentialsProvider::new(
aws_credential_types::Credentials::new(
"test", "test", None, None, "test"
)
))
.build();
Client::from_conf(config)
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = buat_client().await;
// List existing queues
let hasil = client.list_queues().send().await?;
println!("Existing queues:");
for url in hasil.queue_urls() {
println!(" - {}", url);
}
Ok(())
}
Queue Management #
use aws_sdk_sqs::{Client, types::QueueAttributeName};
use std::collections::HashMap;
async fn buat_standard_queue(
client: &Client,
nama: &str,
) -> Result<String, aws_sdk_sqs::Error> {
let mut atribut = HashMap::new();
// Visibility timeout: a message is invisible for N seconds after being received
atribut.insert(QueueAttributeName::VisibilityTimeout, "30".to_string());
// Message retention: how long messages are stored (default 4 days)
atribut.insert(QueueAttributeName::MessageRetentionPeriod, "86400".to_string()); // 1 day
// Long polling: wait up to N seconds for messages (cost saving)
atribut.insert(QueueAttributeName::ReceiveMessageWaitTimeSeconds, "20".to_string());
let hasil = client
.create_queue()
.queue_name(nama)
.set_attributes(Some(atribut))
.send()
.await?;
let url = hasil.queue_url().unwrap_or("").to_string();
println!("Standard queue created: {}", url);
Ok(url)
}
async fn buat_fifo_queue(
client: &Client,
nama: &str, // must end in .fifo
) -> Result<String, aws_sdk_sqs::Error> {
let mut atribut = HashMap::new();
atribut.insert(QueueAttributeName::FifoQueue, "true".to_string());
// Content-based deduplication: hash the message body as the dedup ID
atribut.insert(QueueAttributeName::ContentBasedDeduplication, "true".to_string());
atribut.insert(QueueAttributeName::VisibilityTimeout, "60".to_string());
let hasil = client
.create_queue()
.queue_name(nama) // e.g. "pesanan-prioritas.fifo"
.set_attributes(Some(atribut))
.send()
.await?;
let url = hasil.queue_url().unwrap_or("").to_string();
println!("FIFO queue created: {}", url);
Ok(url)
}
async fn buat_queue_dengan_dlq(
client: &Client,
nama_utama: &str,
maks_receive: i32,
) -> Result<String, Box<dyn std::error::Error>> {
// 1. Create the DLQ first
let dlq = client
.create_queue()
.queue_name(&format!("{}-dlq", nama_utama))
.send()
.await?;
let dlq_url = dlq.queue_url().unwrap_or("").to_string();
// 2. Get the DLQ ARN
let dlq_attrs = client
.get_queue_attributes()
.queue_url(&dlq_url)
.attribute_names(QueueAttributeName::QueueArn)
.send()
.await?;
let dlq_arn = dlq_attrs
.attributes()
.and_then(|a| a.get(&QueueAttributeName::QueueArn))
.cloned()
.unwrap_or_default();
// 3. Create the main queue with a Redrive Policy to the DLQ
let redrive_policy = serde_json::json!({
"deadLetterTargetArn": dlq_arn,
"maxReceiveCount": maks_receive // move to the DLQ after N failures
})
.to_string();
let mut atribut = HashMap::new();
atribut.insert(
QueueAttributeName::RedrivePolicy,
redrive_policy,
);
atribut.insert(QueueAttributeName::VisibilityTimeout, "30".to_string());
let hasil = client
.create_queue()
.queue_name(nama_utama)
.set_attributes(Some(atribut))
.send()
.await?;
let url = hasil.queue_url().unwrap_or("").to_string();
println!("Queue '{}' with DLQ created successfully", nama_utama);
Ok(url)
}
Sending Messages #
use aws_sdk_sqs::{Client, types::MessageAttributeValue};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
struct EventPemrosesan {
pub id: u64,
pub tipe: String,
pub data: serde_json::Value,
pub dibuat_pada: String,
}
async fn kirim_pesan(
client: &Client,
queue_url: &str,
event: &EventPemrosesan,
) -> Result<String, aws_sdk_sqs::Error> {
let body = serde_json::to_string(event).unwrap();
let hasil = client
.send_message()
.queue_url(queue_url)
.message_body(&body)
// Delivery delay: the message is invisible for N seconds
.delay_seconds(0)
// Message attributes: extra metadata that can be filtered
.message_attributes(
"EventType",
MessageAttributeValue::builder()
.data_type("String")
.string_value(&event.tipe)
.build()
.unwrap(),
)
.message_attributes(
"Source",
MessageAttributeValue::builder()
.data_type("String")
.string_value("api-server")
.build()
.unwrap(),
)
.send()
.await?;
let msg_id = hasil.message_id().unwrap_or("").to_string();
println!("Message sent: {}", msg_id);
Ok(msg_id)
}
// Send to a FIFO queue — needs MessageGroupId and MessageDeduplicationId
async fn kirim_ke_fifo(
client: &Client,
queue_url: &str,
event: &EventPemrosesan,
group_id: &str, // all messages in the same group are processed in order
) -> Result<String, aws_sdk_sqs::Error> {
let body = serde_json::to_string(event).unwrap();
let hasil = client
.send_message()
.queue_url(queue_url)
.message_body(&body)
.message_group_id(group_id)
// Deduplication ID: messages with the same ID within 5 minutes are ignored
.message_deduplication_id(&format!("{}-{}", event.tipe, event.id))
.send()
.await?;
Ok(hasil.message_id().unwrap_or("").to_string())
}
// Batch send — send up to 10 messages at once (cost saving)
async fn kirim_batch(
client: &Client,
queue_url: &str,
events: &[EventPemrosesan],
) -> Result<usize, Box<dyn std::error::Error>> {
use aws_sdk_sqs::types::SendMessageBatchRequestEntry;
let entries: Vec<SendMessageBatchRequestEntry> = events
.iter()
.enumerate()
.map(|(i, event)| {
SendMessageBatchRequestEntry::builder()
.id(i.to_string())
.message_body(serde_json::to_string(event).unwrap())
.build()
.unwrap()
})
.collect();
let hasil = client
.send_message_batch()
.queue_url(queue_url)
.set_entries(Some(entries))
.send()
.await?;
let berhasil = hasil.successful().len();
let gagal = hasil.failed().len();
if gagal > 0 {
for f in hasil.failed() {
eprintln!("Failed to send entry {}: {}", f.id(), f.message().unwrap_or("?"));
}
}
println!("Batch: {}/{} succeeded", berhasil, events.len());
Ok(berhasil)
}
Receiving and Processing Messages #
use aws_sdk_sqs::{Client, types::Message};
async fn terima_dan_proses(
client: &Client,
queue_url: &str,
) -> Result<(), Box<dyn std::error::Error>> {
loop {
// Long polling: wait up to 20 seconds if the queue is empty
// More cost-effective than constant short polling
let hasil = client
.receive_message()
.queue_url(queue_url)
.max_number_of_messages(10) // max 10 messages per request
.wait_time_seconds(20) // long polling
.visibility_timeout(30) // invisible to other consumers for 30s
.message_attribute_names("All") // get all message attributes
.send()
.await?;
let pesan_list = hasil.messages();
if pesan_list.is_empty() {
println!("Queue empty, waiting...");
continue;
}
for pesan in pesan_list {
match proses_satu_pesan(client, queue_url, pesan).await {
Ok(_) => println!("Message processed successfully"),
Err(e) => eprintln!("Error processing message: {}", e),
}
}
}
}
async fn proses_satu_pesan(
client: &Client,
queue_url: &str,
pesan: &Message,
) -> Result<(), Box<dyn std::error::Error>> {
let receipt_handle = pesan.receipt_handle().unwrap_or("");
let body = pesan.body().unwrap_or("{}");
println!("Message ID: {}", pesan.message_id().unwrap_or("?"));
// Read message attributes
if let Some(attrs) = pesan.message_attributes() {
if let Some(event_type) = attrs.get("EventType") {
println!("EventType: {}", event_type.string_value().unwrap_or("?"));
}
}
// Parse the body
let event: EventPemrosesan = serde_json::from_str(body)?;
println!("Processing event: {} #{}", event.tipe, event.id);
// Process the event...
jalankan_logika_bisnis(&event).await?;
// Delete the message after successful processing
// IMPORTANT: if not deleted, the message reappears after the visibility timeout
client
.delete_message()
.queue_url(queue_url)
.receipt_handle(receipt_handle)
.send()
.await?;
println!("Message deleted from the queue");
Ok(())
}
async fn jalankan_logika_bisnis(event: &EventPemrosesan) -> Result<(), String> {
println!("Running logic for: {}", event.tipe);
Ok(())
}
// Batch delete — saves API calls when successfully processing many messages
async fn hapus_batch(
client: &Client,
queue_url: &str,
pesan_list: &[Message],
) -> Result<(), aws_sdk_sqs::Error> {
use aws_sdk_sqs::types::DeleteMessageBatchRequestEntry;
let entries: Vec<DeleteMessageBatchRequestEntry> = pesan_list
.iter()
.enumerate()
.filter_map(|(i, m)| {
m.receipt_handle().map(|rh| {
DeleteMessageBatchRequestEntry::builder()
.id(i.to_string())
.receipt_handle(rh)
.build()
.unwrap()
})
})
.collect();
client
.delete_message_batch()
.queue_url(queue_url)
.set_entries(Some(entries))
.send()
.await?;
println!("{} messages deleted", pesan_list.len());
Ok(())
}
Visibility Timeout and Extensions #
use aws_sdk_sqs::Client;
// Extend the visibility timeout for messages that need longer processing
async fn perpanjang_visibility(
client: &Client,
queue_url: &str,
receipt_handle: &str,
tambahan_detik: i32,
) -> Result<(), aws_sdk_sqs::Error> {
client
.change_message_visibility()
.queue_url(queue_url)
.receipt_handle(receipt_handle)
.visibility_timeout(tambahan_detik)
.send()
.await?;
println!("Visibility timeout extended by {} seconds", tambahan_detik);
Ok(())
}
// A worker that processes long messages and extends visibility periodically
async fn proses_pesan_panjang(
client: std::sync::Arc<Client>,
queue_url: String,
pesan: Message,
) {
let receipt_handle = pesan.receipt_handle().unwrap_or("").to_string();
// Spawn a task to extend visibility every 25 seconds (before the 30s timeout)
let client_clone = std::sync::Arc::clone(&client);
let queue_clone = queue_url.clone();
let handle_clone = receipt_handle.clone();
let keepalive = tokio::spawn(async move {
loop {
tokio::time::sleep(std::time::Duration::from_secs(25)).await;
let _ = perpanjang_visibility(&client_clone, &queue_clone, &handle_clone, 30).await;
}
});
// Process the long-running message
tokio::time::sleep(std::time::Duration::from_secs(60)).await; // simulate 60 seconds
println!("Message processed");
// Stop the keepalive
keepalive.abort();
// Delete the message
let _ = client
.delete_message()
.queue_url(&queue_url)
.receipt_handle(&receipt_handle)
.send()
.await;
}
SQS with SNS Fan-Out #
A common AWS pattern: SNS receives an event and forwards it to many SQS queues at once:
flowchart LR
P["Producer"] --> SNS["SNS Topic\npesanan-events"]
SNS --> Q1["SQS Queue\npemrosesan-pesanan\n(ECS task)"]
SNS --> Q2["SQS Queue\nnotifikasi-email\n(Lambda)"]
SNS --> Q3["SQS Queue\nupdate-inventori\n(ECS task)"]
SNS --> Q4["SQS Queue\nanalytics\n(Kinesis Firehose)"]Configuration in Terraform/CDK (not Rust code, but important to understand):
# Terraform: SNS topic + SQS queue + subscription
resource "aws_sns_topic" "pesanan_events" {
name = "pesanan-events"
}
resource "aws_sqs_queue" "pemrosesan" {
name = "pemrosesan-pesanan"
visibility_timeout_seconds = 30
message_retention_seconds = 86400
redrive_policy = jsonencode({
deadLetterTargetArn = aws_sqs_queue.pemrosesan_dlq.arn
maxReceiveCount = 3
})
}
resource "aws_sns_topic_subscription" "pemrosesan_sub" {
topic_arn = aws_sns_topic.pesanan_events.arn
protocol = "sqs"
endpoint = aws_sqs_queue.pemrosesan.arn
}
Messages from SNS have a wrapper in SQS — they need to be unwrapped:
#[derive(Debug, serde::Deserialize)]
struct SnsSqsWrapper {
#[serde(rename = "Type")]
tipe: String,
#[serde(rename = "Message")]
pesan: String, // JSON string from SNS
#[serde(rename = "MessageId")]
message_id: String,
#[serde(rename = "TopicArn")]
topic_arn: String,
}
async fn proses_pesan_dari_sns(body: &str) -> Result<(), Box<dyn std::error::Error>> {
// Messages from SNS have a wrapper
if let Ok(wrapper) = serde_json::from_str::<SnsSqsWrapper>(body) {
if wrapper.tipe == "Notification" {
// Unwrap: the real message is in the "Message" field
let event: EventPemrosesan = serde_json::from_str(&wrapper.pesan)?;
println!("Event from SNS via SQS: {} #{}", event.tipe, event.id);
}
} else {
// Direct message (not from SNS)
let event: EventPemrosesan = serde_json::from_str(body)?;
println!("Direct event: {} #{}", event.tipe, event.id);
}
Ok(())
}
SQS, RabbitMQ, and Kafka Comparison #
Aspect Amazon SQS RabbitMQ Kafka
Infrastructure management
Fully managed Yes No No
Setup Minutes Hours Days
Scaling Automatic Manual Semi-manual
Delivery guarantee
At-least-once Standard queue With ack Yes
Exactly-once FIFO queue Not native With transactions
At-most-once No With auto-ack No
Replay / history
Can re-read No No Yes (offset)
Retention Max 14 days Until consumed Configurable
Throughput
Max Unlimited ~100k/sec/node Millions/sec
Pricing model
Per request Yes ($0.40/1M msg) Self-hosted Self-hosted / Confluent
When SQS is the best fit:
✓ Already in the AWS ecosystem (Lambda, ECS, EC2)
✓ Don't want to manage message broker infrastructure
✓ Simple needs: send a message, process it, delete it
✓ Automatic scaling without configuration
✓ Direct integration with SNS, Lambda, S3 Events
Summary #
- Standard vs FIFO — Standard for most cases (high throughput, at-least-once); FIFO when order and exactly-once matter but throughput is lower.
- Long polling with
wait_time_seconds(20)— far more cost-effective than short polling; waits up to 20 seconds before returning if the queue is empty.- Delete messages after successful processing — if not deleted, the message reappears after the visibility timeout. This is SQS’s built-in “at-least-once delivery” feature.
- Visibility timeout — the time a message is “invisible” to other consumers after being received. Set it longer than the expected maximum processing time.
- Extend the visibility timeout for long processes — spawn a background task calling
change_message_visibilityevery N seconds so messages don’t reappear mid-processing.- Batch operations save costs —
send_message_batchanddelete_message_batchsend/delete up to 10 messages per API call, reducing cost up to 10x.- Dead Letter Queues via Redrive Policy — messages that fail N times are automatically moved to the DLQ. Set
maxReceiveCountto a reasonable retry count.- SNS + SQS fan-out pattern — one event can be forwarded to many different queues via SNS subscriptions. SNS messages have a JSON wrapper — remember to unwrap the
Messagefield.- FIFO requires
MessageGroupId— all messages in the same group are processed in order. Use an entity ID (user ID, order ID) as the group ID.