Sending and Receiving Messages
Three API calls do almost all the work in SQS: SendMessage, ReceiveMessage, DeleteMessage.
This module is where the course stops being conceptual.
It also settles the distinction that confuses nearly everyone on their first day:
📌
MessageIdidentifies the message.ReceiptHandleidentifies one receive of it. They are different things, they change at different times, and using the wrong one is a silent bug.
1. What you will learn
- Send, receive and delete a message in both the AWS CLI and Java (SDK v2)
- Explain the difference between
MessageIdandReceiptHandle, and when each changes - Use message attributes correctly, and know why they are not part of the body
- Read queue state with
GetQueueAttributes - Write a minimal polling loop that is actually correct
2. Why there are three calls and not two
The obvious API would be two calls: put a message in, take a message out. SQS has three, and the third is the interesting one.
Module 02 established why deletion is explicit: SQS cannot observe whether your processing succeeded, so it delegates the acknowledgement to the only party that knows. But that raises a question the API has to answer:
When you say "delete it", which thing are you deleting?
Not "the message with this id" — that is not precise enough. Consider: a message is received by worker A, A stalls, the message is redelivered to worker B, B completes and deletes. Then A wakes up and also tries to delete. If deletion referred to the message, A's late delete would be indistinguishable from B's legitimate one — and worse, it might delete a different delivery that is legitimately in progress.
So deletion refers to a specific receive event, identified by a token that SQS issues at receive time. That token is the receipt handle.
Why are there two identifiers instead of one? Because the two questions are different. "Which message is this?" is answered by
MessageIdand the answer never changes. "Which attempt at this message am I acknowledging?" is answered byReceiptHandle, and the answer changes on every receive. Collapsing them into one identifier would make it impossible to distinguish a stale acknowledgement from a current one.
3. The round trip
sequenceDiagram
autonumber
participant P as Producer
participant S as SQS
participant C as Consumer
P->>S: SendMessage(queueUrl, body, attributes)
S-->>P: MessageId = "059f36b4-87a3-…"<br/>MD5OfMessageBody = "e4e68fb7…"
Note over P: Durably stored. Producer is done.
C->>S: ReceiveMessage(queueUrl,<br/>MaxNumberOfMessages=10,<br/>WaitTimeSeconds=20)
S-->>C: [ { messageId: "059f36b4-87a3-…",<br/> receiptHandle: "AQEBwJnKyrHi…",<br/> body: "…",<br/> attributes: { ApproximateReceiveCount: "1" } } ]
Note over S,C: Message is now in flight —<br/>hidden from other consumers,<br/>NOT deleted
C->>C: process(message)
C->>S: DeleteMessage(queueUrl, receiptHandle)
S-->>C: 200 OK
Note over S: Now it is gone.Reading the diagram. Three details are worth pausing on.
- The send response contains an MD5 of the body. You can verify it to detect corruption in transit. Most applications do not, and the SDK checks it for you by default — but it is there.
- The receive response is a list. Even when you ask for one message, the API returns a collection, and it may be empty (Module 03 §8 explains why an empty response does not mean an empty queue).
- The gap between step 5 and step 8 is where everything goes wrong. A crash there means no delete, which means redelivery. That gap is Module 08's entire subject.
4. SendMessage
SendMessageResponse sent = sqs.sendMessage(r -> r
.queueUrl(queueUrl)
.messageBody("""
{"orderId":"4471","amountCents":2999,"currency":"GBP","schemaVersion":1}
"""));
String messageId = sent.messageId(); // stable identity, assigned by SQSCLI:
aws sqs send-message \
--queue-url "$QUEUE_URL" \
--message-body '{"orderId":"4471","amountCents":2999,"currency":"GBP","schemaVersion":1}'The body
The body is a string. JSON, XML or plain text — SQS does not parse it or care. Two constraints worth knowing now:
| Constraint | Value | Source |
|---|---|---|
| Maximum size | 1 MiB (1,048,576 bytes) | aws-facts.md §1 |
| Allowed characters | A restricted Unicode range — binary must be Base64-encoded | aws-facts.md §1 |
⚠️ The 1 MiB figure is widely mis-taught as 256 KB. AWS raised it. If a tutorial or a colleague says 256 KB, they are working from stale information — see misconceptions.md §16.
⚠️ But billing chunks payloads at 64 KB, so a 1 MiB message is billed as 16 requests, not one (Module 18). "It fits" and "it is sensible" are different questions.
Designing the body
Two rules that cost nothing now and save a deployment freeze later.
Rule 1 — carry intent and references, not bulk data.
// ❌ The whole document inline: 800 KB, billed as 13 requests, brittle
{ "document": "<800 KB of base64>" }
// ✅ A reference: 180 bytes, billed as 1 request
{ "bucket": "sqs-course-docs", "key": "invoices/4471.pdf", "schemaVersion": 1 }This is the claim-check pattern (Module 25). The consumer fetches what it needs.
Rule 2 — version the schema from message one.
You cannot migrate messages that are already in the queue. When you deploy a producer that adds a
field, messages in both shapes coexist for as long as the backlog takes to drain — and if there is a
DLQ, potentially for 14 days. A schemaVersion field plus consumers that ignore unknown fields
makes that a non-event.
🎯 Interview point. "How do you evolve a message schema?" — additive changes only, a version field, consumers tolerant of unknown fields, and never remove a field until every message containing it has expired from every queue and DLQ. Candidates who have been burned by this mention the DLQ.
Message attributes
Structured metadata that travels alongside the body without being part of it.
Map<String, MessageAttributeValue> attrs = Map.of(
"orderId", MessageAttributeValue.builder()
.dataType("String").stringValue("4471").build(),
"eventType", MessageAttributeValue.builder()
.dataType("String").stringValue("OrderPlaced").build(),
"correlationId", MessageAttributeValue.builder()
.dataType("String").stringValue(MDC.get("correlationId")).build()
);
sqs.sendMessage(r -> r
.queueUrl(queueUrl)
.messageBody(body)
.messageAttributes(attrs));Types are String, Number and Binary. Up to 10 per message
(aws-facts.md §1).
Why put anything in attributes rather than the body? Three reasons:
- SNS filter policies read attributes, not bodies (by default). Routing decisions made in the subscription rather than the consumer save you delivering and paying for messages nobody wants (Module 20).
- They survive body schema changes. A correlation ID in an attribute is readable by a consumer that cannot parse the body at all — which is exactly the situation you are in when debugging a poison message.
- They are readable without deserialising. Useful in a DLQ inspector.
⚠️ Attributes count toward the 1 MiB size limit. They are metadata, not free space.
⚠️ FIFO content-based deduplication hashes the body only — not the attributes. Two messages that differ only in an attribute are treated as duplicates. This has caused real production incidents (Module 07).
5. ReceiveMessage
ReceiveMessageResponse response = sqs.receiveMessage(r -> r
.queueUrl(queueUrl)
.maxNumberOfMessages(10) // 1–10. DEFAULT IS 1.
.waitTimeSeconds(20) // long polling. DEFAULT IS 0.
.messageAttributeNames("All") // otherwise you get none
.messageSystemAttributeNames(MessageSystemAttributeName.ALL));
for (Message m : response.messages()) {
// ...
}CLI:
aws sqs receive-message \
--queue-url "$QUEUE_URL" \
--max-number-of-messages 10 \
--wait-time-seconds 20 \
--message-attribute-names All \
--message-system-attribute-names AllFour parameters that are all wrong by default
| Parameter | Default | Set it to | Why |
|---|---|---|---|
MaxNumberOfMessages | 1 | 10 | 10× fewer API calls for the same work (13) |
WaitTimeSeconds | 0 | 20 | Short polling wastes money and returns false empties (12) |
MessageAttributeNames | none | "All" | Attributes are not returned unless requested |
MessageSystemAttributeNames | none | ALL | Including ApproximateReceiveCount, your best debugging signal |
⚠️
MaxNumberOfMessagesdefaults to 1. This is the single most common silent throughput and cost bug in SQS consumers. It looks like it is working — because it is — just ten times more expensively than necessary.
⚠️ Attributes you do not ask for are not returned. A consumer that reads
message.messageAttributes()and finds it empty has usually forgotten.messageAttributeNames("All"), not lost the attributes.
(Older SDK v2 versions use .attributeNames(QueueAttributeName...) for system attributes; it is
deprecated in favour of .messageSystemAttributeNames(...). Both work; prefer the newer one.)
What a message looks like
Message m = response.messages().get(0);
m.messageId(); // "059f36b4-87a3-44ab-83d2-661975830a7d" — stable
m.receiptHandle(); // "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a..." — this receive only
m.body(); // the string you sent
m.md5OfBody(); // integrity check
m.messageAttributes(); // Map<String, MessageAttributeValue> — yours
m.attributes(); // Map<MessageSystemAttributeName, String> — SQS'sSystem attributes worth knowing
| Attribute | Meaning | Use it for |
|---|---|---|
ApproximateReceiveCount | How many times this message has been received | ⭐ Debugging, backoff (10), detecting replays |
SentTimestamp | When the producer sent it (epoch ms) | Measuring end-to-end latency |
ApproximateFirstReceiveTimestamp | When it was first received | Measuring queueing delay |
SenderId | The IAM principal that sent it | Audit, multi-producer debugging |
MessageGroupId | FIFO group (or fair-queue group) | Module 07 |
SequenceNumber | FIFO ordering token | Module 07 |
📌 Log
ApproximateReceiveCounton every receive. It costs one field and it is the difference between "we think there might be duplicates" and "message X has been retried 4 times since 09:12".
6. MessageId versus ReceiptHandle
The distinction, drawn.
flowchart TB
M["<b>Message</b><br/>MessageId: 059f36b4-87a3-44ab<br/><i>assigned once, never changes</i>"]
M --> R1["<b>Receive #1</b> — 09:00:00<br/>ReceiptHandle: AQEBwJnKyrHi…<br/>ApproximateReceiveCount: 1"]
R1 -->|"consumer crashes,<br/>no delete"| R2
R2["<b>Receive #2</b> — 09:00:30<br/>ReceiptHandle: AQEBzWwaftRI…<br/>ApproximateReceiveCount: 2"]
R2 -->|"visibility expires again"| R3
R3["<b>Receive #3</b> — 09:01:00<br/>ReceiptHandle: AQEBX8nesZEX…<br/>ApproximateReceiveCount: 3"]
R3 -->|"DeleteMessage(AQEBX8nesZEX…)"| DONE(["✅ Deleted"])
R1 -.->|"❌ DeleteMessage with the<br/>OLD handle does not work"| FAIL(["No effect on<br/>the current receive"])Reading the diagram. One message, three receives, three different receipt handles. Only the handle from the most recent receive is valid. The two older ones refer to receives that have already expired.
MessageId | ReceiptHandle | |
|---|---|---|
| Assigned | Once, at send | Every receive |
| Changes | Never | Every receive |
| Length | 36-char UUID | Long opaque token |
| Valid for | The message's lifetime | This receive, until its visibility expires |
| Used by | Logging, idempotency keys, tracing | DeleteMessage, ChangeMessageVisibility |
| Unique across | The queue | Every receive ever |
⚠️ Common mistake. Calling
DeleteMessagewith theMessageId. The API expects a receipt handle; you getReceiptHandleIsInvalid— or, if the string happens to parse, nothing useful happens and the message comes back later.
⚠️ Common mistake. Caching a receipt handle to delete "later". Once the visibility timeout lapses, the handle is stale. A delete with a stale handle can silently fail to remove the message, and you will see the same work done twice. Module 08 covers extending visibility for long work instead.
7. DeleteMessage
sqs.deleteMessage(d -> d
.queueUrl(queueUrl)
.receiptHandle(m.receiptHandle()));aws sqs delete-message \
--queue-url "$QUEUE_URL" \
--receipt-handle "$RECEIPT_HANDLE"Two rules, both of which people break:
📌 Delete after processing, never before. "Delete-then-work" loses the message on any crash.
📌 Delete is part of the success path, not a
finallyblock. Afinallythat deletes runs on failure too — which is silent data loss dressed up as cleanup. The DLQ stays empty and everyone believes the system is healthy (Module 10).
DeleteMessage is idempotent in the sense that deleting an already-deleted message does not error.
It is not idempotent across receives — an old handle does not delete a new receive.
8. GetQueueAttributes
How you inspect a queue's configuration and (approximate) state.
Map<QueueAttributeName, String> attrs = sqs.getQueueAttributes(r -> r
.queueUrl(queueUrl)
.attributeNames(QueueAttributeName.ALL))
.attributes();
attrs.get(QueueAttributeName.APPROXIMATE_NUMBER_OF_MESSAGES); // backlog
attrs.get(QueueAttributeName.APPROXIMATE_NUMBER_OF_MESSAGES_NOT_VISIBLE); // in flight
attrs.get(QueueAttributeName.VISIBILITY_TIMEOUT); // exact
attrs.get(QueueAttributeName.QUEUE_ARN); // exactaws sqs get-queue-attributes --queue-url "$QUEUE_URL" --attribute-names AllRemember Module 03 §10: configuration attributes are exact; count attributes are approximate. AWS's naming tells you which is which.
⚠️
GetQueueAttributesis a billed API call. Calling it in a tight loop to "monitor" a queue is both expensive and worse than the free alternative — CloudWatch already publishes these metrics at one-minute granularity (Module 17).
9. A minimal correct consumer
Everything above, assembled. This is deliberately the smallest consumer that is not wrong — it gets batching, error isolation and delete-on-success right, and it is still only about forty lines.
import software.amazon.awssdk.services.sqs.SqsClient;
import software.amazon.awssdk.services.sqs.model.*;
public final class Consumer {
private final SqsClient sqs;
private final String queueUrl;
private volatile boolean running = true;
Consumer(SqsClient sqs, String queueUrl) {
this.sqs = sqs;
this.queueUrl = queueUrl;
}
void run() {
while (running) {
ReceiveMessageResponse response = sqs.receiveMessage(r -> r
.queueUrl(queueUrl)
.maxNumberOfMessages(10) // ← not the default
.waitTimeSeconds(20) // ← not the default; this is also our throttle
.messageAttributeNames("All")
.messageSystemAttributeNames(MessageSystemAttributeName.ALL));
// An empty response is normal, not an error. Just loop.
for (Message m : response.messages()) {
handleOne(m);
}
}
}
private void handleOne(Message m) {
String receiveCount = m.attributes()
.getOrDefault(MessageSystemAttributeName.APPROXIMATE_RECEIVE_COUNT, "?");
try {
log.info("received messageId={} receiveCount={}", m.messageId(), receiveCount);
process(m); // your business logic
sqs.deleteMessage(d -> d // ← only on success
.queueUrl(queueUrl)
.receiptHandle(m.receiptHandle()));
log.info("completed messageId={}", m.messageId());
} catch (Exception e) {
// Deliberately do NOT delete. The message becomes visible again
// after the visibility timeout and is retried. See Module 08 & 10.
log.error("failed messageId={} receiveCount={}", m.messageId(), receiveCount, e);
}
}
void shutdown() { running = false; }
}Six decisions in that code, each of which is a later module:
| Line | Decision | Why | Module |
|---|---|---|---|
maxNumberOfMessages(10) | Override the default of 1 | 10× fewer API calls | 13 |
waitTimeSeconds(20) | Long polling | Cost, latency, fewer false empties | 12 |
messageSystemAttributeNames(ALL) | Get the receive count | Debugging and backoff | 10 |
try per message, not per batch | One bad message must not fail nine good ones | Partial failure | 13 |
deleteMessage inside try, after process | Delete only on success | At-least-once safety | 09 |
catch that does not delete | Let SQS retry | Retry is "do not delete" | 10 |
What this consumer still gets wrong
It is correct but not production-ready. The gaps, each closed later:
- No idempotency — a redelivery will re-run
process()(09) - Deletes one at a time — 10 API calls where 1 would do (13)
- No backoff — a failing downstream is hammered every visibility timeout (10)
- No visibility extension — work longer than the timeout gets double-processed (08)
- No graceful shutdown —
running = falsedoes not wait for in-flight work (22) - No metrics — you cannot size anything without processing-time data (17)
🎯 Interview point. Being able to name what a simple consumer is missing — and in what order you would add it — is a much better signal than producing a perfect consumer from memory.
10. The CLI, for exploration
The CLI is the fastest way to poke at a queue during an incident or while learning.
export QUEUE_URL=$(aws sqs get-queue-url --queue-name sqs-course-demo --query QueueUrl --output text)
# Send
aws sqs send-message --queue-url "$QUEUE_URL" --message-body 'hello'
# Receive (note: with short polling by default — pass --wait-time-seconds)
aws sqs receive-message --queue-url "$QUEUE_URL" --wait-time-seconds 20 \
--message-system-attribute-names All
# Delete, using the handle from the receive above
aws sqs delete-message --queue-url "$QUEUE_URL" --receipt-handle 'AQEB...'
# Inspect
aws sqs get-queue-attributes --queue-url "$QUEUE_URL" --attribute-names All⚠️
aws sqs purge-queuedeletes every message in the queue, immediately and irreversibly. It exists, it is a single command, and it is almost never the right answer — least of all during an incident (Module 26). It is deliberately absent from this course's IAM policy (Module 00 §6).
⚠️ Receiving from the CLI competes with your running consumers. A message you pull for debugging is invisible to them for the visibility timeout. On a busy production queue, a casual
receive-messageis not read-only.
11. Production considerations
Set MaxNumberOfMessages and WaitTimeSeconds explicitly, everywhere. The defaults are wrong
for essentially every workload and the failure is silent.
Receipt handles are short-lived. Their validity ends with the visibility timeout. Do not store them, pass them between services, or put them in a database.
Bodies carry intent and references. Under 64 KB keeps you at one billed request per call; over 1 MiB does not fit at all. Use S3 and a pointer.
Schema evolution is a day-one decision. Additive changes, a version field, tolerant consumers. Remember that a DLQ can hold an old-format message for 14 days after you thought the migration was done.
SenderId is useful in multi-producer queues. When something malformed appears, knowing which
IAM principal sent it narrows the search immediately.
Correlation IDs go in attributes, not the body. They must be readable when the body is unparseable — which is exactly when you need them (Module 17).
Reuse the client. One SqsClient per process; it is thread-safe and holds a connection pool
(Module 00 §8).
12. Common mistakes
Calling DeleteMessage with the MessageId.
Why it's wrong: the API takes a receipt handle. You get an error, or a silent no-op and a
redelivery.
Instead: keep the handle from the receive that gave you the message.
Caching a receipt handle for later use. Why it's wrong: it is valid only for that receive, and only until visibility expires. Instead: delete promptly, or extend visibility (Module 08).
Leaving MaxNumberOfMessages at the default.
Why it's wrong: 10× the API calls and 10× the cost for identical work, with no error to tell you.
Instead: set it to 10 for batchable work.
Forgetting MessageAttributeNames.
Why it's wrong: attributes are not returned unless requested, so they appear to have vanished.
Instead: .messageAttributeNames("All").
Deleting in a finally block.
Why it's wrong: it deletes on failure too. Silent data loss, and an empty DLQ that makes everything
look healthy.
Instead: delete on the success path only.
Putting a large payload in the body. Why it's wrong: billed in 64 KB chunks, and capped at 1 MiB. Instead: S3 pointer, or the Extended Client Library.
Calling GetQueueUrl or GetQueueAttributes on every message.
Why it's wrong: billed API calls returning near-constant data.
Instead: resolve the URL once at startup; use CloudWatch for metrics.
13. Real-world example
An order-intake endpoint, end to end.
The producer — an HTTP handler that must return in under 100 ms.
@PostMapping("/orders")
ResponseEntity<OrderAccepted> placeOrder(@RequestBody OrderRequest req) {
validate(req); // synchronous — gates the request
Order order = orders.save(req.toOrder()); // synchronous — we need the id
sqs.sendMessage(r -> r
.queueUrl(paymentsQueueUrl)
.messageBody(json.write(new PaymentCommand(
order.id(), order.amountCents(), order.currency(), 1)))
.messageAttributes(Map.of(
"orderId", strAttr(order.id()),
"eventType", strAttr("PaymentRequested"),
"correlationId", strAttr(MDC.get("correlationId")))));
return ResponseEntity.accepted() // 202, not 200 — Module 01 §9
.body(new OrderAccepted(order.id(), "PENDING", "/orders/" + order.id()));
}Note what is synchronous and what is not. Validation and the order write gate the response —
the customer needs an order id. Payment does not; it is recorded as intent and returns 202.
The consumer is the loop from §9, with process() charging the card.
What they logged, per message:
level=INFO msg=received messageId=059f36b4-87a3-44ab orderId=4471 correlationId=c-9f2e receiveCount=1
level=INFO msg=completed messageId=059f36b4-87a3-44ab orderId=4471 correlationId=c-9f2e durationMs=284Why that shape. The correlationId links these lines to the HTTP request that created the
order, across a process boundary where no stack trace can reach. The receiveCount tells you
instantly whether you are looking at a first attempt or a retry — which, when the same orderId
appears twice, is the difference between "we have a duplicate problem" and "we have a redelivery,
which is expected, and our idempotency guard handled it"
(Module 03 §13).
14. Interview questions
🟢 Beginner
Which three API calls make up the basic SQS flow?
SendMessage, ReceiveMessage, DeleteMessage. All three are required — omitting the delete means
the message is redelivered.
What is a receipt handle?
An opaque token SQS returns with a received message, identifying that specific receive. It is what
DeleteMessage and ChangeMessageVisibility take.
🟡 Intermediate
What is the difference between MessageId and ReceiptHandle? Why are there two identifiers?
MessageId is the message's stable identity, assigned at send and unchanged for its lifetime.
ReceiptHandle identifies one receive event and changes on every receive. Two identifiers exist
because "which message is this?" and "which attempt am I acknowledging?" are different questions —
and without the second, a stale acknowledgement from a slow worker would be indistinguishable from a
legitimate one.
What happens if you never call DeleteMessage?
The message becomes visible again when the visibility timeout expires and is redelivered. That
repeats until it reaches maxReceiveCount and moves to a DLQ, or until the retention period expires
and it is silently dropped.
Why are message attributes not returned by default?
They must be requested explicitly with MessageAttributeNames. This keeps responses small by
default — and it is why a consumer that "lost its attributes" has usually just forgotten the
parameter.
🔴 Advanced
A consumer deletes using a handle it cached five minutes ago, and the message reappears. Explain exactly why.
The handle referred to a receive whose visibility timeout has since expired. When it expired, the message became available again and was very likely re-received by another consumer — which issued a new receipt handle. The cached handle now refers to a receive that is no longer current, so the delete does not remove the message from the current delivery. Meanwhile the message is being processed a second time.
The root cause is not the delete; it is that processing outlived the visibility timeout. The fixes,
in order of preference: extend visibility with ChangeMessageVisibility while working
(Module 08); size the timeout from the p99 rather than the mean; and
make processing idempotent so the duplicate is harmless
(Module 09).
Why does ReceiveMessage return a list even when you ask for one message?
Because the API is a batch API with a ceiling, not a single-item API. It returns between 0 and
MaxNumberOfMessages — 0 being normal on an empty or non-empty queue under short polling
(Module 03 §8). Treating an empty list as an error is a common bug.
⚫ System design
Design a message schema for an order event that must survive two years of producer and consumer changes.
The structure of a strong answer:
1. Envelope and payload, separated.
{
"schemaVersion": 2,
"eventType": "OrderPlaced",
"eventId": "uuid", // idempotency key — Module 09
"occurredAt": "2026-09-21T10:14:33Z",
"payload": { /* version-specific */ }
}The envelope is stable forever; only payload evolves. A consumer can route, deduplicate and log
without understanding the payload at all.
2. Additive-only evolution. New fields are optional with defaults. Never remove or repurpose a field. Never change a field's type — add a new one.
3. Consumers ignore unknown fields. Configure the deserialiser for this explicitly (Jackson:
FAIL_ON_UNKNOWN_PROPERTIES = false). A consumer that rejects a message because the producer added
a field turns a routine deploy into an outage.
4. Deploy order matters. Consumers that tolerate the new shape go first; producers that emit it go second. Otherwise the new-shape messages arrive before anything can read them.
5. Retirement requires patience. A field can be removed only after every message containing it has expired from every queue and every DLQ. With 14-day DLQ retention that is a two-week minimum — and this is the step everyone forgets.
6. Identifiers, not embedded state. Reference orderId rather than embedding the order. Embedded
state is stale the moment it is enqueued, and it makes the message large.
Follow-ups to expect: "What if you must make a breaking change?" — a new queue and a
dual-writing period, not a version bump. "How do you know when the old version is gone?" — emit a
metric dimensioned by schemaVersion and wait for it to reach zero.
15. Summary
- Send → Receive → Delete. All three are required; the delete is not optional cleanup.
MessageIdidentifies the message and never changes.ReceiptHandleidentifies one receive and changes every time. Only the latest handle is valid.MaxNumberOfMessagesdefaults to 1 andWaitTimeSecondsdefaults to 0. Both are wrong for production. Set them.- Attributes are not returned unless requested.
ApproximateReceiveCountis the single most useful field for debugging — log it always.- Bodies carry intent and references: under 64 KB to stay at one billed request, 1 MiB hard cap.
- Version the schema from message one, because in-flight messages cannot be migrated.
- Delete on the success path only — never in a
finally.
← Previous: 03 — How SQS Actually Works · Index: Course home · Next: 05 — The Message Lifecycle →