Breaking the Feed Scaling Wall: Insights from Re-architecting a Production Social Feed

Breaking the Feed Scaling Wall: Insights from Re-architecting a Production Social Feed

Every social app eventually hits what we call the Feed Scaling Wall. It usually shows up the moment your first power user follows their 500th account, and the once-snappy SELECT * query that powered their feed starts taking 200ms. Then 500ms. Then it times out.

To get ahead of this at Hoomanely, we architected for scale from day one. We recognized early that optimizing for write simplicity, just saving a post, inevitably punishes read performance, and in a production environment where reads outnumber writes by roughly 100 to 1, a traditional pull-based approach becomes a real liability.

Instead, we built an asynchronous fan-out-on-write model as our core foundation, using MongoDB's high write throughput to pre-compute feeds ahead of time. That architectural choice let us skip the scaling wall entirely. This post covers why we abandoned the naive approach, the specifics of our schema design (including our deliberate use of denormalization), and how we got predictable P99 read latency at scale.

The read amplification bottleneck

Social systems are aggressively read-heavy. A single post gets written once and read thousands of times. Optimizing purely for write efficiency, just inserting a row, pushes all the cost onto the read path instead. Every feed load then forces the database to index-scan thousands of user IDs (the following list), randomly access disparate pages on disk for those users' posts, and sort everything in memory before handing back the top 10.

That's a classic O(N) operation where N is the number of followed users multiplied by their posting frequency, computationally expensive and hostile to caching. We needed to flip that relationship around entirely.

Why MongoDB

We didn't pick MongoDB because it was trendy, we picked it for specific architectural properties. Schema polymorphism: a "post" is rarely uniform, it might be a poll, a video, an article, or an SOS alert, and relational tables struggle with that kind of variance, usually falling back on EAV patterns or JSON blobs. MongoDB's BSON document structure handles that variance natively.

Atomic arrays and counters: features like likes, comments, and view counts need high-concurrency atomic updates ($inc, $addToSet), which in SQL usually means row-level locking or extra side tables to manage.

Write throughput and sharding: the fan-out pattern generates a big write multiplier, one post becomes a thousand inserts, and MongoDB's efficient B-tree writes plus native horizontal sharding let us absorb those spikes without creating a bottleneck.

The approach: fan-out on write

We shift the computational cost from read time to write time. When a user publishes content, we treat it as an event that triggers a fan-out process, preemptively pushing the post ID out to every follower's feed ahead of time.

The design pattern

Fan-out architecture diagram showing a post write distributing references to each follower's feed
Fan-out architecture diagram showing a post write distributing references to each follower's feed

When a user publishes a post: the core write saves the full post object, content, media URLs, location, into the posts collection. Then fan-out identifies the user's followers and inserts a lightweight reference into a dedicated personalisedposts collection for each one.

That's write amplification: one user post becomes a thousand follower inserts. It costs more in storage and write IOPS, but it buys O(1) access time on reads. Since our read-to-write ratio runs above 100:1, trading write complexity for read latency is the right call here.

The schema design

The real trick isn't just the architecture, it's the schema, and we use two main collections.

The heavyweight, Post, holds the actual content and stays mostly normalized:

class Post(BaseModel):
    id: str  # TimeUUID
    user_id: str
    content: str
    media_links: List[str]
    location: Location
    created_at: datetime
    # ... comments, likes, tags

The lightweight, PersonalisedPost, is our pointer collection, denormalized specifically for feed generation:

class PersonalisedPost(BaseModel):
    user_id: str          # The FOLLOWER (Viewer)
    post_id: str          # Reference to the actual Post
    priority_post_id: str # The "Smart" Sorting Key
    timestamp: datetime

Why this works: the PersonalisedPost document is tiny, so we can fit millions of them in RAM. The priority_post_id is a compound key we use specifically for status management.

Strategic denormalization

In distributed systems, strict normalization is often the wrong default. We deliberately duplicate some data to cut join latency.

Immutable attributes: we store the author's name and avatar directly on the Post document, which removes the need to hit the User collection every time a feed renders.

Eventual consistency: if a user changes their profile picture, we don't rush to update every past post they've made. We accept that historical data can be slightly stale, and that trade-off meaningfully cuts write pressure on the posts collection.

Cache locality: keeping the PersonalisedPost collection small, just IDs, means the working set for active users' feeds fits entirely in RAM (the WiredTiger cache), avoiding disk page faults while people scroll.

Optimizing for the next stage of scale

The current architecture handles our current load well, but we've got a clear roadmap for the next 10x.

Capped allocations: infinite scroll is rarely actually infinite, realistically users only care about the last 500 to 1,000 posts, so we can use TTL indexes on the personalisedposts collection to automatically prune old entries and keep per-user storage flat.

The celebrity edge case: for users with a million-plus followers, fanning out a million writes is too slow. We'll move to a hybrid model, push-based fan-out for normal users, and pull-based for celebrities, where loading your feed means merging your push bucket with pull queries against the celebrities you follow.

Sharding strategy: we can shard the personalisedposts collection by user_id, which keeps all of a given user's feed entries on the same physical shard, making retrieval a single-server operation even at very large scale.

Results and takeaways

Moving to this write-optimized schema got us consistent read latency, feeds load in under 50ms whether someone follows 5 people or 5,000, simpler pagination with no complex offset queries, just paginating linearly through the personalisedposts list, and unread status handled without any extra lookups.

There are real trade-offs. Writing is slower, and creating a post for a celebrity with a million followers would mean a million inserts. In those celebrity edge cases we'd fall back to a pull model as part of the hybrid approach, but for a community-driven platform like Hoomanely, this architecture gives us the right balance between performance and complexity.