← Back to DevBytes

Designing a Social Media Feed with CQRS and Event Sourcing

Understanding the Social Media Feed Problem

A social media feed is deceptively complex. At first glance it looks like a simple list of posts sorted by time, but under the hood it involves multiple read models, cross-service data aggregation, real-time updates, and personalized content. Traditional CRUD architectures struggle here because they tightly couple the write model (how data is stored) to the read model (how data is queried). When you need to serve a feed that combines posts, comments, likes, follow relationships, and trending signals β€” all with different access patterns β€” that coupling becomes a bottleneck.

CQRS (Command Query Responsibility Segregation) and Event Sourcing offer a compelling alternative. Together they decouple writes from reads, give you an immutable audit trail of every user action, and let you build specialized, highly performant read models tailored to each feed variant.

What Is CQRS in the Context of a Social Feed

πŸš€ Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

CQRS splits the application into two distinct paths:

The critical insight: the command side never directly serves feed queries. Instead, every write operation produces an event. Those events are consumed asynchronously by projection builders that maintain read-optimized views. This means your feed can be denormalized, pre-sorted, and personalized without ever touching the canonical write storage.

What Is Event Sourcing

Event Sourcing replaces mutable state with an append-only log of events. Instead of storing "current state of a post" in a row and overwriting it on edits, you store a sequence of fine-grained events:

// Event stream for a single post (aggregate: Post)
PostCreated { postId, authorId, content, timestamp }
PostEdited { postId, newContent, editTimestamp }
PostLiked { postId, userId, timestamp }
PostUnliked { postId, userId, timestamp }
CommentAdded { postId, commentId, authorId, text, timestamp }

The current state of any aggregate is derived by replaying its event stream from the beginning. This gives you a complete, auditable history β€” invaluable for features like "edit history," undo/redo, and machine learning pipelines that analyze behavior over time.

Why This Combination Matters for Feed Design

Architecture Overview

Here's the high-level flow for a social media feed system built with CQRS and Event Sourcing:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Client App  │──POST─▢│  Command API    │──────▢│  Event Store     β”‚
β”‚  (write)     β”‚       β”‚  /api/v1/posts  β”‚       β”‚  (append-only)   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜       β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                                          β”‚
                                                          β”‚ events (Kafka/stream)
                                                          β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Client App  │──GET──│  Query API      │◀──────│  Projection DB   β”‚
β”‚  (read)      β”‚       β”‚  /api/v1/feed   β”‚       β”‚  (read-optimized)β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                                          β–²
                                                          β”‚
                                                  β”Œβ”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                                  β”‚  Projection      β”‚
                                                  β”‚  Workers         β”‚
                                                  β”‚  (build views)   β”‚
                                                  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Modeling the Domain Events

Start by defining the events that matter for your feed. Think in terms of what users do, not what state changes look like. Use a strongly typed event envelope:

// events/types.ts
export interface EventEnvelope {
  eventId: string;          // UUID, unique per event
  aggregateId: string;      // e.g., postId or userId
  aggregateType: string;    // "Post" | "User" | "Comment"
  eventType: string;        // "PostCreated" | "PostLiked" etc.
  payload: object;
  metadata: {
    timestamp: number;      // epoch millis
    userId: string;         // who caused this event
    correlationId: string;  // trace ID
    version: number;        // aggregate version, for optimistic concurrency
  };
}

// events/post-events.ts
export interface PostCreated extends EventEnvelope {
  eventType: 'PostCreated';
  payload: {
    postId: string;
    authorId: string;
    content: string;
    mediaUrls: string[];
    visibility: 'public' | 'friends' | 'private';
  };
}

export interface PostLiked extends EventEnvelope {
  eventType: 'PostLiked';
  payload: {
    postId: string;
    likedByUserId: string;
  };
}

export interface FollowRelationCreated extends EventEnvelope {
  eventType: 'FollowRelationCreated';
  payload: {
    followerId: string;
    followedId: string;
  };
}

Every event is immutable. Once appended to the event store, it can never be modified or deleted. This is the foundation that makes everything else work reliably.

Implementing the Command Side

The command side accepts user intents (commands), validates them, and produces events. A common pattern uses the Aggregate pattern from Domain-Driven Design. Each aggregate is responsible for a consistency boundary β€” typically one aggregate per post, per user profile, and per follow relationship.

// aggregates/post-aggregate.ts
import { PostCreated, PostEdited, PostLiked, PostUnliked, CommentAdded } from '../events/post-events';
import { EventStore } from '../event-store/event-store';

export class PostAggregate {
  private postId: string;
  private authorId: string;
  private content: string;
  private mediaUrls: string[];
  private visibility: string;
  private likes: Set; // set of userIds who liked
  private comments: Array<{ commentId: string; authorId: string; text: string; timestamp: number }>;
  private version: number;

  // Rehydrate from event history
  static async load(postId: string, eventStore: EventStore): Promise {
    const events = await eventStore.getEventsForAggregate('Post', postId);
    const aggregate = new PostAggregate(postId);
    for (const event of events) {
      aggregate.apply(event);
    }
    return aggregate;
  }

  constructor(postId: string) {
    this.postId = postId;
    this.likes = new Set();
    this.comments = [];
    this.version = 0;
  }

  private apply(event: EventEnvelope): void {
    switch (event.eventType) {
      case 'PostCreated':
        const created = event.payload as PostCreated['payload'];
        this.authorId = created.authorId;
        this.content = created.content;
        this.mediaUrls = created.mediaUrls;
        this.visibility = created.visibility;
        break;
      case 'PostEdited':
        const edited = event.payload as PostEdited['payload'];
        this.content = edited.newContent;
        break;
      case 'PostLiked':
        const liked = event.payload as PostLiked['payload'];
        this.likes.add(liked.likedByUserId);
        break;
      case 'PostUnliked':
        const unliked = event.payload as PostUnliked['payload'];
        this.likes.delete(unliked.unlikedByUserId);
        break;
      case 'CommentAdded':
        const commentAdded = event.payload as CommentAdded['payload'];
        this.comments.push({
          commentId: commentAdded.commentId,
          authorId: commentAdded.authorId,
          text: commentAdded.text,
          timestamp: commentAdded.timestamp
        });
        break;
    }
    this.version = event.metadata.version;
  }

  // Command: Create a new post
  public createPost(
    authorId: string, 
    content: string, 
    mediaUrls: string[], 
    visibility: string
  ): PostCreated {
    if (this.version !== 0) {
      throw new Error('Post already exists');
    }
    if (!content || content.length > 5000) {
      throw new Error('Content must be between 1 and 5000 characters');
    }
    const event: PostCreated = {
      eventId: generateUUID(),
      aggregateId: this.postId,
      aggregateType: 'Post',
      eventType: 'PostCreated',
      payload: { postId: this.postId, authorId, content, mediaUrls, visibility },
      metadata: {
        timestamp: Date.now(),
        userId: authorId,
        correlationId: getCurrentCorrelationId(),
        version: this.version + 1
      }
    };
    this.apply(event);
    return event;
  }

  // Command: Like a post
  public likePost(userId: string): PostLiked {
    if (this.likes.has(userId)) {
      throw new Error('User already liked this post');
    }
    const event: PostLiked = {
      eventId: generateUUID(),
      aggregateId: this.postId,
      aggregateType: 'Post',
      eventType: 'PostLiked',
      payload: { postId: this.postId, likedByUserId: userId },
      metadata: {
        timestamp: Date.now(),
        userId: userId,
        correlationId: getCurrentCorrelationId(),
        version: this.version + 1
      }
    };
    this.apply(event);
    return event;
  }
}

The command handler ties it together β€” loads the aggregate, executes the command, and persists the resulting events atomically:

// handlers/post-command-handler.ts
export class PostCommandHandler {
  constructor(private eventStore: EventStore, private eventBus: EventBus) {}

  async handleCreatePost(command: CreatePostCommand): Promise {
    const aggregate = new PostAggregate(command.postId);
    const event = aggregate.createPost(
      command.authorId,
      command.content,
      command.mediaUrls,
      command.visibility
    );
    
    // Atomically append to event store with optimistic concurrency check
    await this.eventStore.append(
      'Post',
      command.postId,
      [event],
      -1 // expected version: -1 means "new stream"
    );
    
    // Publish to event bus for projections and downstream consumers
    await this.eventBus.publish(event);
  }

  async handleLikePost(command: LikePostCommand): Promise {
    const aggregate = await PostAggregate.load(command.postId, this.eventStore);
    const event = aggregate.likePost(command.userId);
    
    await this.eventStore.append(
      'Post',
      command.postId,
      [event],
      aggregate.version // optimistic concurrency check
    );
    
    await this.eventBus.publish(event);
  }
}

The Event Store

The event store is the system of record. It must guarantee atomic, ordered appends per aggregate stream. A production implementation might use PostgreSQL with a dedicated events table, or a specialized database like EventStoreDB. Here's a minimal PostgreSQL-backed event store:

// event-store/postgres-event-store.ts
import { Pool } from 'pg';

export class PostgresEventStore implements EventStore {
  private pool: Pool;

  constructor(pool: Pool) {
    this.pool = pool;
  }

  async append(
    aggregateType: string,
    aggregateId: string,
    events: EventEnvelope[],
    expectedVersion: number
  ): Promise {
    const client = await this.pool.connect();
    try {
      await client.query('BEGIN');
      
      // Lock the stream to prevent concurrent appends
      const current = await client.query(
        `SELECT MAX(metadata->>'version')::int as version 
         FROM events 
         WHERE aggregate_id = $1 AND aggregate_type = $2`,
        [aggregateId, aggregateType]
      );
      const currentVersion = current.rows[0]?.version || 0;
      
      if (expectedVersion !== -1 && currentVersion !== expectedVersion) {
        throw new Error(
          `Concurrency conflict: expected version ${expectedVersion}, got ${currentVersion}`
        );
      }

      let nextVersion = currentVersion;
      for (const event of events) {
        nextVersion++;
        event.metadata.version = nextVersion;
        await client.query(
          `INSERT INTO events (event_id, aggregate_id, aggregate_type, event_type, payload, metadata, occurred_at)
           VALUES ($1, $2, $3, $4, $5, $6, $7)`,
          [
            event.eventId,
            event.aggregateId,
            event.aggregateType,
            event.eventType,
            JSON.stringify(event.payload),
            JSON.stringify(event.metadata),
            new Date(event.metadata.timestamp)
          ]
        );
      }
      
      await client.query('COMMIT');
    } catch (err) {
      await client.query('ROLLBACK');
      throw err;
    } finally {
      client.release();
    }
  }

  async getEventsForAggregate(
    aggregateType: string,
    aggregateId: string
  ): Promise {
    const result = await this.pool.query(
      `SELECT event_id, event_type, payload, metadata 
       FROM events 
       WHERE aggregate_id = $1 AND aggregate_type = $2
       ORDER BY metadata->>'version' ASC`,
      [aggregateId, aggregateType]
    );
    
    return result.rows.map(row => ({
      eventId: row.event_id,
      aggregateId,
      aggregateType,
      eventType: row.event_type,
      payload: row.payload,
      metadata: row.metadata
    }));
  }

  async getAllEventsSince(timestamp: number, batchSize: number = 500): Promise {
    const result = await this.pool.query(
      `SELECT event_id, aggregate_id, aggregate_type, event_type, payload, metadata
       FROM events
       WHERE occurred_at > $1
       ORDER BY occurred_at ASC
       LIMIT $2`,
      [new Date(timestamp), batchSize]
    );
    
    return result.rows.map(row => ({
      eventId: row.event_id,
      aggregateId: row.aggregate_id,
      aggregateType: row.aggregate_type,
      eventType: row.event_type,
      payload: row.payload,
      metadata: row.metadata
    }));
  }
}

The events table schema is straightforward and append-only:

CREATE TABLE events (
  event_id       UUID PRIMARY KEY,
  aggregate_id   UUID NOT NULL,
  aggregate_type VARCHAR(50) NOT NULL,
  event_type     VARCHAR(100) NOT NULL,
  payload        JSONB NOT NULL,
  metadata       JSONB NOT NULL,
  occurred_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_events_aggregate ON events (aggregate_id, aggregate_type, occurred_at);
CREATE INDEX idx_events_type_occurred ON events (event_type, occurred_at);
CREATE INDEX idx_events_occurred ON events (occurred_at);

Building Feed Projections

Projections are the heart of the read side. They consume events and build denormalized views optimized for feed queries. A projection worker typically runs as a background process, listening to the event bus and incrementally updating a dedicated read database (Redis, PostgreSQL, Elasticsearch, or a combination).

Home Feed Projection (Chronological)

The home feed shows posts from users the viewer follows, sorted by recency. This projection maintains a per-user feed list in Redis for lightning-fast retrieval:

// projections/home-feed-projection.ts
import { Redis } from 'ioredis';
import { EventBus } from '../event-bus/event-bus';
import { 
  PostCreated, FollowRelationCreated, PostEdited, 
  PostVisibilityChanged, PostDeleted 
} from '../events/post-events';

export class HomeFeedProjection {
  private redis: Redis;
  private eventBus: EventBus;
  private checkpoint: number; // last processed event timestamp

  constructor(redis: Redis, eventBus: EventBus) {
    this.redis = redis;
    this.eventBus = eventBus;
    this.checkpoint = 0;
  }

  async start(): Promise {
    // 1. Load checkpoint from Redis
    const saved = await this.redis.get('projection:home-feed:checkpoint');
    if (saved) {
      this.checkpoint = parseInt(saved, 10);
    }

    // 2. Subscribe to relevant event types
    this.eventBus.subscribe('PostCreated', this.handlePostCreated.bind(this));
    this.eventBus.subscribe('PostEdited', this.handlePostEdited.bind(this));
    this.eventBus.subscribe('PostVisibilityChanged', this.handleVisibilityChanged.bind(this));
    this.eventBus.subscribe('PostDeleted', this.handlePostDeleted.bind(this));
    this.eventBus.subscribe('FollowRelationCreated', this.handleFollowCreated.bind(this));
    this.eventBus.subscribe('FollowRelationRemoved', this.handleFollowRemoved.bind(this));

    // 3. Catch up on missed events if needed (handled by event bus subscription replay)
    console.log('HomeFeedProjection started');
  }

  // When a new post is created, fan-out to all followers' feeds
  private async handlePostCreated(event: PostCreated): Promise {
    const { postId, authorId, content, visibility } = event.payload;
    const timestamp = event.metadata.timestamp;

    if (visibility === 'private') {
      // Private posts don't go into follower feeds
      await this.saveCheckpoint(timestamp);
      return;
    }

    // Get all followers of the author
    const followers = await this.getFollowers(authorId);

    // Build the feed entry
    const feedEntry = JSON.stringify({
      postId,
      authorId,
      content: content.substring(0, 200), // preview
      timestamp,
      type: 'post'
    });

    // Fan-out: push to each follower's feed sorted set in Redis
    const pipeline = this.redis.pipeline();
    for (const followerId of followers) {
      const feedKey = `feed:home:${followerId}`;
      pipeline.zadd(feedKey, timestamp, feedEntry);
      // Keep only the latest 1000 entries to bound memory
      pipeline.zremrangebyrank(feedKey, 0, -1001);
    }
    await pipeline.exec();
    await this.saveCheckpoint(timestamp);
  }

  // When a follow relationship is created, backfill the new follower's feed
  private async handleFollowCreated(event: FollowRelationCreated): Promise {
    const { followerId, followedId } = event.payload;
    
    // Get recent public posts from the newly followed user
    const recentPosts = await this.getRecentPublicPosts(followedId, 50);
    
    if (recentPosts.length > 0) {
      const feedKey = `feed:home:${followerId}`;
      const pipeline = this.redis.pipeline();
      for (const post of recentPosts) {
        const entry = JSON.stringify({
          postId: post.postId,
          authorId: post.authorId,
          content: post.content.substring(0, 200),
          timestamp: post.timestamp,
          type: 'post'
        });
        pipeline.zadd(feedKey, post.timestamp, entry);
      }
      pipeline.zremrangebyrank(feedKey, 0, -1001);
      await pipeline.exec();
    }
    
    // Also store the follow relationship for future fan-out
    await this.redis.sadd(`user:${followedId}:followers`, followerId);
    await this.redis.sadd(`user:${followerId}:following`, followedId);
    await this.saveCheckpoint(event.metadata.timestamp);
  }

  private async handleFollowRemoved(event: FollowRelationRemoved): Promise {
    const { followerId, followedId } = event.payload;
    
    // Remove all posts by followedId from follower's feed
    const feedKey = `feed:home:${followerId}`;
    const members = await this.redis.zrange(feedKey, 0, -1);
    const pipeline = this.redis.pipeline();
    
    for (const member of members) {
      const entry = JSON.parse(member);
      if (entry.authorId === followedId) {
        pipeline.zrem(feedKey, member);
      }
    }
    
    await pipeline.exec();
    await this.redis.srem(`user:${followedId}:followers`, followerId);
    await this.redis.srem(`user:${followerId}:following`, followedId);
    await this.saveCheckpoint(event.metadata.timestamp);
  }

  private async handlePostEdited(event: PostEdited): Promise {
    // Update the feed entry content across all follower feeds
    const { postId, newContent } = event.payload;
    const followers = await this.redis.smembers(`post:${postId}:followers-cached`);
    
    const pipeline = this.redis.pipeline();
    for (const followerId of followers) {
      const feedKey = `feed:home:${followerId}`;
      const members = await this.redis.zrange(feedKey, 0, -1);
      for (const member of members) {
        const entry = JSON.parse(member);
        if (entry.postId === postId) {
          entry.content = newContent.substring(0, 200);
          // ZADD with same score updates the member value in Redis sorted sets
          pipeline.zadd(feedKey, entry.timestamp, JSON.stringify(entry));
        }
      }
    }
    await pipeline.exec();
    await this.saveCheckpoint(event.metadata.timestamp);
  }

  private async handlePostDeleted(event: PostDeleted): Promise {
    const { postId } = event.payload;
    const followers = await this.redis.smembers(`post:${postId}:followers-cached`);
    
    const pipeline = this.redis.pipeline();
    for (const followerId of followers) {
      const feedKey = `feed:home:${followerId}`;
      const members = await this.redis.zrange(feedKey, 0, -1);
      for (const member of members) {
        const entry = JSON.parse(member);
        if (entry.postId === postId) {
          pipeline.zrem(feedKey, member);
        }
      }
    }
    await pipeline.exec();
    await this.saveCheckpoint(event.metadata.timestamp);
  }

  private async getFollowers(userId: string): Promise {
    return this.redis.smembers(`user:${userId}:followers`);
  }

  private async getRecentPublicPosts(userId: string, limit: number): Promise {
    // This could query a dedicated post-detail projection
    const posts = await this.redis.lrange(`user:${userId}:posts`, 0, limit - 1);
    return posts.map(p => JSON.parse(p));
  }

  private async saveCheckpoint(timestamp: number): Promise {
    this.checkpoint = Math.max(this.checkpoint, timestamp);
    await this.redis.set('projection:home-feed:checkpoint', this.checkpoint.toString());
  }
}

Trending Feed Projection (Materialized View in PostgreSQL)

A trending feed ranks posts by a composite score combining recency, likes, and comments. This projection uses PostgreSQL as the read store because it supports complex ranking queries with indexes:

// projections/trending-feed-projection.ts
import { Pool } from 'pg';

export class TrendingFeedProjection {
  private pool: Pool;

  constructor(pool: Pool) {
    this.pool = pool;
  }

  async initialize(): Promise {
    await this.pool.query(`
      CREATE TABLE IF NOT EXISTS trending_posts (
        post_id UUID PRIMARY KEY,
        author_id UUID NOT NULL,
        content TEXT NOT NULL,
        like_count INT DEFAULT 0,
        comment_count INT DEFAULT 0,
        created_at TIMESTAMPTZ NOT NULL,
        last_updated TIMESTAMPTZ NOT NULL,
        trending_score DOUBLE PRECISION DEFAULT 0
      );
      
      CREATE INDEX IF NOT EXISTS idx_trending_score 
        ON trending_posts (trending_score DESC, created_at DESC);
    `);
  }

  async handlePostCreated(event: PostCreated): Promise {
    const { postId, authorId, content } = event.payload;
    await this.pool.query(
      `INSERT INTO trending_posts (post_id, author_id, content, created_at, last_updated)
       VALUES ($1, $2, $3, $4, $4)
       ON CONFLICT (post_id) DO NOTHING`,
      [postId, authorId, content, new Date(event.metadata.timestamp)]
    );
    await this.recalculateScore(postId);
  }

  async handlePostLiked(event: PostLiked): Promise {
    const { postId } = event.payload;
    await this.pool.query(
      `UPDATE trending_posts 
       SET like_count = like_count + 1, last_updated = NOW()
       WHERE post_id = $1`,
      [postId]
    );
    await this.recalculateScore(postId);
  }

  async handleCommentAdded(event: CommentAdded): Promise {
    const { postId } = event.payload;
    await this.pool.query(
      `UPDATE trending_posts 
       SET comment_count = comment_count + 1, last_updated = NOW()
       WHERE post_id = $1`,
      [postId]
    );
    await this.recalculateScore(postId);
  }

  private async recalculateScore(postId: string): Promise {
    // Trending score = weighted combination of recency, likes, and comments
    // Uses a time-decay factor so older posts naturally fade
    await this.pool.query(`
      UPDATE trending_posts 
      SET trending_score = (
        (like_count * 2.0 + comment_count * 3.0) * 
        EXP(-EXTRACT(EPOCH FROM (NOW() - created_at)) / 86400.0 * 0.1)
      )
      WHERE post_id = $1
    `, [postId]);
  }

  async getTrendingFeed(limit: number = 20, offset: number = 0): Promise {
    const result = await this.pool.query(
      `SELECT post_id, author_id, content, like_count, comment_count, trending_score
       FROM trending_posts
       ORDER BY trending_score DESC, created_at DESC
       LIMIT $1 OFFSET $2`,
      [limit, offset]
    );
    return result.rows;
  }
}

The Query API β€” Serving the Feed

The query API is thin and fast. It simply reads from projections with no business logic, no validation, and no joins across write-side tables:

// query-api/feed-query-handler.ts
import { Redis } from 'ioredis';
import { TrendingFeedProjection } from '../projections/trending-feed-projection';

export class FeedQueryService {
  private redis: Redis;
  private trendingProjection: TrendingFeedProjection;

  constructor(redis: Redis, trendingProjection: TrendingFeedProjection) {
    this.redis = redis;
    this.trendingProjection = trendingProjection;
  }

  async getHomeFeed(userId: string, cursor?: number, limit: number = 20): Promise {
    const feedKey = `feed:home:${userId}`;
    
    // Redis ZREVRANGE returns highest score (most recent) first
    const start = cursor ? await this.redis.zrevrank(feedKey, cursor) + 1 : 0;
    const entries = await this.redis.zrevrange(feedKey, start, start + limit - 1);
    
    // entries are JSON strings; parse and enrich if needed
    const feedItems = entries.map(e => JSON.parse(e));
    
    const nextCursor = entries.length === limit 
      ? entries[entries.length - 1] 
      : null;
    
    return {
      items: feedItems,
      nextCursor: nextCursor ? JSON.stringify(nextCursor) : null,
      hasMore: entries.length === limit
    };
  }

  async getTrendingFeed(userId: string, page: number = 1, limit: number = 20): Promise {
    const offset = (page - 1) * limit;
    const posts = await this.trendingProjection.getTrendingFeed(limit, offset);
    
    return {
      items: posts,
      nextCursor: page.toString(),
      hasMore: posts.length === limit
    };
  }
}

interface FeedResponse {
  items: any[];
  nextCursor: string | null;
  hasMore: boolean;
}

Real-Time Feed Updates with WebSockets

For truly live feeds, combine the projection system with WebSocket push. When a projection updates a follower's feed, it also publishes a notification to a WebSocket hub:

// realtime/feed-notifier.ts
import { WebSocketServer, WebSocket } from 'ws';
import { EventBus } from '../event-bus/event-bus';

export class FeedNotifier {
  private wss: WebSocketServer;
  private connections: Map; // userId -> active sockets

  constructor(server: any) {
    this.wss = new WebSocketServer({ server });
    this.connections = new Map();
    
    this.wss.on('connection', (ws, req) => {
      // Authenticate and extract userId from token in query string or header
      const userId = extractUserIdFromRequest(req);
      if (!userId) {
        ws.close(4001, 'Authentication required');
        return;
      }
      
      // Register connection
      const existing = this.connections.get(userId) || [];
      existing.push(ws);
      this.connections.set(userId, existing);
      
      ws.on('close', () => {
        const sockets = this.connections.get(userId) || [];
        this.connections.set(userId, sockets.filter(s => s !== ws));
      });
      
      // Send initial snapshot
      this.sendInitialFeed(userId, ws);
    });
  }

  private async sendInitialFeed(userId: string, ws: WebSocket): Promise {
    // Fetch current feed snapshot and send as first message
    // (Implementation depends on your feed query service)
    ws.send(JSON.stringify({ type: 'feed:snapshot', items: [] }));
  }

  // Called by projection after updating a user's feed
  notifyFeedUpdate(userId: string, newEntry: object): void {
    const sockets = this.connections.get(userId) || [];
    const message = JSON.stringify({
      type: 'feed:new-item',
      item: newEntry
    });
    
    for (const ws of sockets) {
      if (ws.readyState === WebSocket.OPEN) {
        ws.send(message);
      }
    }
  }

  // Called when a user is actively viewing their feed and scrolls
  notifyFeedInsert(userId: string, entries: object[], position: 'top' | 'bottom'): void {
    const sockets = this.connections.get(userId) || [];
    const message = JSON.stringify({
      type: 'feed:insert',
      entries,
      position
    });
    
    for (const ws of sockets) {
      if (ws.readyState === WebSocket.OPEN) {
        ws.send(message);
      }
    }
  }
}

Wire the notifier into the home feed projection so that when a new post is fanned-out to followers, those currently online get instant updates:

// Inside HomeFeedProjection.handlePostCreated, after pipeline.exec():
for (const followerId of followers) {
  const feedKey = `feed:home:${followerId}`;
  // Check if follower is online via presence service or connection map
  if (this.presenceService.isOnline(followerId)) {
    this.feedNotifier.notifyFeedUpdate(followerId, {
      postId,
      authorId,
      content: content.substring(0, 200),
      timestamp,
      type: 'post'
    });
  }
}

Handling Consistency and Lag

Eventual consistency is inherent to CQRS. There is always a delay between a write being acknowledged and the projection being updated. For a social feed, this is usually acceptable β€” users understand that feeds take a moment to update. However, you can minimize perceived lag:

Snapshotting and Rehydration Performance

Loading an aggregate by replaying thousands of events on every command becomes expensive. Introduce snapshots β€” periodically save the aggregate's full state at a given version, then load only events since that snapshot:

// snapshotting/snapshot-manager.ts
export class SnapshotManager {
  private pool: Pool;
  private snapshotFrequency: number = 50; // every 50 events

  async getAggregate(
    aggregateType: string, 
    aggregateId: string, 
    eventStore: EventStore
  ): Promise<{ state: any; version: number }> {
    // Try to load latest snapshot
    const snapshot = await this.pool.query(
      `SELECT state, version FROM snapshots 
       WHERE aggregate_id = $1 AND aggregate_type = $2 
       ORDER BY version DESC LIMIT 1`,
      [aggregateId, aggregateType]
    );

    let state: any = {};
    let fromVersion = 0;

    if (snapshot.rows.length > 0) {
      state = snapshot.rows

πŸš€ Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started β€” $23.99/mo
← Back to all articles