Event-Driven Architecture with AWS Lambda & EventBridge
This designs an event-driven pipeline on Lambda and EventBridge that survives poison messages, retry storms, traffic spikes, and cold-start latency, rather than just the happy path.
EventBridge: route by pattern, not by function
Don't put business logic in the rule and don't fan every event into one general-purpose Lambda. Define narrow event patterns so each consumer only ever sees what it's built to handle.
{
"source": ["com.acme.orders"],
"detail-type": ["OrderCompleted"],
"detail": {
"status": ["completed"],
"customerId": [{ "exists": true }]
}
}That exists check alone keeps a payload with a missing customerId off the consumer: it either gets caught earlier in the pipeline or routed to a separate "malformed event" rule that alerts a human. Pattern matching is free; use it as a validation layer as well as a router.
Lambda concurrency: reserved and provisioned, deliberately
Two settings matter here: reserved concurrency caps how many concurrent executions a function can consume, stopping one noisy event source from starving every other function in your account of the shared concurrency pool; provisioned concurrency keeps a set number of execution environments warm, eliminating cold starts for that traffic.
resource "aws_lambda_function" "order_processor" {
function_name = "order-processor"
runtime = "nodejs20.x"
handler = "index.handler"
memory_size = 512
timeout = 30
reserved_concurrent_executions = 50
}
resource "aws_lambda_provisioned_concurrency_config" "order_processor_warm" {
function_name = aws_lambda_function.order_processor.function_name
qualifier = aws_lambda_function.order_processor.version
provisioned_concurrent_executions = 10
}Provisioned concurrency is billed whether or not it's invoked. Reserve it for customer-facing traffic where the 2-3s cold-start tax is unacceptable, and let background/batch consumers cold-start freely.
SQS as a buffer: backpressure
Wiring EventBridge straight to Lambda means its concurrency scaling is dictated by the event source's burst rate. Add an SQS queue between the bus and the function and you get three things direct-invoke doesn't: a buffer that absorbs spikes, batching, and a place to attach a DLQ.
Lambda now polls the queue via an event source mapping, with its own concurrency and batching controls:
resource "aws_cloudwatch_event_target" "to_queue" {
rule = aws_cloudwatch_event_rule.order_completed.name
arn = aws_sqs_queue.order_buffer.arn
}
resource "aws_lambda_event_source_mapping" "queue_to_lambda" {
event_source_arn = aws_sqs_queue.order_buffer.arn
function_name = aws_lambda_function.order_processor.arn
batch_size = 10
maximum_batching_window_in_seconds = 5
scaling_config {
maximum_concurrency = 20
}
}maximum_concurrency on the event source mapping is a second, more granular throttle than reserved concurrency on the function itself. Use it when one function is fed by multiple queues and you want per-queue limits.
The DLQ: where poison events go to die quietly
Instead of retrying an unrecoverable event indefinitely, or silently dropping it once EventBridge's retry window expires, route it to a dead-letter queue after a bounded number of attempts. Someone, or some alarm, looks at what's in that queue instead.
{
"deadLetterTargetArn": "arn:aws:sqs:eu-west-1:111122223333:order-processor-dlq",
"maxReceiveCount": 5
}Attach that redrive policy to the source queue (order_buffer above). Separately, configure the Lambda function's on_failure destination for failures after the event leaves SQS, inside its own retry handling, the two mechanisms cover different points, and you generally want both.
Idempotent handlers: the tax for at-least-once delivery
EventBridge and SQS guarantee at-least-once delivery, never exactly-once, combined with Lambda retries, any event can hit your handler two, three, or more times. If "process this order" means "charge this card," a duplicate delivery that isn't deduplicated is a duplicate charge.
The fix is a dedupe table keyed on the event's own idempotency key (I use the EventBridge event ID or a business key like order ID + status, whichever is stable across retries):
{
"TableName": "processed-events",
"KeySchema": [{ "AttributeName": "eventId", "KeyType": "HASH" }],
"TimeToLiveSpecification": {
"AttributeName": "expiresAt",
"Enabled": true
}
}Write to this table with a conditional PutItem (attribute_not_exists(eventId)) before doing anything irreversible. If the write fails because the item exists, this event's already been processed, return success without re-running the side effect. TTL keeps the table from growing unbounded; a few days is usually enough given EventBridge/SQS retry windows.
Idempotency keys matter most for anything with an external side effect: payments, emails, inventory decrements. Pure read/transform/write-to-your-own-database operations are often naturally idempotent already; skip the dedupe table for those.
The full path, end to end
EventBridge, SQS, Lambda concurrency limits, conditional writes, you've probably used each on its own already. What separates a pipeline that shrugs off a bad production week from one that pages someone at 2am is having all four in place before the first poison event shows up.
Want to actually run this in production?
This tutorial covers the concepts and architecture. If you want to implement it in your own infrastructure, or get good enough to own this problem long-term, I offer 1:1 mentoring built around your real environment, not a generic course.
This tutorial
- Core architecture & key concepts
- Illustrative code snippets
- The reasoning behind each decision
1:1 mentoring
- Working sessions on your own environment
- Direct answers to the edge cases you're hitting
- Feedback on your actual implementation
- Ongoing support as you build it out