The most surprising thing about SQS’s "receive and delete" is that it’s not atomic, and the system actively works against you if you treat it that way.

Let’s see it in action. Imagine you’ve got a message in SQS:

{
  "MessageId": "12345678-abcd-efgh-ijkl-012345678901",
  "ReceiptHandle": "AQEBw3.../...",
  "MD5OfBody": "...",
  "Body": "{\"order_id\": \"ORD98765\", \"status\": \"processing\"}"
}

Your Java application, using the AWS SDK, receives this message. The ReceiveMessageRequest might look something like this:

ReceiveMessageRequest receiveMessageRequest = ReceiveMessageRequest.builder()
    .queueUrl("https://sqs.us-east-1.amazonaws.com/123456789012/my-queue")
    .maxNumberOfMessages(1)
    .visibilityTimeout(30) // 30 seconds
    .build();

List<Message> messages = sqsClient.receiveMessage(receiveMessageRequest).messages();

The SDK gives you the Message object. You then process the Body – perhaps updating a database with the order_id. Once processing is complete, you call deleteMessage:

for (Message message : messages) {
    // ... process message.getBody() ...

    DeleteMessageRequest deleteMessageRequest = DeleteMessageRequest.builder()
        .queueUrl("https://sqs.us-east-1.amazonaws.com/123456789012/my-queue")
        .receiptHandle(message.receiptHandle())
        .build();
    sqsClient.deleteMessage(deleteMessageRequest);
}

The Python equivalent is very similar:

import boto3

sqs = boto3.client('sqs', region_name='us-east-1')
queue_url = "https://sqs.sqs.us-east-1.amazonaws.com/123456789012/my-queue"

response = sqs.receive_message(
    QueueUrl=queue_url,
    MaxNumberOfMessages=1,
    VisibilityTimeout=30
)

messages = response.get('Messages', [])

for message in messages:
    message_body = message['Body']
    receipt_handle = message['ReceiptHandle']

    # ... process message_body ...

    sqs.delete_message(
        QueueUrl=queue_url,
        ReceiptHandle=receipt_handle
    )

This pattern, often called "receive and delete," is the most common way to consume messages. You get a message, do your work, and then tell SQS it’s done. The core idea is that the message is not deleted until you explicitly ask for it. While it’s being processed, it’s hidden from other consumers due to the VisibilityTimeout. If you don’t delete it within that timeout, it reappears in the queue for another consumer to pick up.

The problem arises when your processing fails or takes longer than the VisibilityTimeout. If your Java application crashes after receiving the message but before calling deleteMessage, or if the database update takes 45 seconds and your VisibilityTimeout was 30, that message will become visible again. The next consumer might pick it up, potentially leading to duplicate processing.

The mental model here is that SQS is a highly available, distributed log. When you ReceiveMessage, you’re essentially "checking out" a message for a limited time. The ReceiptHandle is your temporary claim ticket. If you lose the ticket (don’t delete) or don’t return it before it expires, someone else can get the same item. The VisibilityTimeout is the duration of that checkout.

The real levers you control are:

  • VisibilityTimeout: This is the most critical setting. You need to set it to be longer than your longest expected processing time, plus a buffer. If your jobs typically take 10 seconds, set it to 30 or 60 seconds. If some jobs can take up to a minute, you might need a 2-minute timeout. Too short, and you risk duplicate processing. Too long, and a permanently stuck message can clog up your queue, preventing other messages from being processed for a long time. The queue-level default is 30 seconds, but you can override it per ReceiveMessage call or set a queue-specific default.
  • Error Handling and Retries: Your application must handle exceptions gracefully. If processing fails, you typically don’t delete the message. Instead, you let the VisibilityTimeout expire, or you can explicitly ChangeMessageVisibility to extend the timeout if you’re still working on it, or even release it immediately by setting the timeout to 0.
  • Dead-Letter Queues (DLQs): For messages that consistently fail after a certain number of retries, you should configure a DLQ. This moves the problematic messages to a separate queue for later inspection, preventing them from blocking your main queue indefinitely.

The ReceiptHandle itself is not static. If a message’s visibility timeout expires and it becomes visible again, it will be assigned a new ReceiptHandle. This is crucial: if your application somehow receives the same message twice (perhaps due to a network blip on the delete operation), and it tries to delete it using an old ReceiptHandle after the message has already reappeared with a new one, the delete operation will silently fail, and you’ll get an error like InvalidReceiptHandle.

The next concept you’ll run into is how to handle these duplicate processing scenarios reliably, leading you to explore idempotency.

Want structured learning?

Take the full Sqs course →