I’ve been building distributed systems for over a decade, and recently I noticed many teams struggling with complex message brokers when simpler solutions could work better. That’s why I want to share my approach to event-driven architecture using Node.js, Redis Streams, and TypeScript. This combination has served me well in production environments, offering reliability without unnecessary complexity.
Have you ever wondered how modern applications handle thousands of events without dropping a single one? Let me show you how Redis Streams makes this possible with surprisingly simple code.
First, let’s set up our development environment. You’ll need Node.js 18 or higher and Redis 6.2+. I prefer using Docker for Redis because it keeps my local machine clean. Here’s a basic docker-compose file I use:
version: '3.8'
services:
redis:
image: redis:7-alpine
ports: ["6379:6379"]
command: redis-server --appendonly yes
For the Node.js setup, I start with a simple package.json:
{
"name": "event-system",
"version": "1.0.0",
"scripts": {
"dev": "tsx watch src/app.ts",
"build": "tsc"
},
"dependencies": {
"ioredis": "^5.3.2",
"typescript": "^5.3.3"
}
}
Redis Streams work like persistent logs where events stay available for consumption. Unlike traditional queues, streams maintain order and allow multiple consumers to read without removing events. This persistence became crucial when I needed to replay events after a system failure.
What happens when your consumer crashes mid-processing? Redis consumer groups handle this elegantly.
Here’s how I structure event metadata in TypeScript:
interface EventMetadata {
eventId: string;
type: string;
timestamp: number;
source: string;
}
interface DomainEvent<T> {
metadata: EventMetadata;
data: T;
}
The core of our system is the event bus. I create a Redis client wrapper that manages connections and retries:
class EventBus {
private redis: Redis;
constructor() {
this.redis = new Redis(process.env.REDIS_URL);
}
async publish(stream: string, event: DomainEvent): Promise<void> {
await this.redis.xadd(stream, '*',
'event', JSON.stringify(event));
}
}
Publishing events feels straightforward once the infrastructure is in place. Here’s an order service example from a recent project:
class OrderService {
private eventBus: EventBus;
async createOrder(orderData: Order): Promise<void> {
const event: DomainEvent<Order> = {
metadata: {
eventId: uuid(),
type: 'ORDER_CREATED',
timestamp: Date.now(),
source: 'order-service'
},
data: orderData
};
await this.eventBus.publish('orders', event);
}
}
Consumers need to be resilient. I use consumer groups for load balancing and fault tolerance. Each service gets its own consumer group, and within that group, multiple instances can share the load.
How do you ensure events are processed exactly once? This was a challenge I faced early on.
class PaymentConsumer {
private redis: Redis;
async processEvents(): Promise<void> {
while (true) {
const events = await this.redis.xreadgroup(
'GROUP', 'payments', 'worker1',
'BLOCK', 5000,
'STREAMS', 'orders', '>'
);
if (events) {
for (const event of events) {
try {
await this.handlePayment(event);
await this.redis.xack('orders', 'payments', event.id);
} catch (error) {
await this.moveToDeadLetter(event, error);
}
}
}
}
}
}
Dead letter queues saved me countless debugging hours. When an event fails processing after several retries, I move it to a separate stream for investigation:
async moveToDeadLetter(event: any, error: Error): Promise<void> {
const deadEvent = {
...event,
error: error.message,
failedAt: new Date().toISOString()
};
await this.redis.xadd('dead-letters', '*',
'event', JSON.stringify(deadEvent));
}
Monitoring is non-negotiable in production. I add simple metrics to track event throughput and errors:
setInterval(async () => {
const length = await redis.xlen('orders');
console.log(`Orders stream length: ${length}`);
}, 60000);
Testing event-driven systems requires a different approach. I use a memory Redis instance for tests:
describe('Order Service', () => {
let redis: Redis;
let orderService: OrderService;
beforeEach(async () => {
redis = new Redis({ lazyConnect: true });
orderService = new OrderService(redis);
});
afterEach(async () => {
await redis.quit();
});
});
Performance tuning became essential when we scaled to handling 10,000 events per second. I found that batch processing and connection pooling made significant differences.
Deploying to production involves careful planning. I always set up multiple Redis instances across availability zones and use Redis Sentinel for failover.
What surprised me most was how well this architecture handles sudden traffic spikes. The persistent nature of streams means no events are lost during overload periods.
The beauty of this approach lies in its simplicity. You get Kafka-like reliability with Redis’s operational simplicity. I’ve seen teams implement this in weeks rather than months.
If you’re considering event-driven architecture, start with Redis Streams before jumping to more complex solutions. It might be all you need.
I’d love to hear about your experiences with event-driven systems. What challenges have you faced? Share your thoughts in the comments below, and if this helped you, please like and share this with your team.