A real-time social media backend supporting 5,000+ concurrent WebSocket connections with sub-50ms message delivery. Built with Django Channels, PostgreSQL, and Redis. Features real-time messaging, feed fan-out on write, user profiles, and content sharing.
Hybrid architecture: traditional Django views for REST API + Django Channels for WebSocket connections. Both share the same Django ORM and business logic. Redis serves as the channel layer for WebSocket message routing and as cache for feed data.
Component diagram
Load Balancer: AWS ALB (HTTP) + NLB (WebSocket) or single ALB with target groups
Django Channels extends Django to handle WebSockets, HTTP2, and other async protocols. Each WebSocket connection maps to a Consumer instance. Redis channel layer enables cross-process message broadcasting.
5K concurrent connections across 4 Daphne workers (1250 conn/worker)
Memory per connection: ~2KB (minimal consumer state)
Heartbeat: 30s ping/pong to detect dead connections
Graceful shutdown: Drain connections before worker restart (Kubernetes preStop hook)
Feed Generation: Fan-out on Write
Feed fan-out on write: when a user posts, the post ID is pushed to all followers' feed lists in Redis. Read path is O(1) — just fetch the pre-computed feed. Write path is O(followers) but async via Celery.
Why fan-out on write?
Approach
Read Latency
Write Latency
Storage
Fan-out on write
O(1) — Redis ZRANGE
O(followers) async
Higher (duplicate post IDs)
Fan-out on read
O(followed × posts)
O(1)
Lower
Hybrid (celebrity)
O(1) for most, merge for celebs
O(normal followers)
Moderate
Implementation
# On post creation (Celery task)
def fan_out_post(post_id, author_id):
follower_ids = Follow.objects.filter(following_id=author_id)\
.values_list('follower_id', flat=True)
pipeline = redis.pipeline()
for fid in follower_ids:
pipeline.zadd(f"feed:{fid}", {post_id: time.time()})
pipeline.zremrangebyrank(f"feed:{fid}", 0, -1001) # Keep latest 1000
pipeline.execute()
Celebrity handling
Users with >10K followers are excluded from fan-out. Their posts are fetched at read time via a separate "celebrity posts" key and merged with the user's personal feed.
Real-time Messaging
1:1 and group messaging via WebSocket with message persistence in PostgreSQL. Redis pub/sub routes messages to online recipients; offline recipients get push notifications via Celery.
Message flow
Sender sends message via WebSocket → Django Channels consumer
Consumer validates, saves to PostgreSQL (Message model)
Consumer publishes to Redis channel chat:{conversation_id}
All online participants in that conversation receive via WebSocket
For offline users: Celery task sends push notification (FCM/APNs)
Channel layer backlog: Redis pub/sub queue grew under burst. Fixed by increasing Daphne worker count and adding backpressure (reject new connections when queue > 10K).
Feed fan-out latency: Large follower batches blocked Celery workers. Fixed by chunking (100 followers/batch) and priority queue for high-priority users.
PostgreSQL connection exhaustion: Default pool (20) too small for async workers. Increased to 100 per worker + PgBouncer.
Key Architecture Decisions
ADR-001: Django Channels over Node.js/Socket.io for real-time
Decision: Stay in Python/Django ecosystem. Context: Team expertise, shared models/auth, single codebase. Trade-off: Channels less mature than Socket.io; higher memory per connection. Mitigated by worker scaling.