For a long time my default way to connect two services was the obvious one: service A calls service B. A POST, a Lambda invoking another, a write that fires a webhook. It works until you have five consumers of the same event and every new consumer forces you to touch the producer. That's where EventBridge changed my architecture: the producer stops knowing who's listening. It emits "this happened" to a bus and forgets about it. Consumers subscribe on their own with pattern rules. I chose this model in several AWS services because the coupling between teams was costing me more than the extra latency; in exchange, I accepted that debugging an event-driven system is harder than following a synchronous call. This article is about when it's worth it and when it isn't.
The problem it solves: the producer shouldn't know its consumers
Picture a simple domain event: OrderConfirmed. When an order is confirmed, you need to bill, notify the customer, reserve inventory, and update analytics. The naive version has the orders service call all four. The day marketing wants to hear about it too, someone has to open the orders code, add the fifth call, test it, and deploy it. The producer piles up responsibilities that aren't its own.
With an event bus, the orders service emits a single event and is done. Each consumer decides whether it cares. Adding marketing means creating a new rule pointing at its target, without touching orders. Coupling shifts from "code to code" to "event schema to event schema," which is far cheaper to maintain.
The rules pattern: filter on the bus, not in the consumer
What I liked most about EventBridge over a classic SNS is that filtering lives in the rule, declaratively, over the event content. I don't hand the event to a Lambda so it can decide whether it matters: the bus only delivers what matches.
{
"source": ["orders.service"],
"detail-type": ["OrderConfirmed"],
"detail": {
"total": [{ "numeric": [">", 500] }],
"country": ["ES", "MX", "CO"]
}
}
That rule only fires for confirmed orders over 500 in three countries. The consumer doesn't run even once for the rest. That if used to live inside the function, invoked millions of times to discard most cases; now the discard is free and doesn't show up in my Lambda bill or my logs.
Where EventBridge is NOT the answer
Being honest about the trade-offs is what separates an architecture decision from an act of faith. EventBridge isn't free on complexity.
If you need the consumer's response, don't use a bus. It's fire-and-forget: you emit and you don't know what happened next unless you set up another event coming back. For a request/response flow —the user is waiting for a result on screen— a direct synchronous call is still the right thing. Dropping an event bus in there only adds latency and a state machine nobody asked for.
I also don't use it when strict ordering is a hard requirement. EventBridge doesn't guarantee order, and it can deliver the same event more than once. If your case needs to process things in exact sequence, an SQS FIFO queue fits better. In fact, a pattern that works for me is EventBridge for the fan-out and SQS as a buffer in front of each slow consumer, combining the best of both.
💡 The question that decides everything: does the producer need to know what happened to the consumer? If the answer is yes, you don't have an event, you have a call. Don't dress it up as event-driven.
At-least-once delivery forces you to be idempotent
This is the detail people discover in production, not in design. EventBridge guarantees at-least-once delivery, which means your consumer can receive OrderConfirmed twice for the same order. If you bill on every reception, you just charged twice.
The solution isn't praying it won't happen: it's designing the consumer so that processing the same event twice yields the same result as processing it once. An idempotency key per event, a table that records "I already processed this ID," and an INSERT ... ON CONFLICT DO NOTHING in Postgres before doing the work with side effects.
async function handle(event: OrderConfirmed) {
const inserted = await db.processedEvents.createIfAbsent(event.id);
if (!inserted) return; // already processed, exit clean
await bill(event);
}
It's not elegant code, it's code that survives reality. Any event-driven architecture that doesn't treat idempotency as a first-class requirement is building on sand.
Observability: the real price I paid
The most expensive thing about adopting EventBridge wasn't the service, it was observability. In a synchronous call you follow the stack trace and see the whole path. On a bus, the producer emitted and left; the consumer failed three hops later; and correlating the two requires that you propagated a correlationId in every event's detail from day one.
I learned it late and paid for it in incidents that cost extra hours just because I couldn't join cause to effect. Now every event carries its correlation ID, every consumer logs it, and a DLQ per rule captures failures so no event is silently lost. With that, the decoupling pays off. Without it, you're trading a coupling problem for a blind-debugging one, and it's not clear you come out ahead.