Skip to content
Back to Build Tutorials

Event Bridge

Connect services with an event bus (Kafka/NATS/EventBridge) for decoupled, replayable communication.

Why

Point-to-point HTTP calls between services create a brittle coupling graph: service A down → B fails → C times out → D returns stale data. An event bus breaks this chain — services publish events and subscribe to what they care about, with no knowledge of who produces what. The bus also gives you replayability: when a bug in the consumer is fixed, you reprocess the last 7 days of events instead of asking users to redo their actions. This is the architectural difference between 'we lost your order because the email service was down' and 'the email will send when the service recovers'. The cost is operating a bus, but managed options (EventBridge, Upstash) make this negligible.

When

When you have ≥3 services that need to react to the same business event. Takes 1-2 days. Skip for a monolith or 2-service setup.

How

1. Choose a bus by scale: NATS (lightweight, <1ms), Kafka (durable, replayable), EventBridge (managed AWS)
   - For most SaaS: Upstash Kafka or AWS EventBridge — no infra to run
   - For low-latency: NATS with JetStream for durability

2. Define a shared event schema in a packages/events package (monorepo) or schema registry
   - Use zod or Avro; version every event with a schema_version field
   - Consumers must handle both N and N-1 versions during a rolling deploy

3. Producers publish with a topic + event type + correlation ID
   - Topic: "orders" ; event type: "OrderCreated" ; correlationId for tracing
   - Publish is fire-and-forget from the producer's perspective (at-least-once delivery)

4. Consumers subscribe with a consumer group + idempotent handler
   - Consumer group: parallel consumers share the load, each event processed once
   - Idempotency: dedupe by event ID (same as webhook idempotency)

5. Enable replay: store events with a retention of 7-30 days
   - When a consumer bug is fixed, reset the consumer offset to reprocess
   - This is the killer feature — you can't do this with point-to-point HTTP

Example

// Producer (order service)
import { Kafka } from "kafkajs";
const kafka = new Kafka({ brokers: [env.KAFKA_BROKER] });
const producer = kafka.producer();
await producer.connect();
await producer.send({
  topic: "orders",
  messages: [{
    key: order.id,
    value: JSON.stringify({
      type: "OrderCreated", schemaVersion: 1, correlationId: crypto.randomUUID(),
      data: { orderId: order.id, userId: order.userId, total: order.total },
    }),
  }],
});

// Consumer (email service) — idempotent, consumer group
const consumer = kafka.consumer({ groupId: "email-service" });
await consumer.subscribe({ topic: "orders", fromBeginning: false });
await consumer.run({
  eachMessage: async ({ message }) => {
    const event = JSON.parse(message.value.toString());
    if (await alreadySent(event.data.orderId)) return;  // idempotent
    await sendOrderConfirmation(event.data);
  },
});

Common Problems

  • ⚠Publishing events synchronously in the request handler, so a bus hiccup adds 200ms to every user request.
  • ⚠Not versioning event schemas, so a producer deploy breaks all consumers until they're also deployed.
  • ⚠Using a single topic for all events, so consumers filter through 100 irrelevant messages to find theirs.
  • ⚠Skipping idempotency because 'the bus delivers once' — Kafka is at-least-once, duplicates happen on rebalance.

Best Practices

  • ✓Publish events after the DB transaction commits (outbox pattern) — never before, or you publish events for rolled-back writes.
  • ✓Use the transactional outbox pattern: write the event to an outbox table in the same TX, a relay publishes it to the bus.
  • ✓Set per-consumer lag alerts — a consumer falling behind indicates a bug or a scaling need.
  • ✓Keep events small (<100KB); put large payloads in object storage and reference them by URL in the event.