DEV Community

Cover image for EventBridge, SQS and Lambda: How to Wire Event-Driven Systems on AWS
Amir BEN YAALA
Amir BEN YAALA

Posted on Originally published at nodetech-consulting.com

EventBridge, SQS and Lambda: How to Wire Event-Driven Systems on AWS

Publish domain events to an EventBridge bus, never straight into another service's SQS queue, and never wire the bus directly to Lambda for serious workloads. The bus owns routing: one rule and one queue per consumer. Each queue owns delivery: buffering, retries, backpressure, a dead-letter queue. Here is why each hop earns its place, and when a plain queue is still the right call.

Why should publishers never write directly to SQS?

Because a queue is the private inbox of exactly one consumer. When a producer publishes into it, the producer takes a hard dependency on that consumer's existence, name, and permissions. Fan-out dies: adding a second consumer means changing producer code. And filtering, archive, and replay never existed in the first place.

  • Coupling. The producer must know the queue URL and hold sqs:SendMessage on it. Rename or split the consumer and the producer redeploys.

  • No fan-out. A queue delivers each message to one consumer pool. A second audience means a second publish call in producer code, then a third.

  • Permission sprawl. Every producer-consumer pair adds another IAM edge, and soon nobody can say who reads what without an audit.

  • Lost capabilities. No content filtering, no archive, no replay, no schema registry. Once a message is consumed, it is gone.

What changes when you publish to an EventBridge bus?

It reverses the dependency. The producer emits a domain event to the bus and stops caring who listens. Rules match on event content, and each consumer gets its own rule, its own SQS queue, and its own Lambda. Adding a consumer touches nothing on the producer's side, and the bus adds archive, replay, and a schema registry.

EventBridge also gives every target its own dead-letter queue for delivery failures, and the archive lets you replay a time range of events after a bug fix. The producer's contract shrinks to one thing: a stable envelope.

// Publish a domain event to the bus (TypeScript, AWS SDK v3)
import { EventBridgeClient, PutEventsCommand } from '@aws-sdk/client-eventbridge';
import { randomUUID } from 'node:crypto';

const client = new EventBridgeClient({});

await client.send(new PutEventsCommand({
  Entries: [{
    EventBusName: 'orders',
    Source: 'com.shop.orders',
    DetailType: 'OrderPlaced',
    Time: new Date(),
    Detail: JSON.stringify({
      id: randomUUID(),          // producer-side id: consumers dedup on this
      occurredAt: new Date().toISOString(),
      orderId: 'ord_1042',
      totalCents: 129900,
      currency: 'EUR',
    }),
  }],
}));
Enter fullscreen mode Exit fullscreen mode

Two disciplines keep the envelope durable: the producer generates the event id (consumers deduplicate on it, including after a replay), and detail carries facts, not instructions. The resulting topology: one publisher, one bus, rules fanning out to one queue per consumer, each drained by its own function.

Publisher sends events to EventBridge; rules fan out to one SQS queue per consumer, each drained by its own Lambda
One event in, any number of consumers out: the bus owns routing, the queues own delivery.

Why put SQS between the rule and the Lambda?

Because a direct rule-to-Lambda target gives you no buffer and no control. A queue between them absorbs spikes, lets Lambda poll in batches, retries on a visibility timeout you choose, and dead-letters poison messages after a bounded number of receives. It also gives every consumer an independent throttle and an off switch.

  • Backpressure. A slow consumer makes messages age in its own queue, visible in the oldest-message-age metric. Nothing is dropped, and no other consumer is affected.

  • Batching. The SQS event source delivers records in batches with partial batch responses, far cheaper than one invocation per event.

  • Per-consumer concurrency. maxConcurrency on the event source mapping caps one consumer (the floor is 2) without starving the rest of the account, and protects fragile downstream dependencies.

  • Real retries. Visibility timeout, receive count, and a redrive policy to a DLQ you can inspect and drain.

  • Pause and resume. Disable the event source mapping during an incident: events keep accumulating safely, then drain when you re-enable it.

How do you wire a rule to a queue?

Two pieces of configuration: an event pattern on the rule, and a resource policy on the queue. The pattern filters on envelope fields, so a consumer receives only what it asked for. The policy authorizes events.amazonaws.com to send messages, scoped by source ARN to that one rule. Add a dead-letter queue on the target for delivery failures.

// Event pattern on the billing rule: only large EUR orders reach this queue
{
  "source": ["com.shop.orders"],
  "detail-type": ["OrderPlaced"],
  "detail": {
    "currency": ["EUR"],
    "totalCents": [{ "numeric": [">=", 50000] }]
  }
}
Enter fullscreen mode Exit fullscreen mode

The event pattern syntax covers prefix, numeric, and exists matching, so routing decisions live in configuration rather than in consumer code. The queue side needs one policy, and forgetting it is the classic first-deployment failure: the rule matches, delivery fails, and only the rule's FailedInvocations metric tells you.

// Resource policy on the billing queue: only this rule may write to it
{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "AllowEventBridgeRule",
    "Effect": "Allow",
    "Principal": { "Service": "events.amazonaws.com" },
    "Action": "sqs:SendMessage",
    "Resource": "arn:aws:sqs:eu-west-3:123456789012:billing-orders",
    "Condition": {
      "ArnEquals": {
        "aws:SourceArn": "arn:aws:events:eu-west-3:123456789012:rule/orders/billing-large-orders"
      }
    }
  }]
}
Enter fullscreen mode Exit fullscreen mode

When is publishing straight to a queue correct?

When you are sending a command, not announcing a fact. A command targets one known handler and expects it to act: resize this image, send this receipt. Point-to-point work distribution is exactly what SQS was built for. The anti-pattern is not the queue itself; it is broadcasting events through one consumer's private inbox.

The test we apply: if a second team asking for the same message would be legitimate, it is an event and belongs on the bus. If a second reader would be a bug, it is a command, and a queue is the honest contract. Our guide on when to use SQS covers those point-to-point cases in depth.

What trade-offs does the bus impose?

Three honest costs. EventBridge does not guarantee ordering: when sequence matters, target a FIFO queue and set a message group id per aggregate. Delivery is at-least-once end to end, so consumers must deduplicate on the producer's event id. And the extra hop adds latency, typically tens of milliseconds per event.

None of this is new work. Standard queues were already at-least-once, so idempotent handlers were already mandatory; we detailed those handler patterns in our article on reliable Lambda and SQS systems. As for latency: if a user is waiting synchronously on the result, measure before adding hops. For background work, the operational control is worth far more than the milliseconds.

Direct queue or bus first: the comparison

The table compares the two wirings across the properties that decide production outcomes. Direct publishing wins on nothing except hop count. The bus-first wiring costs one resource policy and a small latency tax, and buys fan-out, filtering, replay, per-consumer retries, and producers that never change when consumers do.

Property Publisher → SQS direct Publisher → EventBridge → SQS → Lambda
Fan-out None: one queue, one consumer pool Any number of consumers via rules
Adding a consumer Producer code change and redeploy New rule and queue; producer untouched
Filtering In consumer code, after delivery Content-based, in the rule, before delivery
Replay None once messages are consumed Bus archive, replayable by time range
Retry semantics Visibility timeout on the single queue Per consumer: own timeout, receive count, DLQ
Backpressure Yes, but shared by all message types Per consumer, with its own concurrency cap
Coupling Producer knows the consumer's queue and IAM Producer knows only the bus and the envelope

The wiring checklist

Seven checks before an event pipeline ships. Each traces back to a section above, and together they guarantee the properties that matter: producers ignorant of consumers, consumers that fail independently, and events that can be replayed once the fix is deployed.

  • Producers publish domain events to the bus, never into another service's queue.

  • One rule, one queue, one function per consumer.

  • A DLQ on the EventBridge target and a redrive policy on the queue.

  • A resource policy on each queue, scoped to its rule's ARN.

  • maxConcurrency set per consumer; alarms on queue age and DLQ depth.

  • FIFO queue and message group id where order matters; idempotent consumers everywhere, deduplicating on the event id.

  • Commands go point-to-point to a queue; events go to the bus.


Originally published on the NodeTech Consulting engineering blog. We design, build and operate Node.js and AWS serverless backends.

Top comments (0)