0x55aa
← Back to Blog

Event-Driven Architecture: Stop Waiting for Things to Happen šŸŽ­āš”

•15 min read

Event-Driven Architecture: Stop Waiting for Things to Happen šŸŽ­āš”

Real talk: The first time a customer complained that our checkout took 12 seconds, I thought our database was dying. Nope - the API was just sitting there WAITING for:

  • Email service to send receipt (3s)
  • Inventory service to update stock (2s)
  • Analytics service to log the purchase (1s)
  • Recommendation engine to update (4s)
  • Loyalty points service to credit account (2s)

The user just wanted to buy a damn coffee mug and we're making them wait for 5 different services to finish! 😱

Welcome to event-driven architecture - where you stop waiting and start living!

What's Event-Driven Architecture Anyway? šŸ¤”

Think of it like a restaurant:

Traditional synchronous architecture (request-response):

Customer: "I'd like a burger"
Waiter: *walks to kitchen*
Waiter: *waits for chef to cook burger*
Waiter: *waits for chef to plate it*
Waiter: *waits for chef to add fries*
Waiter: *brings burger back*
Customer: *has been waiting 20 minutes* 😤

Event-driven architecture:

Customer: "I'd like a burger"
Waiter: "Sure thing!" *drops order ticket and walks away*
Kitchen: *sees ticket, starts cooking*
Fry station: *sees ticket, makes fries*
Plating: *assembles when ready*
Runner: *brings food when done*
Customer: *gets their burger, didn't even notice they were waiting* 😊

Translation: Instead of waiting for responses, you just ANNOUNCE that something happened (an event) and let other services react independently!

The Blocking Nightmare That Taught Me Events šŸ’€

When I architected our e-commerce checkout at my previous company, I naively built it like this:

Synchronous blocking nightmare:

app.post('/api/checkout', async (req, res) => {
  try {
    // Step 1: Create order (200ms)
    const order = await orderService.createOrder(req.body);

    // Step 2: Process payment (800ms)
    const payment = await paymentService.charge(order.total);

    // Step 3: Send confirmation email (3000ms) 😱
    await emailService.sendReceipt(order.id);

    // Step 4: Update inventory (500ms)
    await inventoryService.decrementStock(order.items);

    // Step 5: Update analytics (400ms)
    await analyticsService.trackPurchase(order);

    // Step 6: Credit loyalty points (600ms)
    await loyaltyService.creditPoints(order.userId, order.total);

    // Step 7: Update recommendations (1200ms)
    await recommendationService.updateAfterPurchase(order);

    // FINALLY respond to user
    res.json({ success: true, orderId: order.id });
    // Total time: 6.7 seconds! 🐌
  } catch (error) {
    // If ANY service fails, ENTIRE checkout fails! šŸ’„
    res.status(500).json({ error: 'Checkout failed' });
  }
});

What happened in production:

  • Response time: 6-8 seconds (users thought it was broken!)
  • Email service crashed → checkout failed (WTF?!)
  • 40% cart abandonment rate
  • My boss: "Why is our conversion rate so bad?"
  • Me: "Learning about event-driven architecture..." šŸ˜…

The event-driven approach:

const EventEmitter = require('events');
const eventBus = new EventEmitter();

app.post('/api/checkout', async (req, res) => {
  try {
    // Step 1: Create order (200ms)
    const order = await orderService.createOrder(req.body);

    // Step 2: Process payment (800ms) - CRITICAL, must wait
    const payment = await paymentService.charge(order.total);

    // Step 3: Emit event and IMMEDIATELY respond!
    eventBus.emit('order.placed', {
      orderId: order.id,
      userId: order.userId,
      items: order.items,
      total: order.total,
      timestamp: Date.now()
    });

    // User gets response in 1 second! šŸš€
    res.json({ success: true, orderId: order.id });

  } catch (error) {
    res.status(500).json({ error: 'Checkout failed' });
  }
});

// Background event handlers (run independently!)
eventBus.on('order.placed', async (event) => {
  await emailService.sendReceipt(event.orderId);
  // If this fails, checkout still succeeded!
});

eventBus.on('order.placed', async (event) => {
  await inventoryService.decrementStock(event.items);
});

eventBus.on('order.placed', async (event) => {
  await analyticsService.trackPurchase(event);
});

eventBus.on('order.placed', async (event) => {
  await loyaltyService.creditPoints(event.userId, event.total);
});

eventBus.on('order.placed', async (event) => {
  await recommendationService.updateAfterPurchase(event);
});

Results:

  • Response time: 6.7s → 1.0s (85% faster!)
  • Email service crashes? Checkout still works! āœ…
  • Cart abandonment: 40% → 12%
  • My boss: "What changed? Conversions are up!"
  • Me: šŸ˜Ž

Core Event-Driven Patterns šŸŽÆ

Pattern #1: Fire and Forget

Use case: User doesn't need to wait for the result

// Traditional: User waits for email
app.post('/api/register', async (req, res) => {
  const user = await createUser(req.body);
  await emailService.sendWelcomeEmail(user.email); // 2s wait!
  res.json({ success: true });
});

// Event-driven: User gets instant response
app.post('/api/register', async (req, res) => {
  const user = await createUser(req.body);

  events.emit('user.registered', { userId: user.id, email: user.email });

  res.json({ success: true }); // Instant!
});

// Email sent in background
events.on('user.registered', async (event) => {
  await emailService.sendWelcomeEmail(event.email);
});

When I use this: Emails, notifications, analytics, logging - anything the user doesn't need to wait for!

Pattern #2: Event Sourcing

Instead of storing current state, store ALL events that led to that state!

Traditional state:

// Database: Just the current state
{
  orderId: "123",
  status: "delivered",
  total: 99.99
}
// How did we get here? Who knows! 🤷

Event sourcing:

// Database: The complete history!
[
  { event: 'order.created', orderId: '123', total: 99.99, timestamp: 1234567890 },
  { event: 'payment.processed', orderId: '123', amount: 99.99, timestamp: 1234567895 },
  { event: 'order.shipped', orderId: '123', trackingId: 'ABC123', timestamp: 1234568000 },
  { event: 'order.delivered', orderId: '123', timestamp: 1234570000 }
]

// Reconstruct current state by replaying events
function getCurrentState(orderId) {
  const events = db.getEvents(orderId);
  let state = {};

  events.forEach(event => {
    if (event.event === 'order.created') state.status = 'pending';
    if (event.event === 'payment.processed') state.status = 'paid';
    if (event.event === 'order.shipped') state.status = 'shipped';
    if (event.event === 'order.delivered') state.status = 'delivered';
  });

  return state;
}

Why this is POWERFUL:

  • āœ… Complete audit trail (see EVERY change)
  • āœ… Time travel debugging (replay to any point)
  • āœ… Never lose data (only append, never delete)
  • āœ… Multiple read models from same events

When designing our e-commerce backend, event sourcing saved us during a fraud investigation! We could replay EVERY action the user took to prove they were legit!

Pattern #3: CQRS (Command Query Responsibility Segregation)

Fancy name, simple idea: Different models for reading vs writing!

// Write model: Optimized for fast writes
class OrderCommandModel {
  async createOrder(orderData) {
    const order = await db.orders.insert(orderData);
    events.emit('order.created', order);
    return order.id;
  }

  async cancelOrder(orderId) {
    await db.orders.update(orderId, { status: 'cancelled' });
    events.emit('order.cancelled', { orderId });
  }
}

// Read model: Optimized for fast queries
class OrderQueryModel {
  constructor() {
    // Build denormalized views for fast reads
    events.on('order.created', this.updateOrderView);
    events.on('order.cancelled', this.updateOrderView);
  }

  async getOrdersForUser(userId) {
    // Precomputed, blazing fast!
    return await cache.get(`user:${userId}:orders`);
  }

  async updateOrderView(event) {
    // Keep read cache updated
    const orders = await db.getOrdersForUser(event.userId);
    await cache.set(`user:${event.userId}:orders`, orders);
  }
}

Benefits:

  • āœ… Writes don't slow down reads
  • āœ… Reads don't slow down writes
  • āœ… Scale them independently!

A scalability lesson that cost us: Before CQRS, complex order queries locked tables and slowed down checkouts. After: queries hit read cache, checkouts were instant! šŸš€

Pattern #4: Saga Pattern (Distributed Transactions)

The problem: How do you handle a transaction across multiple services?

Example: Booking a trip

  1. Reserve flight
  2. Reserve hotel
  3. Charge credit card
  4. Send confirmation

If step 3 fails, you need to UNDO steps 1 and 2!

Saga solution:

// Saga orchestrator
class TripBookingSaga {
  async execute(bookingData) {
    const saga = {
      bookingId: uuid(),
      steps: [],
      status: 'pending'
    };

    try {
      // Step 1: Reserve flight
      const flight = await flightService.reserve(bookingData.flight);
      saga.steps.push({ service: 'flight', reservationId: flight.id });
      events.emit('saga.step.completed', { sagaId: saga.bookingId, step: 'flight' });

      // Step 2: Reserve hotel
      const hotel = await hotelService.reserve(bookingData.hotel);
      saga.steps.push({ service: 'hotel', reservationId: hotel.id });
      events.emit('saga.step.completed', { sagaId: saga.bookingId, step: 'hotel' });

      // Step 3: Charge card
      const payment = await paymentService.charge(bookingData.total);
      saga.steps.push({ service: 'payment', transactionId: payment.id });
      events.emit('saga.completed', { sagaId: saga.bookingId });

      return { success: true, bookingId: saga.bookingId };

    } catch (error) {
      // OH NO! Rollback everything!
      await this.compensate(saga);
      events.emit('saga.failed', { sagaId: saga.bookingId, error: error.message });
      throw error;
    }
  }

  async compensate(saga) {
    // Undo steps in reverse order
    for (const step of saga.steps.reverse()) {
      if (step.service === 'flight') {
        await flightService.cancelReservation(step.reservationId);
      }
      if (step.service === 'hotel') {
        await hotelService.cancelReservation(step.reservationId);
      }
      if (step.service === 'payment') {
        await paymentService.refund(step.transactionId);
      }
    }
  }
}

Why sagas are crucial for microservices:

  • āœ… No distributed transactions (they don't scale!)
  • āœ… Each service stays independent
  • āœ… Handles failures gracefully
  • āœ… Maintains eventual consistency

In production, I've learned: Sagas are complex but NECESSARY when you need cross-service transactions!

Event Bus Technologies (What Should You Use?) šŸ› ļø

Option #1: RabbitMQ (My Go-To for Startups)

Why I love RabbitMQ:

  • āœ… Easy to set up
  • āœ… Message persistence
  • āœ… Flexible routing (topics, fanout, direct)
  • āœ… Battle-tested

Quick example:

const amqp = require('amqplib');

// Publisher
async function publishEvent(eventName, data) {
  const connection = await amqp.connect('amqp://localhost');
  const channel = await connection.createChannel();

  await channel.assertExchange('events', 'topic', { durable: true });

  channel.publish('events', eventName, Buffer.from(JSON.stringify(data)), {
    persistent: true
  });

  console.log(`Published: ${eventName}`);
}

// Subscriber
async function subscribeToEvents() {
  const connection = await amqp.connect('amqp://localhost');
  const channel = await connection.createChannel();

  await channel.assertExchange('events', 'topic', { durable: true });
  const queue = await channel.assertQueue('email-service', { durable: true });

  // Subscribe to order events
  await channel.bindQueue(queue.queue, 'events', 'order.*');

  channel.consume(queue.queue, (msg) => {
    const event = JSON.parse(msg.content.toString());
    console.log('Received:', event);

    // Process event
    handleEvent(event);

    // Acknowledge message
    channel.ack(msg);
  });
}

await publishEvent('order.placed', { orderId: '123', total: 99.99 });

Docker setup:

docker run -d --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:3-management

Option #2: Apache Kafka (For High-Volume Production)

When to use Kafka:

  • āœ… Need to handle millions of events/sec
  • āœ… Want event replay (Kafka keeps events!)
  • āœ… Building real-time data pipelines
  • āœ… Multiple consumers need same events
const { Kafka } = require('kafkajs');

const kafka = new Kafka({
  clientId: 'my-app',
  brokers: ['localhost:9092']
});

// Producer
const producer = kafka.producer();
await producer.connect();

await producer.send({
  topic: 'order-events',
  messages: [
    {
      key: 'order-123',
      value: JSON.stringify({ orderId: '123', total: 99.99 }),
      timestamp: Date.now()
    }
  ]
});

// Consumer
const consumer = kafka.consumer({ groupId: 'email-service' });
await consumer.connect();
await consumer.subscribe({ topic: 'order-events', fromBeginning: false });

await consumer.run({
  eachMessage: async ({ topic, partition, message }) => {
    const event = JSON.parse(message.value.toString());
    console.log('Processing:', event);
    await handleOrderEvent(event);
  }
});

Kafka vs RabbitMQ:

Feature RabbitMQ Kafka
Setup complexity Easy Medium
Throughput 10K msg/sec 1M+ msg/sec
Message retention Delete after consume Keep for days/weeks
Use case Task queues Event streams

My setup: RabbitMQ for our e-commerce backend, Kafka for analytics pipeline!

Option #3: AWS EventBridge (Serverless Events)

When on AWS:

const AWS = require('aws-sdk');
const eventBridge = new AWS.EventBridge();

// Publish event
await eventBridge.putEvents({
  Entries: [{
    Source: 'order-service',
    DetailType: 'order.placed',
    Detail: JSON.stringify({ orderId: '123', total: 99.99 }),
    EventBusName: 'my-event-bus'
  }]
}).promise();

// Subscribe (via EventBridge Rule + Lambda)
// Rule: { "detail-type": ["order.placed"] }
exports.handler = async (event) => {
  const orderData = event.detail;
  await sendEmail(orderData);
};

Pros: Fully managed, serverless, pay-per-event Cons: AWS lock-in, less flexible than Kafka

Option #4: Redis Pub/Sub (Lightweight Events)

For simple use cases:

const redis = require('redis');
const publisher = redis.createClient();
const subscriber = redis.createClient();

// Publish
publisher.publish('order-events', JSON.stringify({ orderId: '123' }));

// Subscribe
subscriber.subscribe('order-events');
subscriber.on('message', (channel, message) => {
  const event = JSON.parse(message);
  console.log('Received:', event);
});

Pros: Super simple, minimal setup Cons: No persistence (message lost if subscriber is down!)

When architecting on AWS, I learned: Use EventBridge for serverless, RabbitMQ for traditional apps, Kafka for high-volume!

Common Event-Driven Mistakes (I Made All of These) 🪤

Mistake #1: Event Order Assumptions

// BAD: Assuming events arrive in order
events.on('order.paid', async (event) => {
  await updateOrderStatus(event.orderId, 'paid');
});

events.on('order.shipped', async (event) => {
  await updateOrderStatus(event.orderId, 'shipped');
  // What if 'shipped' arrives before 'paid'?! šŸ’„
});

// GOOD: Check current state first
events.on('order.shipped', async (event) => {
  const order = await getOrder(event.orderId);

  if (order.status !== 'paid') {
    console.warn(`Order ${event.orderId} shipped before payment!`);
    // Queue for retry or handle specially
    return;
  }

  await updateOrderStatus(event.orderId, 'shipped');
});

The lesson: Events can arrive OUT OF ORDER! Always validate state!

Mistake #2: Not Handling Duplicates

The reality: Events can be delivered TWICE (network issues, retries, etc.)

// BAD: Process every event blindly
events.on('payment.received', async (event) => {
  await creditUserAccount(event.userId, event.amount);
  // User gets double credit if event is duplicated! 😱
});

// GOOD: Idempotent handling
events.on('payment.received', async (event) => {
  const processed = await db.processedEvents.findOne({ eventId: event.id });

  if (processed) {
    console.log(`Event ${event.id} already processed, skipping`);
    return;
  }

  await creditUserAccount(event.userId, event.amount);
  await db.processedEvents.insert({ eventId: event.id, processedAt: Date.now() });
});

A scalability lesson that cost us: A network blip caused payment events to be processed twice. User got double loyalty points. We had to manually fix 2,000 accounts! 😭

Mistake #3: Event Data Bloat

// BAD: Huge event payloads
events.emit('order.placed', {
  order: entireOrderObject,        // 50KB
  user: entireUserProfile,          // 30KB
  products: allProductDetailsArray, // 200KB
  relatedOrders: last50Orders,      // 1MB
  // Total: 1.3MB per event! šŸ’ø
});

// GOOD: Minimal event payloads
events.emit('order.placed', {
  orderId: '123',
  userId: '456',
  total: 99.99,
  timestamp: Date.now()
  // Total: <1KB. Subscribers fetch details if needed!
});

Why this matters:

  • Smaller events = faster transmission
  • Less network cost
  • Easier to evolve schema

Mistake #4: No Dead Letter Queue

// BAD: Failed events disappear
events.on('send.email', async (event) => {
  try {
    await emailService.send(event.to, event.subject, event.body);
  } catch (error) {
    console.error('Email failed:', error);
    // Event is lost forever! šŸ’€
  }
});

// GOOD: Dead letter queue for failures
events.on('send.email', async (event) => {
  try {
    await emailService.send(event.to, event.subject, event.body);
  } catch (error) {
    console.error('Email failed, moving to DLQ:', error);

    // Send to dead letter queue for manual review
    await dlq.push({
      originalEvent: event,
      error: error.message,
      failedAt: Date.now(),
      retryCount: (event.retryCount || 0) + 1
    });
  }
});

// Background job: Retry failed events
setInterval(async () => {
  const failedEvents = await dlq.getRetryable();

  for (const item of failedEvents) {
    if (item.retryCount < 3) {
      events.emit('send.email', { ...item.originalEvent, retryCount: item.retryCount });
    } else {
      // After 3 retries, alert ops team
      await alertOps(`Email permanently failed: ${item.originalEvent.to}`);
    }
  }
}, 60000); // Every minute

In production, I've learned: Always have a DLQ! Network fails, services crash, APIs have downtime!

The Decision Tree: Sync vs Async? 🌳

Use synchronous (request-response) when:

  • āœ… User MUST wait for result (payment processing)
  • āœ… Need immediate consistency (inventory check before checkout)
  • āœ… Simple CRUD operations
  • āœ… Error must block the user action

Use event-driven (async) when:

  • āœ… User doesn't need to wait (emails, notifications)
  • āœ… Multiple systems need to react (analytics, logging, recommendations)
  • āœ… Eventual consistency is acceptable
  • āœ… Want to decouple services

My production architecture:

Synchronous:

  • Create order → Database
  • Process payment → Payment gateway
  • Check inventory → Inventory service

Event-driven:

  • Send receipt email
  • Update analytics
  • Credit loyalty points
  • Update recommendations
  • Trigger fraud detection
  • Log to data warehouse

Hybrid approach: Critical path is sync, everything else is async! šŸŽÆ

Quick Start: Your Event-Driven Checklist āœ…

Ready to add events? Start here:

  1. Choose your event bus:

    # RabbitMQ (good for most apps)
    docker run -d --name rabbitmq -p 5672:5672 rabbitmq:3
    
    # Or Redis (simpler)
    docker run -d --name redis -p 6379:6379 redis:alpine
    
  2. Identify async operations:

    • What can happen AFTER user gets response?
    • Emails, notifications, analytics, logging
  3. Define your events:

    // Event naming: <entity>.<action>
    const events = {
      ORDER_PLACED: 'order.placed',
      USER_REGISTERED: 'user.registered',
      PAYMENT_PROCESSED: 'payment.processed'
    };
    
  4. Implement publishers:

    async function createOrder(orderData) {
      const order = await db.orders.insert(orderData);
      eventBus.emit('order.placed', { orderId: order.id });
      return order;
    }
    
  5. Implement subscribers:

    eventBus.on('order.placed', async (event) => {
      await emailService.sendReceipt(event.orderId);
    });
    
  6. Add monitoring and DLQ! šŸ“Š

The Bottom Line šŸ’”

Event-driven architecture isn't about being trendy - it's about building systems that don't make users wait!

The essentials:

  1. Async for non-critical operations (emails, analytics)
  2. Events should be small (IDs, not full objects)
  3. Handle duplicates and out-of-order (always validate)
  4. Dead letter queues are mandatory (events WILL fail)
  5. Monitor event flow (what you can't see will break)

The truth about event-driven architecture:

It's not "fire events everywhere!" - it's strategic async for things users shouldn't wait for! You're trading immediate consistency for better user experience and scalability!

When designing our e-commerce backend, I learned this: Events are powerful but add complexity. Start with sync, move to async when you have a REASON (slow responses, scaling issues, coupling problems). And for the love of all that is holy, NEVER make payment processing async! šŸ’³

You don't need perfect event-driven from day one - you need strategic async where it matters! šŸš€

Your Action Plan šŸŽÆ

This week:

  1. Identify 3 operations users shouldn't wait for
  2. Set up RabbitMQ or Redis locally
  3. Move email sending to events
  4. Measure response time improvements

This month:

  1. Move analytics and logging to events
  2. Implement dead letter queue
  3. Add event monitoring dashboard
  4. Document your event schemas

This quarter:

  1. Implement CQRS for read-heavy operations
  2. Add event sourcing for critical workflows
  3. Build saga pattern for distributed transactions
  4. Become the event-driven guru on your team! šŸ†

Resources Worth Your Time šŸ“š

Tools I use daily:

  • RabbitMQ - Reliable message broker
  • Apache Kafka - High-throughput event streaming
  • Bull - Redis-based queue for Node.js

Reading list:

Real talk: The best event architecture is the one that solves YOUR problems, not the one from a blog post!


Struggling with slow response times? Connect with me on LinkedIn and share your event-driven wins!

Want to see my event architectures? Check out my GitHub - I've got examples from simple to production-scale!

Now go forth and event responsibly! šŸŽ­āš”


P.S. If your users are waiting for emails to send before getting a response, you're doing it wrong! Move that to events! šŸ“§

P.P.S. I once made payment processing async (fire and forget). Guess who had duplicate charges and lost transactions? Learn from my pain - NEVER async critical operations! Some things MUST be synchronous! šŸ’³šŸ˜±