Event-driven architecture means services react to things that happened, rather than being called directly and synchronously. On AWS, Lambda is almost always the compute side of that pattern, and EventBridge is increasingly the routing layer that decides which events go where.
- An AWS account with permissions to create Lambda functions, EventBridge rules, and IAM roles.
- Basic familiarity with JSON and at least one Lambda-supported runtime (Node.js, Python, etc.).
Why event-driven instead of a request-response service
A traditional service waits for a direct call and responds synchronously. An event-driven service reacts to something that already happened — a file landed in S3, an order was placed, a scheduled time arrived — without the producer needing to know or care who's listening, or wait for them to finish.
The producer (whatever generated the event) doesn't hold a connection open waiting for a response, and doesn't need to know how many consumers exist or what they each do — a genuinely different failure and scaling profile than a chain of synchronous service-to-service calls.
A basic Lambda function
import json
def handler(event, context):
order_id = event["detail"]["orderId"]
print(f"Processing order {order_id}")
return {"statusCode": 200}Lambda functions are stateless and short-lived (a 15-minute maximum execution time), billed per invocation and per millisecond of actual execution — there's no cost for idle time between invocations, unlike an always-on EC2 instance.
EventBridge: rules as content-based filters
An EventBridge rule matches on the actual structure of the event, not just a topic name — this is the real difference from SNS's simpler topic-based fan-out.
{
"source": ["myapp.orders"],
"detail-type": ["OrderPlaced"],
"detail": {
"amount": [{ "numeric": [">", 1000] }]
}
}This rule matches only OrderPlaced events from myapp.orders where amount exceeds 1000 — high-value orders get routed to a different target (perhaps a fraud-review Lambda) than routine ones, entirely through the rule's own filtering logic, with no code in the producer or a shared queue-consumer needing to implement that branching itself.
aws events put-rule \
--name high-value-orders \
--event-pattern file://pattern.json \
--event-bus-name myapp-events
aws events put-targets \
--rule high-value-orders \
--event-bus-name myapp-events \
--targets "Id"="1","Arn"="arn:aws:lambda:us-east-1:123456789012:function:fraud-review"Publishing a custom event
Applications publish their own domain events to a custom event bus (rather than only reacting to AWS service events) using PutEvents:
import boto3
import json
client = boto3.client("events")
client.put_events(
Entries=[
{
"Source": "myapp.orders",
"DetailType": "OrderPlaced",
"Detail": json.dumps({"orderId": "abc123", "amount": 1500}),
"EventBusName": "myapp-events",
}
]
)This is the foundation of an internal event-driven architecture across your own services: one service publishes OrderPlaced, and any number of independently deployed consumers (inventory, notifications, analytics, fraud review) each define their own rule against the same bus, without the order service knowing any of them exist.
Fan-out: one event, many independent consumers
| Aspect | SNS fan-out | EventBridge fan-out |
|---|---|---|
| Filtering | Topic-based, or basic message attribute filters | Rich content-based pattern matching on the event body |
| Sources | Whatever explicitly publishes to the topic | Native integration with 200+ AWS services as event sources |
| Schema tooling | None built in | Schema registry can infer and version event schemas automatically |
| Typical use | Simple pub/sub, mobile push notifications | Cross-service application event routing |
Reacting to AWS service events directly
EventBridge also receives events natively from most AWS services — an S3 upload, an EC2 state change, a CodePipeline stage completion — without any custom publishing code at all:
{
"source": ["aws.s3"],
"detail-type": ["Object Created"],
"detail": {
"bucket": { "name": ["my-uploads-bucket"] }
}
}Routed to a Lambda function, this is the standard pattern for "process a file the moment it's uploaded" — resize an image, parse a CSV, trigger a downstream pipeline — with no polling anywhere in the system.
Retries, dead-letter queues, and failure handling
Async Lambda invocations (which is how EventBridge invokes targets) retry automatically on failure — twice, with backoff, by default — before giving up. Production event-driven systems should configure an explicit destination for what happens after that:
aws lambda put-function-event-invoke-config \
--function-name fraud-review \
--maximum-retry-attempts 2 \
--destination-config '{"OnFailure":{"Destination":"arn:aws:sqs:us-east-1:123456789012:fraud-review-dlq"}}'Without this, an event that fails processing after retries are exhausted is simply dropped — no error surfaces anywhere, and no one is notified. A dead-letter queue turns a silent failure into an inspectable, replayable one.
Schema discovery and versioning events
EventBridge's schema registry can automatically infer a JSON Schema from events flowing through a bus, and generate strongly-typed code bindings (in Java, Python, TypeScript, and others) from that schema — useful once an event has more than one or two consumers, since it turns "guess the event's shape by reading a producer's code" into a shared, versioned contract:
aws schemas describe-schema \
--registry-name myapp-events \
--schema-name myapp.orders@OrderPlacedSchema versions are tracked automatically as the inferred shape changes over time, which is genuinely useful for catching an unannounced breaking change to an event's structure — a new required field, or a renamed one — before it silently breaks a consumer that assumed the old shape.
Cost model: where event-driven billing actually helps
The cost advantage of Lambda-plus-EventBridge over an always-on service is sharpest for genuinely spiky or low-volume workloads — a function invoked a few thousand times a day pays only for those few thousand invocations' actual execution time, with nothing billed during the (likely much larger) idle time in between. EventBridge itself bills per million events published (custom events; AWS service-generated events routed through the default bus are free), which is a marginal cost compared to compute for almost any realistic event volume.
Always-on t3.small handling the same load: ~$15/month regardless of actual traffic
Lambda handling 5,000 invocations/day at 200ms each: often under $1/month
The crossover point where an always-on server becomes cheaper than Lambda is real, but it requires sustained, high, predictable throughput — a workload genuinely running near-continuously at high concurrency. Most workloads that are event-driven by nature (reacting to uploads, orders, scheduled jobs) never approach that crossover, which is exactly why this architecture is the default recommendation for them.
Orchestrating multi-step workflows with Step Functions
A single Lambda function reacting to a single event covers a lot of real use cases, but a genuine multi-step process — process a payment, then update inventory, then send a confirmation, with real branching and retry logic between each step — usually shouldn't be crammed into one long Lambda function or chained together with ad hoc EventBridge rules calling each other. AWS Step Functions exists specifically for this: a state machine defined declaratively, where each state can be a Lambda invocation, and Step Functions itself handles retries, error branching, and parallel execution between them.
{
"StartAt": "ProcessPayment",
"States": {
"ProcessPayment": {
"Type": "Task",
"Resource": "arn:aws:lambda:us-east-1:123456789012:function:process-payment",
"Next": "UpdateInventory",
"Retry": [{ "ErrorEquals": ["States.TaskFailed"], "MaxAttempts": 3 }]
},
"UpdateInventory": {
"Type": "Task",
"Resource": "arn:aws:lambda:us-east-1:123456789012:function:update-inventory",
"End": true
}
}
}EventBridge and Step Functions compose naturally: an EventBridge rule can trigger a Step Functions execution directly as its target, and a Step Functions state machine can itself call PutEvents at any point to publish domain events other consumers react to — the two aren't competing tools, EventBridge handles routing and fan-out, Step Functions handles ordered, stateful orchestration within a single business process.
Idempotency: the detail that prevents duplicate processing
Both EventBridge and Lambda's async invocation model provide at-least-once delivery, not exactly-once — the same event can, in rare failure scenarios (a retry after a timeout where the first attempt actually succeeded), be delivered and processed more than once. Any Lambda function reacting to an event that has a real-world side effect (charging a payment, sending an email, decrementing inventory) needs to handle this explicitly, typically by tracking a unique event ID and short-circuiting if it's already been processed:
import boto3
table = boto3.resource("dynamodb").Table("processed-events")
def handler(event, context):
event_id = event["id"]
try:
table.put_item(Item={"eventId": event_id}, ConditionExpression="attribute_not_exists(eventId)")
except table.meta.client.exceptions.ConditionalCheckFailedException:
print(f"Event {event_id} already processed, skipping")
return
# real processing happens only once, past this pointDynamoDB's conditional write (attribute_not_exists) is what makes this safe under concurrent duplicate deliveries — two simultaneous invocations for the same event ID can't both "win" the check, so the processing logic genuinely runs exactly once even if the event itself was delivered twice.
Common mistakes
- Assuming EventBridge itself retries failed deliveries. The retry behavior comes from Lambda's async invocation model, not EventBridge — a target that isn't Lambda (an HTTP endpoint via an API destination, for instance) has a different, more limited retry policy worth checking explicitly.
- Not configuring a dead-letter queue or on-failure destination, and only discovering dropped events when a customer reports something that silently never happened.
- Writing an overly broad event pattern (matching on
sourcealone with nodetailfiltering) and relying on the Lambda function itself to filter further in code — this wastes invocations (and money) on events the function immediately discards; push the filtering into the rule instead. - Treating EventBridge as a message queue for high-throughput, ordered processing. It's built for routing and fan-out, not strict ordering or backpressure — genuinely high-volume sequential processing usually belongs behind SQS (optionally as an EventBridge target) instead.
When this architecture actually pays off
- Genuinely event-driven, bursty, or infrequent workloads (file processing, scheduled jobs, reacting to state changes) are the strongest fit — not every workload benefits from decomposing into events.
- Push filtering logic into EventBridge rules rather than Lambda code — it's cheaper (no invocation for events that would be discarded anyway) and keeps routing logic visible and centralized rather than buried across many functions.
- Always configure a dead-letter destination for anything where a silently dropped event is a real problem — which, in practice, is almost everything running in production.
- Use a custom event bus per application domain (not the default bus for your own application events) — it keeps your own events cleanly separated from AWS service events and from other applications' events sharing the account.
Related reading
- Core AWS Services Every Developer Should Know — shares tags: cloud, devops (same category).
- AWS Cost Optimization: A Service-by-Service Playbook — shares tags: cloud, devops.
- Deploying a 3-Tier Application on AWS with Public and Private Subnets — shares tags: cloud, devops.
- What Is GitOps? Principles, Workflow, and Why Teams Adopt It — shares tags: devops.
- Understanding RAG: Retrieval-Augmented Generation Explained — shares tags: programming.