If you've ever built a social media feed, you already know the problem. Every time a user opens the app, you need to figure out who they follow, fetch all their posts, sort them, paginate them and do it fast. Do that for thousands of users at once and your database starts crying.

This is the story of how I built the feed for my Threads clone, what went wrong at each step, and how I ended up with a production grade caching strategy using Redis covering cache-aside, TTL jitter, and distributed locks.

By the end of this post you'll understand not just what these patterns are, but why each one exists and what problem it was solving.

Let's start from the beginning.

The Feed Without Any Cache

Here's the core of what a personalized feed does. When a user opens the app, the backend needs to:

  1. Find everyone that user follows
  2. Fetch the latest threads from those people
  3. Check which threads the user has already liked
  4. Return it all paginated

Here's what that looks like in code:

export const getFeed = async (userId: string, limit: number, cursor?: string) => {

    // Step 1: find everyone this user follows
    const following = await prisma.follow.findMany({
        where: { followerId: userId },
        select: { followingId: true }
    })

    const authorIds = [userId, ...following.map(f => f.followingId)]

    // Step 2: fetch their threads with cursor pagination
    const threads = await prisma.thread.findMany({
        where: { authorId: { in: authorIds } },
        take: limit + 1,
        ...(cursor && { cursor: { id: cursor }, skip: 1 }),
        orderBy: { createdAt: "desc" }
    })

    const hasNextPage = threads.length > limit
    const data = hasNextPage ? threads.slice(0, limit) : threads

    // Step 3: check which ones the user liked
    const likedThreads = await prisma.like.findMany({
        where: { userId, threadId: { in: data.map(t => t.id) } },
        select: { threadId: true }
    })

    const likedThreadIds = new Set(likedThreads.map(l => l.threadId))

    return {
        data: data.map(thread => ({ ...thread, isLiked: likedThreadIds.has(thread.id) })),
        nextCursor: hasNextPage ? data[data.length - 1].id : null,
        hasNextPage
    }
}

This works. But every single feed request hits Postgres with at least two queries the follow graph lookup and the thread fetch. For one user that's fine. For 500 concurrent users, that's 1000+ heavy queries per second on your database.

We need a cache.

Pattern 1: Cache-Aside

Situation: Every feed request was hitting Postgres directly. Two expensive queries per request, every time, even if the same user refreshed the feed 10 times in a minute.

Task: Reduce database load by storing feed results temporarily so repeated requests don't hit Postgres.

Action: Implement the cache-aside pattern with Redis.

Cache-aside is the most common caching strategy. The idea is simple:

  1. Check Redis first
  2. If the data is there (cache hit), return it immediately no DB query
  3. If it's not there (cache miss), query the DB, store the result in Redis, then return it
Request comes in
    ↓
Check Redis
    ↓ hit              ↓ miss
Return cached    →   Query Postgres
    data         →   Store in Redis
                 →   Return data

Here's what that looks like in the feed:

const cacheKey = `feed:user:${userId}:cursor:${cursor ?? "first"}:limit:${limit}`

const cachedFeed = await getCache(cacheKey)

if (cachedFeed) {
    // cache hit — skip Postgres entirely
    threadIds = cachedFeed.threadIds
    nextCursor = cachedFeed.nextCursor
    hasNextPage = cachedFeed.hasNextPage
} else {
    // cache miss — query Postgres and store result
    const generatedFeed = await generateFeed()
    await setCache(cacheKey, generatedFeed, 60) // 60 second TTL
    threadIds = generatedFeed.threadIds
}

One important design decision here: we only cache the thread IDs and pagination metadata, not the full thread objects. Why? Because isLiked and likesCount change constantly. If we cached the full thread, a user could like a post and still see the old like count for 60 seconds. By caching only IDs, we always fetch fresh thread data from Postgres we just skip the expensive follow graph query.

Result: On a cache hit, the follow graph query is completely skipped. Only two lightweight Postgres queries remain fetch threads by IDs, check likes. Database load drops dramatically for active users.

The New Problem: Thundering Herd

Cache-aside works great. Until the cache expires.

When a cache key hits its TTL and expires, the next request sees a miss and goes to Postgres. That's fine if it's one request. But what if 50 users all request their feed at the exact same moment the cache expires?

All 50 check Redis → all 50 see a miss → all 50 fire the expensive Postgres queries simultaneously.

This is called the thundering herd problem a single cache expiry causes a sudden spike of database load that can overwhelm your DB.

And there's a related problem called a cache stampede — if you cache thousands of users' feeds all with the same TTL (say, exactly 60 seconds), and they were all cached around the same time, they all expire at the same time. Suddenly you have thousands of cache misses happening simultaneously.

We need to solve both.

Pattern 2: TTL Jitter

Situation: All feed cache keys were set with a flat 60 second TTL. Under load, many keys were being populated at the same time, meaning they'd all expire at the same time causing a mass stampede of DB queries.

Task: Spread out cache expirations so they don't all happen at once.

Action: Add random jitter to the TTL.

Instead of always setting TTL to exactly 60 seconds, randomize it within a range:

const ttl = 60 + Math.floor(Math.random() * 30) // anywhere from 60 to 90 seconds
await setCache(cacheKey, generatedFeed, ttl)

That's it. One line change.

Now instead of 1000 cache keys all expiring at T+60s, they expire spread across T+60s to T+90s. The DB load from cache misses is distributed over a 30 second window instead of hitting all at once.

Result: Cache expirations are staggered. No more synchronized mass expiry. The database sees a steady trickle of cache misses instead of a sudden spike.

But jitter only solves the "many keys expiring at once" problem. It doesn't solve the case where multiple concurrent requests hit the same single key at the moment it expires. For that, we need something stronger.

Pattern 3: Distributed Lock (Solving the Thundering Herd Properly)

Situation: Even with jitter, when a single cache key expires, multiple concurrent requests for that same key all see a miss simultaneously and all race to query Postgres and repopulate the cache. Only one of them needs to do that work the rest are wasted.

Task: Ensure only one request rebuilds the cache when a key expires. All other concurrent requests should wait for that one to finish and then read from the cache.

Action: Use a Redis distributed lock.

The idea: when a cache miss happens, before querying Postgres, try to acquire a lock. Only one request can hold the lock at a time. The lock holder does the DB work. Everyone else waits and retries the cache.

Here's how the lock itself works in Redis:

export async function acquireLock(key: string, ttlSeconds: number): Promise<boolean> {
    const result = await redisClient.set(`lock:${key}`, "1", { 
        NX: true,  // only set if key does NOT exist
        EX: ttlSeconds  // auto-expire so it never gets stuck
    })
    return result === "OK"
}

export async function releaseLock(key: string): Promise<void> {
    await redisClient.del(`lock:${key}`)
}

The NX flag is the key part it makes the SET atomic. Only one caller gets "OK" back. Everyone else gets null. This is how Redis guarantees only one lock holder even under concurrent requests.

Now here's how it's used in the feed:

const lockAcquired = await acquireLock(cacheKey, 5)

if (!lockAcquired) {
    // someone else is already rebuilding the cache wait for them
    const maxRetries = 10
    const retryDelay = 50 // ms
    let retryCache = null

    for (let attempt = 0; attempt < maxRetries; attempt++) {
        await new Promise(resolve => setTimeout(resolve, retryDelay))
        retryCache = await getCache(cacheKey)
        if (retryCache) break
    }

    if (retryCache) {
        threadIds = retryCache.threadIds
        nextCursor = retryCache.nextCursor
        hasNextPage = retryCache.hasNextPage
    } else {
        throw new Error("Failed to populate feed cache")
    }

} else {
    // we got the lock we do the DB work
    try {
        const generatedFeed = await generateFeed()
        const ttl = 60 + Math.floor(Math.random() * 30)
        await setCache(cacheKey, generatedFeed, ttl)
        threadIds = generatedFeed.threadIds
        nextCursor = generatedFeed.nextCursor
        hasNextPage = generatedFeed.hasNextPage
    } finally {
        await releaseLock(cacheKey) // always release, even if generateFeed throws
    }
}

Walk through what happens when 50 requests hit a cold cache simultaneously:

  1. All 50 see a cache miss
  2. All 50 try to acquire the lock
  3. Request 1 gets "OK" it's the lock holder
  4. Requests 2–50 get null they enter the retry loop
  5. Request 1 queries Postgres, populates the cache, releases the lock
  6. Requests 2–50 are polling every 50ms one by one they see the cache is populated and return

Instead of 50 Postgres queries, you get exactly 1.

Notice the finally block around releaseLock. This is important if generateFeed() throws an error, the lock still gets released. Without this, a crash would leave the lock in Redis for 5 seconds (the TTL), blocking all other requests during that window.

Result: Under concurrent load on a cold cache, only one request hits Postgres. All others wait and read from cache once it's populated. Database load is completely controlled.

The Full Picture

Here's how all three patterns layer together:

Request comes in
        ↓
Check cache
        ↓ hit → return cached data (fast path)
        ↓ miss
Try to acquire lock
        ↓ got lock                    ↓ lock taken by someone else
Query Postgres                        Wait 50ms, retry cache
Set cache with jitter TTL (60–90s)    Got cache? → return it
Release lock                          Still nothing after 10 retries? → throw
Return data

Each layer solves a specific problem:

Pattern Problem it solves
Cache-aside Repeated DB queries for the same data
TTL jitter Mass cache expiry causing synchronized DB spikes
Distributed lock Concurrent requests all rebuilding the same cache key

Why Not Single Flight Instead of a Lock?

You might have heard of the single flight pattern it collapses concurrent requests for the same resource into one, using an in-memory promise map. It's simpler and has zero network overhead.

The reason a distributed lock is better here: single flight only works within a single Node.js process. If you run two backend instances (which you will in production as you scale), single flight on instance A does nothing about the thundering herd hitting instance B. The Redis lock works across all instances because Redis is the shared source of truth.

Built this while working on my Threads clone full source on GitHub if you want to see the complete implementation in context.