AWS Jul 7, 2026 · 8 min read

EventBridge: decoupling services without building a queue monster

Yohangel Ramos

Yohangel Ramos

Tech Lead · Senior Fullstack Developer

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.

Yohangel Ramos

Written by Yohangel Ramos

Senior Fullstack Developer and Tech Lead. I build with React, Next.js, Nest.js and AWS — and I write about what I learn along the way.

Let's talk →

Keep reading

IA

AI-built startups in 2026: the real numbers behind the hype

IA

How to survive as a programmer in the AI era (without turning cynical or naive)