The Lambda Worked. The Queue Kept Replaying Messages.
A practical AWS SQS and Lambda guide to visibility timeouts, batch failures, partial batch responses, poison messages, dead-letter queues, FIFO ordering, idempotent consumers, and production testing.
The Lambda Worked. The Queue Kept Replaying Messages.
Your Lambda function processed the message successfully.
The database update completed.
Then the same message appeared again.
A few minutes later, the application processed it a second time.
This is one of the most misunderstood behaviors in event-driven AWS architectures.
Developers often think:
"The Lambda succeeded, so the message is done."
But SQS-based processing is a distributed system.
There is a gap between:
1
2
3
4
5
6
7
Message received
|
v
Application side effect completed
|
v
Message successfully acknowledged/deletedA failure can happen inside that gap.
The consequence is simple:
A message may be processed more than once.
The solution is not to assume duplicates never happen.
The solution is to make the system safe when they happen.
This article walks through a production-oriented SQS + Lambda design, including visibility timeouts, batch processing, partial batch responses, dead-letter queues, FIFO behavior, idempotent consumers, and failure testing.
The Incident
Imagine:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
+-------------+
| API / Event |
+------+------+
|
v
+-----------+
| SQS |
+-----+-----+
|
v
+-------------+
| Lambda |
+------+------+
|
v
+-----------+
| RDS |
+-----------+A message arrives:
1
2
3
4
{
"orderId": "ORD-1001",
"type": "PAYMENT_CAPTURED"
}Lambda receives it and updates the database:
1
2
3
4
5
6
7
8
9
10
SQS message
|
v
Lambda
|
v
RDS update
|
v
SUCCESSBut now imagine the Lambda completes the database write and something fails before the queue processing flow completes.
The message becomes visible again.
Lambda receives it again.
Now:
1
2
3
4
5
PAYMENT_CAPTURED
|
+--> update payment
|
+--> update payment AGAINIf the consumer is not idempotent, the duplicate can become a business incident.
SQS Is Not "Run This Exactly Once"
For standard SQS queues, delivery is at least once.
That means duplicate delivery is possible.
The consumer must therefore tolerate:
1
2
3
4
same message
same event
same business operation
more than onceAWS explicitly recommends making Lambda code idempotent when processing SQS events because duplicate processing can occur. citeturn522517search3
This is the correct mental model:
1
2
3
4
5
6
7
8
9
10
Producer
|
v
SQS
|
+--> Consumer attempt #1
|
+--> Consumer attempt #2
|
+--> Consumer attempt #3The message is not the guarantee.
Your consumer's correctness is the guarantee.
Visibility Timeout Is Not a Processing Guarantee
When Lambda receives an SQS message, the message becomes temporarily invisible to other consumers.
That period is the visibility timeout.
Conceptually:
1
2
3
4
5
6
7
8
9
10
11
12
Message received
|
v
Hidden from normal receives
|
+------ processing ------+
| |
v v
Delete Visibility timeout
|
v
Message visibleIf processing finishes and the message is deleted, everything is fine.
If processing takes too long, the message can become visible again while the original invocation is still working.
That can create concurrent duplicate processing.
A Dangerous Example
Suppose:
1
2
Lambda timeout = 30 sec
SQS visibility timeout = 20 secThe function starts processing.
At 20 seconds:
1
Visibility timeout expiresThe message can become available again.
At 25 seconds:
1
2
Original Lambda still processing
Second Lambda can receive the messageNow:
1
2
3
4
5
6
7
8
9
10
11
Message
|
+------+------+
| |
v v
Lambda #1 Lambda #2
| |
+------+------+
|
v
Duplicate side effectThat is why visibility timeout must be designed around real processing behavior.
For Lambda event source mappings, AWS currently recommends configuring the SQS visibility timeout to at least six times the Lambda function timeout, with additional time when a batch window is used. citeturn522517search0
Example Visibility Timeout Calculation
Suppose:
1
2
Lambda timeout = 30 seconds
Maximum batching window = 5 secondsA conservative starting point from the AWS guidance is:
1
2
6 × 30 + 5
= 185 secondsThe exact setting should still be validated against the workload.
The principle is more important than the arithmetic:
Give the event source enough time for the Lambda invocation and retry behavior without making failures invisible for an unnecessarily long time.
The Batch Problem
Lambda can receive multiple SQS messages in one invocation.
For example:
1
2
3
4
5
6
7
8
Batch
+-----------------------+
| Message A |
| Message B |
| Message C |
| Message D |
| Message E |
+-----------------------+Now suppose:
1
2
3
4
5
A -> success
B -> success
C -> success
D -> failure
E -> successBy default, a failed batch can cause the batch's messages to become visible again.
That means:
1
2
3
4
A -> processed again
B -> processed again
C -> processed again
E -> processed againEven though only D failed.
AWS provides partial batch responses to avoid this unnecessary reprocessing. citeturn522517search1
Partial Batch Response
Enable:
1
ReportBatchItemFailuresThen your Lambda reports only the failed message IDs.
Conceptually:
1
2
3
4
5
6
7
8
Batch
A B C D E
| | | | |
S S S F S
Return:
D = failedLambda can then make only the failed message visible again.
The result is:
1
2
3
4
5
A -> no retry
B -> no retry
C -> no retry
D -> retry
E -> no retryThis is especially useful for large batches.
Java Example
A simplified Java Lambda handler can return failed message IDs:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public SQSBatchResponse handleRequest(
SQSEvent event,
Context context) {
List<SQSBatchResponse.BatchItemFailure> failures =
new ArrayList<>();
for (SQSEvent.SQSMessage message : event.getRecords()) {
try {
process(message);
} catch (Exception ex) {
failures.add(
new SQSBatchResponse.BatchItemFailure(
message.getMessageId()
)
);
}
}
return new SQSBatchResponse(failures);
}The important behavior is not the class name.
It is the contract:
1
2
successful messages -> do not retry
failed messages -> retryFor FIFO queues, AWS recommends stopping after the first failure and returning the failed and unprocessed messages so ordering is preserved. citeturn522517search1
The Poison Message Problem
Some messages are temporarily failing.
Others are permanently broken.
For example:
1
2
3
4
{
"orderId": null,
"amount": -500
}If the consumer always fails this message:
1
2
3
4
5
6
Attempt 1 -> fail
Attempt 2 -> fail
Attempt 3 -> fail
Attempt 4 -> fail
Attempt 5 -> fail
...The message keeps returning.
This is a poison message.
Without a dead-letter strategy, the system can waste processing capacity repeatedly trying the same impossible work.
AWS recommends using an SQS dead-letter queue for messages that repeatedly fail processing. citeturn522517search0turn522517search5
Dead-Letter Queue
The architecture becomes:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
+-------------+
| Source SQS |
+------+------+
|
v
+-------+
|Lambda |
+---+---+
|
success | failure
|
v
+-------------+
| DLQ |
+-------------+A message might follow:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Attempt 1
|
v
Attempt 2
|
v
Attempt 3
|
v
Attempt 4
|
v
Attempt 5
|
v
DLQThe exact
maxReceiveCount should match the workload, but AWS currently recommends at least 5 for an SQS queue used with Lambda. citeturn522517search0Why the DLQ Matters More Than It Looks
A DLQ is not just an error bucket.
It is a control mechanism.
Without a DLQ:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Bad message
|
v
Retry
|
v
Bad message
|
v
Retry
|
v
More compute
|
v
More retriesA growing population of permanently failing messages can consume capacity and make the overall system less healthy.
AWS calls this the snowball anti-pattern in its SQS/Lambda guidance. citeturn522517search5
A DLQ breaks that loop.
But a DLQ Does Not Fix the Business Problem
Putting messages in a DLQ is not the end of the process.
You need an operational workflow:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Source Queue
|
v
Lambda
|
failure
v
DLQ
|
v
Investigation
|
+---- fix data
|
+---- fix code
|
+---- replay safelyA useful DLQ strategy should answer:
1
2
3
4
5
6
Who monitors it?
How is an incident detected?
How do we inspect the failed message?
How do we decide whether to replay it?
How do we prevent another duplicate?
How do we track replay status?The Consumer Must Still Be Idempotent
A DLQ reduces repeated failures.
It does not eliminate duplicate delivery.
The consumer should still be safe when it sees the same event twice.
Suppose the message is:
1
2
3
4
5
{
"eventId": "EVT-123",
"type": "ORDER_CREATED",
"orderId": "ORD-1001"
}A simple database pattern is:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
eventId
|
v
Already processed?
|
+---+---+
| |
YES NO
| |
v v
skip process
|
v
mark processedThe uniqueness constraint belongs close to the state change.
For example:
1
2
CREATE UNIQUE INDEX idx_processed_events_event_id
ON processed_events(event_id);Then concurrent consumers cannot silently create the same business effect.
SQS FIFO Is Not a Universal Duplicate Solution
FIFO queues provide:
- ordered processing within message groups
- deduplication behavior
- stricter delivery semantics than standard queues
But FIFO does not mean:
1
Your business operation can never execute twice.For example, consumer failure can still cause a message to be delivered again after visibility timeout.
AWS documents that even FIFO consumers must account for visibility timeout and possible duplicate processing. citeturn522517search6
Also, SQS FIFO producer deduplication is time-sensitive.
AWS documents a 5-minute deduplication interval. A retry after that interval can result in a new message. citeturn522517search7
Therefore:
1
2
3
FIFO
+
idempotent consumeris much stronger than:
1
2
FIFO
aloneOrdering Has a Cost
FIFO queues are useful when ordering is a business requirement.
For example:
1
2
3
4
5
6
7
Account A
Deposit
|
Withdrawal
|
TransferThe order matters.
With SQS FIFO:
1
MessageGroupId = ACCOUNT-AThe messages for that group can be processed in order.
But you should not use one single message group for the entire system unless you actually need global serialization.
Instead, partition by a business key when appropriate:
1
2
3
ACCOUNT-A -> group A
ACCOUNT-B -> group B
ACCOUNT-C -> group CNow independent groups can be processed in parallel while maintaining order inside each group.
A Reliable Architecture
A production-oriented design often looks like:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
+----------------+
| Producer/API |
+-------+--------+
|
v
+---------------+
| SQS |
| standard/FIFO |
+-------+-------+
|
Lambda event
source mapping
|
v
+---------------+
| Lambda |
+-------+-------+
|
+------------+------------+
| |
success failure
| |
v v
side effect retry
|
v
DLQInside Lambda:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Receive batch
|
v
Validate message
|
v
Check idempotency
|
v
Perform business operation
|
v
Commit business state
|
v
Return successThe Most Important Failure Window
Consider this:
1
2
3
4
5
1. Lambda receives message
2. Database update succeeds
3. Lambda crashes
4. Message becomes visible again
5. Lambda processes it againIf step 2 is not idempotent:
1
Database update x2If step 2 is idempotent:
1
2
Database update
Retry -> duplicate detectedThat is the difference between:
1
duplicate deliveryand:
1
duplicate business effectThe first is normal distributed-systems behavior.
The second is an application design bug.
Partial Batch Responses + Idempotency
These solve different problems.
Partial batch response
Answers:
Which messages should be retried from this batch?
Idempotency
Answers:
What should happen if this message is processed again?
DLQ
Answers:
What should happen when this message keeps failing?
They work together:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
SQS
|
v
Lambda Batch
|
+------+------+
| |
success failure
| |
v v
no retry retry only
|
+----+----+
| |
success fail
|
v
DLQAnd every successful business operation should remain safe to repeat.
Observability
Monitor the queue, Lambda, and DLQ together.
Useful SQS signals include:
1
2
3
4
ApproximateNumberOfMessagesVisible
ApproximateAgeOfOldestMessage
NumberOfMessagesReceived
NumberOfMessagesDeletedAlso monitor:
1
2
3
4
5
DLQ message count
Lambda Errors
Lambda Throttles
Lambda Duration
Lambda ConcurrentExecutionsA useful alert could be:
1
DLQ messages > 0Another useful signal is:
1
Age of oldest message keeps increasingThat usually means:
1
Arrival rate > processing capacityor that messages are repeatedly failing.
A Queue Is Also a Shock Absorber
One of the benefits of SQS is that it decouples producers from consumers.
Without a queue:
1
2
3
4
5
6
7
Traffic spike
|
v
API
|
v
DatabaseWith a queue:
1
2
3
4
5
6
7
8
9
10
11
12
13
Traffic spike
|
v
Producer
|
v
SQS <--- absorbs burst
|
v
Consumer
|
v
DatabaseBut queues do not eliminate capacity problems.
They move them.
Instead of:
1
HTTP latencyyou may see:
1
2
3
Queue age
Queue depth
Consumer lagSo the system should monitor both synchronous latency and asynchronous backlog.
Load Testing the Failure Paths
Do not test only:
1
Message arrives -> Lambda succeedsTest:
1
2
3
4
5
6
7
8
9
10
1. Lambda succeeds normally
2. Lambda fails halfway through
3. Database is slow
4. Visibility timeout is too short
5. One message in a batch fails
6. Same message is delivered twice
7. Poison message repeatedly fails
8. DLQ receives message
9. Message is replayed
10. Consumer is restarted during processingOne particularly valuable test is:
1
2
3
Process message
+
force failure after business side effectThen verify:
1
2
3
Message can reappear
+
Business state is still correctThat test validates the actual distributed-systems behavior.
Production Checklist
Before putting an SQS + Lambda workflow into production:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[ ] Consumer is idempotent
[ ] Visibility timeout matches Lambda runtime
[ ] Batch size is intentional
[ ] Partial batch response is enabled where useful
[ ] FIFO is used only when ordering is actually required
[ ] Message groups are partitioned sensibly
[ ] DLQ is configured
[ ] maxReceiveCount is intentional
[ ] DLQ is monitored
[ ] Poison messages have an investigation path
[ ] Replay process is safe
[ ] Business side effects have uniqueness/idempotency protection
[ ] Queue age is monitored
[ ] Lambda errors and throttling are monitored
[ ] Failure paths have been load-testedThe Bigger Lesson
The dangerous assumption in event-driven systems is:
"If my Lambda succeeded once, the message is finished forever."
The stronger model is:
1
2
3
4
5
Messages can be delivered again.
Processing can fail after a side effect.
Batches can partially fail.
Bad messages can repeatedly fail.
Consumers must be safe to run again.That is why a reliable SQS/Lambda architecture uses several controls together:
1
2
3
4
5
6
7
8
9
10
11
Visibility Timeout
+
Partial Batch Response
+
Idempotent Consumer
+
Dead-Letter Queue
+
Monitoring
+
Failure TestingThe goal is not to make retries disappear.
The goal is to make retries boring.
When the same message arrives twice, the system should say:
1
"Already processed."not:
1
"Create another payment."Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article