Lambda Destinations for Reliable Async Processing
Replacing SQS-based retry logic with Lambda Destinations — simpler architecture, built-in failure routing, and 40% less infrastructure to manage.

The Overengineered Async Pattern
Most teams build async Lambda processing like this: API Gateway → Lambda → SQS → Lambda → DLQ → Lambda (DLQ processor) → SNS (alerting). That's six components for what's conceptually a simple flow: "process this asynchronously, retry on failure, alert on permanent failure."
Lambda Destinations, introduced in 2019 but still underused, collapse this pattern. Instead of managing SQS queues and DLQ processors, you configure success and failure destinations directly on the Lambda function. The runtime routes completed invocations to the appropriate destination without intermediate infrastructure.
Architecture: How Destinations Work
Lambda Destinations apply to asynchronous invocations only — when Lambda is invoked via InvokeAsync, S3 events, SNS, EventBridge, or any event source that doesn't wait for a response. For each function, you configure up to two destinations:
- On Success — Receives the function's return value after successful execution
- On Failure — Receives the invocation record after all retries are exhausted
Supported destination types: SQS, SNS, EventBridge, or another Lambda function.
// CDK: Configuring Lambda Destinations
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as destinations from 'aws-cdk-lib/aws-lambda-destinations';
import * as sqs from 'aws-cdk-lib/aws-sqs';
import * as events from 'aws-cdk-lib/aws-events';
const dlq = new sqs.Queue(this, 'FailedOrdersDLQ', {
retentionPeriod: cdk.Duration.days(14),
});
const auditBus = new events.EventBus(this, 'AuditBus');
const processOrder = new lambda.Function(this, 'ProcessOrder', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'process-order.handler',
code: lambda.Code.fromAsset('dist/functions'),
retryAttempts: 2,
maxEventAge: cdk.Duration.hours(1),
onSuccess: new destinations.EventBridgeDestination(auditBus),
onFailure: new destinations.SqsDestination(dlq),
});
With this configuration:
- Successful order processing sends the result to EventBridge for audit trail, analytics, and downstream consumers.
- Failed processing (after 2 retries within 1 hour) routes to the DLQ with full invocation context.
No SQS trigger Lambda. No DLQ processor Lambda. No SNS topic. Three components instead of six.
The Invocation Record: Rich Failure Context
When a destination receives a failure record, it includes everything you need for debugging:
{
"version": "1.0",
"timestamp": "2025-10-25T14:32:18.123Z",
"requestContext": {
"requestId": "a1b2c3d4-5678-90ab-cdef-example",
"functionArn": "arn:aws:lambda:us-east-1:123456789:function:ProcessOrder",
"condition": "RetriesExhausted",
"approximateInvokeCount": 3
},
"requestPayload": {
"orderId": "ORD-12345",
"customerId": "CUST-789",
"items": [{"sku": "WIDGET-A", "qty": 2}]
},
"responseContext": {
"statusCode": 500,
"executedVersion": "$LATEST",
"functionError": "Unhandled"
},
"responsePayload": {
"errorType": "PaymentProviderTimeout",
"errorMessage": "Payment provider did not respond within 10000ms",
"trace": ["PaymentProviderTimeout: Payment provider..."]
}
}
This is dramatically more context than a standard DLQ message. You get the original payload, the error details, the retry count, and the function version — all in one record without any custom wrapping logic.
Pattern: Fan-Out with Success Destinations
Beyond error routing, success destinations enable clean event-driven fan-out:
// Order processing pipeline using destinations
// Step 1: ProcessOrder → on success → EventBridge
// EventBridge rules fan out to:
// → UpdateInventory (async invoke with its own destinations)
// → SendConfirmation (async invoke)
// → UpdateAnalytics (async invoke)
const inventoryRule = new events.Rule(this, 'InventoryRule', {
eventBus: auditBus,
eventPattern: {
source: ['order.processor'],
detailType: ['OrderProcessed'],
},
targets: [
new targets.LambdaFunction(updateInventory, {
event: events.RuleTargetInput.fromEventPath('$.detail.responsePayload'),
}),
new targets.LambdaFunction(sendConfirmation, {
event: events.RuleTargetInput.fromEventPath('$.detail.responsePayload'),
}),
],
});
Each downstream function also uses destinations, creating a self-documenting flow where every success and failure path is explicitly configured in infrastructure code rather than buried in application logic.
Benchmarks: Destinations vs. Traditional SQS Pattern
We migrated our order processing pipeline from the traditional SQS-based pattern to destinations:
| Metric | SQS Pattern | Destinations Pattern | Improvement |
|---|---|---|---|
| Components to manage | 6 (Lambda, SQS, DLQ, DLQ processor, SNS, sub) | 3 (Lambda, EventBridge, DLQ) | -50% |
| End-to-end latency (success path) | 180ms (SQS polling) | 45ms (direct invocation) | -75% |
| Monthly infrastructure cost | $127 (queues + polling + DLQ Lambda) | $34 (EventBridge + DLQ storage) | -73% |
| Code to maintain | ~400 lines (retry logic, DLQ processing) | ~80 lines (handler only) | -80% |
| P99 retry resolution time | 4.2 min (SQS visibility timeout) | 1.8 min (built-in retry) | -57% |
The latency improvement comes from eliminating SQS polling delay. With SQS as a trigger, Lambda polls on a schedule — there's always a delay between message arrival and processing. Async invocations with destinations are immediate.
When NOT to Use Destinations
Destinations aren't universally better. Keep the SQS pattern when:
- You need message batching — Destinations invoke per-event. SQS can batch up to 10 messages per Lambda invocation.
- You need message ordering — SQS FIFO guarantees ordering. Destinations don't.
- You need variable concurrency control — SQS reserved concurrency throttles processing rate. Destinations process at the rate events arrive.
- You need message delay/scheduling — SQS delay queues hold messages for up to 15 minutes. Destinations deliver immediately.
- The trigger is synchronous — API Gateway → Lambda is synchronous; destinations only work for async invocations.
Advanced Pattern: Conditional Routing Based on Response
You can route to different destinations based on the function's return value by using EventBridge as the success destination with content-based routing:
// Handler returns structured response for routing
export async function handler(event: OrderEvent): Promise<OrderResult> {
const result = await processOrder(event);
// Return value goes to EventBridge as event detail
return {
source: 'order.processor',
detailType: result.requiresReview ? 'OrderRequiresReview' : 'OrderProcessed',
orderId: event.orderId,
amount: result.amount,
riskScore: result.riskScore,
};
}
// EventBridge rules route based on detailType:
// OrderProcessed → fulfillment pipeline
// OrderRequiresReview → manual review queue + Slack notification
This replaces complex branching logic inside the function with declarative routing rules in EventBridge — infrastructure you can visualize, test, and modify without redeploying code.
Error Handling Best Practices
- Set
maxEventAgeconservatively — Events older than this are discarded without processing. For time-sensitive operations, set to 1 hour. For batch processing, up to 6 hours. - Always set
retryAttempts— Default is 2. For idempotent operations, 2-3 retries is appropriate. For non-idempotent operations, set to 0 and handle retries in application logic. - Make handlers idempotent — Destinations retry the exact same event. If your handler isn't idempotent, use DynamoDB conditional writes or idempotency tokens.
- Monitor the async event queue — Lambda's internal async queue can back up. Use
AsyncEventsReceivedandAsyncEventAgemetrics.
Conclusion
Lambda Destinations eliminate the infrastructure busywork of async processing. By moving retry logic, failure routing, and success fan-out into the Lambda runtime's built-in capabilities, you reduce components by 50%, cut latency by 75%, and delete hundreds of lines of plumbing code.
The pattern is particularly powerful combined with EventBridge: success destinations publish structured events that downstream consumers subscribe to via rules, creating loosely-coupled pipelines without SQS queues, polling intervals, or custom retry logic.
If you're still building SQS → Lambda → DLQ → DLQ-Processor chains for async processing, destinations give you the same reliability guarantees with radically less infrastructure. Start with your simplest async function, add onSuccess and onFailure destinations, and remove the queue infrastructure. You'll wonder why you ever managed it manually.
Recommended reading

Per-Team Cost Allocation in Shared Kubernetes Clusters: From Chaos to Clarity
Implementing accurate per-namespace cost allocation in multi-tenant Kubernetes clusters, covering request vs. usage attribution, shared resource amortization, and building showback dashboards that drive accountability.

Measuring and Eliminating Toil: From 40% to 12% of Engineering Time
A systematic approach to identifying, measuring, and automating toil—the repetitive operational work that scales linearly with service growth and prevents engineers from doing creative work.

Serverless Postgres in Production: Branching, Scale-to-Zero, and the End of Database Provisioning
Running Neon serverless Postgres in production for 8 months — covering database branching workflows, scale-to-zero economics, connection pooling, and migration from RDS.

Comments
No comments yet. Be the first to share your thoughts.