Case Study · Real-time Social Backend

Social Media Backend: 5K Concurrent WebSockets

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.

Tech: Python · Django · Django Channels · PostgreSQL · Redis · WebSockets · ~4 weeks

System Architecture

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

Real-time Layer (Django Channels)

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.

Consumer structure

class NotificationConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        self.user = self.scope["user"]
        if self.user.is_anonymous:
            await self.close()
            return
        # Join user-specific group
        self.group_name = f"user_{self.user.id}"
        await self.channel_layer.group_add(self.group_name, self.channel_name)
        await self.accept()

    async def disconnect(self, close_code):
        await self.channel_layer.group_discard(self.group_name, self.channel_name)

    async def send_notification(self, event):
        await self.send(text_data=json.dumps(event["data"]))

Connection scaling

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 writeO(1) — Redis ZRANGEO(followers) asyncHigher (duplicate post IDs)
Fan-out on readO(followed × posts)O(1)Lower
Hybrid (celebrity)O(1) for most, merge for celebsO(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

  1. Sender sends message via WebSocket → Django Channels consumer
  2. Consumer validates, saves to PostgreSQL (Message model)
  3. Consumer publishes to Redis channel chat:{conversation_id}
  4. All online participants in that conversation receive via WebSocket
  5. For offline users: Celery task sends push notification (FCM/APNs)

Message model (simplified)

class Message(models.Model):
    conversation = models.ForeignKey(Conversation, on_delete=models.CASCADE)
    sender = models.ForeignKey(User, on_delete=models.CASCADE)
    content = models.TextField()
    message_type = models.CharField(choices=[('text', 'Text'), ('image', 'Image'), ('file', 'File')])
    read_by = models.ManyToManyField(User, related_name='read_messages', through='MessageRead')
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        indexes = [
            models.Index(fields=['conversation', '-created_at']),  # Pagination
            models.Index(fields=['sender', 'created_at']),
        ]

Data Model Highlights

PostgreSQL for relational integrity: users, follows, posts, conversations, messages, notifications. Proper foreign keys, indexes, and constraints prevent data corruption at scale.

Key tables & indexes

Scaling Metrics

Load test: 5,200 concurrent WebSocket connections, 1,200 msg/sec sustained, <50ms p99 delivery latency. Cost: ~$220/month (4x Daphne, 3x Gunicorn, PostgreSQL db.t3.medium, ElastiCache r6g.large).

Bottlenecks discovered & fixed

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.

ADR-002: Fan-out on write for feeds

Decision: Pre-compute feeds in Redis. Context: Read-heavy (100:1 read:write), followers < 5K typical. Trade-off: Write amplification; celebrity problem. Solved with hybrid approach.

ADR-003: PostgreSQL for messages (not MongoDB)

Decision: Relational model for conversations, participants, read receipts. Context: Complex queries (unread counts, search, compliance). Trade-off: More schema rigidity; mitigated by JSONB for extensible message metadata.

Explore More

View other backend case studies or get in touch to discuss your project.