← Back to DevBytes

Designing a Social Media Feed with Low Latency

Understanding the Social Media Feed

A social media feed is the central stream of content a user sees when they open an app like Twitter, Instagram, or Facebook. It aggregates posts, images, videos, and interactions from accounts the user follows, sorted in reverse chronological order or by algorithmic relevance. Designing this feed for low latency means ensuring that when a user opens the app or scrolls, content appears almost instantly—typically within tens to a few hundred milliseconds.

What is a Social Media Feed?

At its core, a feed is a query result: “Given a user, retrieve the most recent posts from the accounts they follow, along with relevant metadata (likes, comments, author details).” This sounds simple, but at scale it becomes a complex distributed systems problem. A naive approach—joining tables in a relational database—fails when millions of users follow thousands of accounts and posts arrive at hundreds of thousands per second.

Why Low Latency Matters

Latency directly impacts user engagement and retention. Studies show that a delay of even 100 milliseconds can cause measurable drops in user satisfaction. In social media, where infinite scroll is the norm, each pagination request must feel instantaneous. Low latency:

Core Design Principles for Low Latency

🚀 Deploy your AI agent in 10 minutes

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

Try it free →

To deliver a feed in milliseconds, you must move data close to the user, precompute where possible, and avoid heavy joins at read time. The key principles are:

Data Modeling and Storage

Two main models dominate feed design:

1. Normalized relational model (pull-based): Store posts in a posts table, followers in a follows table. When a user requests their feed, query: SELECT * FROM posts WHERE author_id IN (list_of_followed_users) ORDER BY created_at DESC LIMIT 20. This becomes slow at scale because the IN clause can contain thousands of user IDs, and sorting across shards is expensive.

2. Fanout-on-write (push-based): For each new post, write a copy into the timeline of every follower. This trades write amplification for blazing-fast reads. The read becomes a simple key lookup: “get the timeline list for user X”. This is the foundation for low latency at massive scale.

For storage, a time-series sorted set in Redis is ideal:

Caching Strategies

Caching reduces database hits and improves response times drastically. The feed cache is typically a Redis sorted set per user. Additional caching layers:

Feed Generation: Push vs. Pull

Most low-latency systems use a hybrid approach:

The hybrid model often stores a "home timeline" cache that covers the last ~3 days of activity. Older content is lazily loaded from a slower archive store.

Real-Time Updates with WebSockets

Once the initial feed loads, new content must appear without manual refresh. WebSockets or Server-Sent Events (SSE) maintain a persistent connection between client and server. When a new post is created, the server pushes a lightweight event to all connected followers. The client inserts the post into the top of its locally cached feed and re-sorts. This keeps latency near zero for live updates.

Implementing a Low-Latency Feed in Practice

Below we'll build a simplified version of a push-based feed service using Node.js and Redis. We'll cover fanout, timeline retrieval, and real-time notification.

1. Fanout Service (Write Path)

When a user creates a post, we need to insert the post ID into the timelines of all followers. For high follower counts, this is done asynchronously via a message queue (e.g., Kafka, RabbitMQ). The example uses a simple loop for illustration.

// fanoutService.js (simplified)
const redis = require('redis');
const client = redis.createClient();
const asyncRedis = require('async-redis');
const asyncClient = asyncRedis.decorate(client);

async function fanoutPost(postId, authorId, timestamp) {
  // In production, get followers from a cache or DB
  const followers = await getFollowers(authorId); // returns array of user IDs
  
  const pipeline = asyncClient.pipeline();
  followers.forEach(followerId => {
    const timelineKey = `timeline:user:${followerId}`;
    // Add post to sorted set with timestamp as score
    pipeline.zadd(timelineKey, timestamp, postId);
    // Optionally trim timeline to last ~1000 entries to bound memory
    pipeline.zremrangebyrank(timelineKey, 0, -1001);
  });
  await pipeline.exec();
}

async function getFollowers(userId) {
  // Mock: fetch from DB or a Redis set 'followers:user:{userId}'
  // Return array of follower IDs
  return ['101', '102', '103']; // dummy
}

Key points: Use pipeline to batch Redis commands, minimizing round-trip time. Trimming the sorted set with ZREMRANGEBYRANK keeps memory bounded. In production, for users with millions of followers, fanout is split across many workers and can be deferred for a few seconds to batch multiple posts.

2. Timeline Read API

The API endpoint returns a paginated portion of the user's timeline, with cursor-based pagination for efficiency.

// api/getFeed.js
const asyncRedis = require('async-redis');
const client = asyncRedis.decorate(redis.createClient());

async function getFeed(userId, cursor, limit = 20) {
  const timelineKey = `timeline:user:${userId}`;
  
  // cursor is the timestamp (score) from the last item of previous page
  // For first page, cursor can be '+inf' (or Date.now() + some future)
  const maxScore = cursor ? cursor : '+inf';
  
  // ZREVRANGEBYSCORE returns posts in descending order (newest first)
  // We exclude the cursor item itself by using '(' prefix on the score
  const posts = await client.zrevrangebyscore(
    timelineKey,
    maxScore,
    '-inf',
    'limit', 0, limit
  );
  
  // Next cursor: timestamp of the last post in this batch (oldest)
  let nextCursor = null;
  if (posts.length > 0) {
    const scores = await client.zscore(timelineKey, posts[posts.length - 1]);
    nextCursor = scores[0]; // score of the last element
  }
  
  // Fetch post content from post cache (hash mapping postId -> content)
  const postIds = posts.map(p => p.split(':')[1]); // if postId format 'post:123'
  const content = await client.hmget('post:cache', postIds);
  
  const feedItems = posts.map((p, i) => ({
    postId: p,
    content: content[i],
    // additional metadata like author, likes can be fetched in parallel
  }));
  
  return {
    items: feedItems,
    nextCursor: nextCursor
  };
}

The client stores the nextCursor and passes it on subsequent scroll requests. This avoids the performance penalty of offset-based pagination (LIMIT 20 OFFSET 1000) where Redis must traverse all previous entries.

3. Real-Time Update via WebSocket

After initial load, the server pushes new posts to connected clients. A lightweight event contains the post ID and timestamp, and the client inserts it into its local feed.

// Server side (Node.js with ws library)
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });

// Map of userId to WebSocket connections (simplified)
const connections = new Map();

wss.on('connection', (ws, req) => {
  // Assume authentication extracts userId
  const userId = getUserIdFromRequest(req);
  connections.set(userId, ws);
  
  ws.on('close', () => connections.delete(userId));
});

// When a new post is created, notify all followers
async function notifyFollowers(postId, authorId, timestamp) {
  const followers = await getFollowers(authorId);
  for (const followerId of followers) {
    const ws = connections.get(followerId);
    if (ws && ws.readyState === WebSocket.OPEN) {
      ws.send(JSON.stringify({
        type: 'new_post',
        postId: postId,
        authorId: authorId,
        timestamp: timestamp
      }));
    }
  }
}

On the client, after receiving the event, the feed UI prepends the new item if it belongs to the current view. This keeps the feed lively with zero additional network requests.

Best Practices for Production Systems

Conclusion

Designing a social media feed for low latency is a balancing act between precomputation, caching, and real-time push. By using fanout-on-write with in-memory sorted sets, cursor-based pagination, and WebSocket-driven live updates, you can deliver a feed that feels instantaneous even under massive scale. The hybrid push/pull model ensures efficient resource usage, while careful trimming, sharding, and monitoring keep the system healthy. Remember, latency is not just a technical metric—it's the heartbeat of user experience. Invest in every millisecond, and your platform will reward you with engaged, loyal users.

🚀 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