CloudAdvanced

AWS Lambda and Event-Driven Architecture with EventBridge

How Lambda and EventBridge fit together to build event-driven systems on AWS — event patterns, fan-out, retries, and where this architecture actually beats a always-on server.

DevFieldGuideJuly 31, 20269 min read
Share:

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.

Prerequisites
  • 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.

Event occursS3 upload, API call, scheduled time, custom app event
EventBridge rule matchesContent-based pattern match against the event
Target invokedLambda, SQS, Step Functions, another EventBridge bus
Async processingProducer doesn't wait for this to complete

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

python
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.

json
{
  "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.

bash
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:

python
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

AspectSNS fan-outEventBridge fan-out
FilteringTopic-based, or basic message attribute filtersRich content-based pattern matching on the event body
SourcesWhatever explicitly publishes to the topicNative integration with 200+ AWS services as event sources
Schema toolingNone built inSchema registry can infer and version event schemas automatically
Typical useSimple pub/sub, mobile push notificationsCross-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:

json
{
  "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:

bash
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:

bash
aws schemas describe-schema \
  --registry-name myapp-events \
  --schema-name myapp.orders@OrderPlaced

Schema 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.

json
{
  "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:

python
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 point

DynamoDB'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

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 source alone with no detail filtering) 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

Best practices
  1. 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.
  2. 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.
  3. 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.
  4. 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.

Frequently Asked Questions

DevFieldGuide
DevFieldGuide

Editorial Team

Practical tutorials and developer tools, written and maintained by the DevFieldGuide team.

Enjoyed this article?

Get the next one straight to your inbox, along with the best of what we publish each week.

Related Articles

More in Cloud

View all