← Back to Projects

Distributed Chat App

GitHub

1. Overview

This project is a distributed chat system built to explore the engineering challenges behind high-throughput real-time messaging at scale. The goal was to build something that could handle serious load, handle failures gracefully, and separate the concerns of message delivery and message persistence cleanly.

The system benchmarks at 13,000 messages per second under load testing, with production-grade fault tolerance patterns throughout.

2. System Architecture

Four Spring Boot WebSocket servers sit behind an Application Load Balancer. RabbitMQ handles all message routing between servers, ensuring a message sent on server A reaches a recipient connected to server B. Dedicated consumers read from RabbitMQ and write to Cassandra for persistence, completely decoupled from the delivery path. Redis handles caching for active session data and recent messages. Everything runs on self-managed AWS EC2 instances.

flowchart TD classDef active stroke-width:2px Client["Client\nWebSocket connection"] ALB["AWS ALB\nLoad Balancer"] S1["Spring Boot Server 1\nWebSocket"] S2["Spring Boot Server 2\nWebSocket"] S3["Spring Boot Server 3\nWebSocket"] S4["Spring Boot Server 4\nWebSocket"] MQ["RabbitMQ\nMessage Routing"] DC["Delivery Consumer\nRoutes to target server"] PC["Persistence Consumer\nWrites asynchronously"] Cass["Cassandra\nMessage Storage"] Redis["Redis\nSession Cache"] Client --> ALB ALB --> S1 & S2 & S3 & S4 S1 & S2 & S3 & S4 --> MQ MQ --> DC MQ --> PC PC --> Cass DC --> Redis class MQ active

The infrastructure is intentionally self-managed rather than using managed services. Running your own servers forces you to understand what the managed abstractions are actually doing, which was the point of the project.

3. Message Flow

When a user sends a message, the following sequence happens:

  • The client sends the message over its WebSocket connection to whichever server it is connected to.
  • That server publishes the message to RabbitMQ on two separate queues: one for delivery, one for persistence. These are independent so a slow write to Cassandra cannot block delivery to the recipient.
  • The delivery consumer routes the message to the target server. The target server pushes it over the recipient's WebSocket connection.
  • The persistence consumer writes the message to Cassandra asynchronously.
  • Redis caches active session state and recent message history so reconnecting clients do not hit Cassandra on every load.
flowchart LR classDef active stroke-width:2px Send["Client sends message\nover WebSocket"] WS["WebSocket Server\nreceives message"] MQ["Publish to RabbitMQ\ntwo queues"] Deliver["Delivery Queue\npush to recipient"] Persist["Persist Queue\nwrite to Cassandra"] Send --> WS --> MQ MQ --> Deliver MQ --> Persist class MQ active

4. Reliability Patterns

  • Dead letter queues: messages that fail processing after a configurable number of retries move to a dead letter queue for inspection rather than being dropped silently.
  • Exponential backoff: transient failures on the consumer side retry with increasing delays to avoid hammering a recovering downstream system.
  • Circuit breaker: if Cassandra or Redis becomes unavailable, the circuit breaker trips and stops repeated failed calls from accumulating, giving the system time to recover without cascading the failure.
  • Delivery and persistence decoupling: because delivery and persistence are separate consumers on separate queues, a Cassandra outage does not interrupt message delivery. Messages queue up in the persistence queue until Cassandra recovers.

5. Engineering Trade-offs

  • Cassandra over PostgreSQL: chat message storage is an append-heavy workload with high read volume for recent history. Cassandra's wide-column model with time-based partitioning handles this well and scales horizontally without the write bottlenecks that come from PostgreSQL at high insert rates.
  • RabbitMQ over Kafka: the messaging patterns here are point-to-point routing between servers, not a durable event log with multiple independent consumers. RabbitMQ's exchange and queue model fits this exactly. Kafka would have added complexity without a clear benefit for this use case.
  • Self-managed EC2 over managed services: this was a deliberate choice to learn the operational layer. Running your own servers forces you to understand what managed services are actually abstracting. ECS or EKS would have been more practical in production.
  • Four servers: enough to demonstrate load balancing and cross-server message routing without the cost of running a larger cluster. The architecture scales horizontally by adding more servers behind the ALB.