Learning/AWS SQS/04 — Sending and Receiving Messages
Beginner 35 min read

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:

📌 MessageId identifies the message. ReceiptHandle identifies 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 MessageId and ReceiptHandle, 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 MessageId and the answer never changes. "Which attempt at this message am I acknowledging?" is answered by ReceiptHandle, 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.

  1. 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.
  2. 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).
  3. 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

Java
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 SQS

CLI:

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:

ConstraintValueSource
Maximum size1 MiB (1,048,576 bytes)aws-facts.md §1
Allowed charactersA restricted Unicode range — binary must be Base64-encodedaws-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.

Java
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:

  1. 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).
  2. 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.
  3. 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

Java
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 All

Four parameters that are all wrong by default

ParameterDefaultSet it toWhy
MaxNumberOfMessages11010× fewer API calls for the same work (13)
WaitTimeSeconds020Short polling wastes money and returns false empties (12)
MessageAttributeNamesnone"All"Attributes are not returned unless requested
MessageSystemAttributeNamesnoneALLIncluding ApproximateReceiveCount, your best debugging signal

⚠️ MaxNumberOfMessages defaults 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

Java
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's

System attributes worth knowing

AttributeMeaningUse it for
ApproximateReceiveCountHow many times this message has been received⭐ Debugging, backoff (10), detecting replays
SentTimestampWhen the producer sent it (epoch ms)Measuring end-to-end latency
ApproximateFirstReceiveTimestampWhen it was first receivedMeasuring queueing delay
SenderIdThe IAM principal that sent itAudit, multi-producer debugging
MessageGroupIdFIFO group (or fair-queue group)Module 07
SequenceNumberFIFO ordering tokenModule 07

📌 Log ApproximateReceiveCount on 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.

MessageIdReceiptHandle
AssignedOnce, at sendEvery receive
ChangesNeverEvery receive
Length36-char UUIDLong opaque token
Valid forThe message's lifetimeThis receive, until its visibility expires
Used byLogging, idempotency keys, tracingDeleteMessage, ChangeMessageVisibility
Unique acrossThe queueEvery receive ever

⚠️ Common mistake. Calling DeleteMessage with the MessageId. The API expects a receipt handle; you get ReceiptHandleIsInvalid — 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

Java
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 finally block. A finally that 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.

Java
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);                                   // exact
aws sqs get-queue-attributes --queue-url "$QUEUE_URL" --attribute-names All

Remember Module 03 §10: configuration attributes are exact; count attributes are approximate. AWS's naming tells you which is which.

⚠️ GetQueueAttributes is 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.

Java
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:

LineDecisionWhyModule
maxNumberOfMessages(10)Override the default of 110× fewer API calls13
waitTimeSeconds(20)Long pollingCost, latency, fewer false empties12
messageSystemAttributeNames(ALL)Get the receive countDebugging and backoff10
try per message, not per batchOne bad message must not fail nine good onesPartial failure13
deleteMessage inside try, after processDelete only on successAt-least-once safety09
catch that does not deleteLet SQS retryRetry 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 shutdownrunning = false does 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-queue deletes 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-message is 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.

Java
@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=284

Why 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.
  • MessageId identifies the message and never changes. ReceiptHandle identifies one receive and changes every time. Only the latest handle is valid.
  • MaxNumberOfMessages defaults to 1 and WaitTimeSeconds defaults to 0. Both are wrong for production. Set them.
  • Attributes are not returned unless requested.
  • ApproximateReceiveCount is 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