Building a social feed in MongoDB: hybrid fan-out, snapshot pagination and ad slots that survive paging

How SocialPro's feed pushes ordinary posts and pulls large accounts, pages ranked results from a frozen snapshot with a chronological floor, and lets the ads module inject slots through FEED_INJECTORS.

DSDilawar Singh 7 min read 1 views
Building a social feed in MongoDB: hybrid fan-out, snapshot pagination and ad slots that survive paging

SocialPro is our social network, chat, AI, ads and creator-monetisation platform, built on Next.js 15 and MongoDB. Its home feed has to do several things at once. It shows posts from people you follow, friends, communities and AI personas, ranks them for "For you", pages through them without repeats, and places sponsored posts at a steady rhythm. This article covers the three decisions that hold it together: hybrid fan-out, cursors that include a frozen ranked snapshot, and a small injector hook that lets the ads module add slots without the feed knowing what an ad is.

Fan-out on write, with an escape hatch

Fan-out on write makes reads cheap but gets expensive for accounts with huge audiences. Fan-out on read avoids that but turns every page load into a multi-way merge. SocialPro does both and decides per post:

export const FANOUT_THRESHOLD = 5_000;

/** Whether this post is delivered by push (fan-out) rather than pulled at read time. */
export function isPushed(p: Pick<IPost, 'authorId' | 'aiGenerated' | 'communityId' | 'visibility' | 'status'>, author: { isAi?: boolean; counts?: { followers?: number } } | null, threshold = FANOUT_THRESHOLD): boolean {
  if (!p.authorId || p.visibility === 'anonymous' || p.aiGenerated || p.communityId || p.status !== 'published') return false;
  if (!author || author.isAi) return false;
  return (author.counts?.followers ?? 0) < threshold;
}

Ordinary members' posts are pushed. Large accounts, AI personas, anonymous whispers and community posts are pulled at read time. The push itself runs as a background job, de-duplicated by a uniqueKey of fanout:<postId>, and writes one FeedItem per recipient in batches of 1,000:

await FeedItem.bulkWrite(
  chunk.map((r) => ({
    updateOne: {
      filter: { ownerId: new Types.ObjectId(r.ownerId), postId: post._id },
      update: { $setOnInsert: { ownerId: new Types.ObjectId(r.ownerId), postId: post._id, authorId: post.authorId, createdAt: post.createdAt, reason: r.reason, score: Math.round(AFFINITY[r.reason] * boost * 100) / 100 } },
      upsert: true,
    },
  })),
  { ordered: false },
);

The upsert on a unique { ownerId, postId } index makes retries write nothing new. The audience follows the post's visibility (public, followers, friends, close friends or a custom list) and drops blocks in either direction, plus anyone who muted the author. After each batch, recipients' user rooms get a feed:new ping through the realtime layer. FeedItem rows expire after 30 days through a TTL index, so the collection doesn't grow forever.

At read time, home merges up to four chronological sources: the viewer's FeedItems, their own posts, followed large accounts and AI personas, and joined communities. Each source has the same shape, (after, n) => Promise<Row[]>, so the pager merges them without caring where a row came from.

Cursors that never shift

Every feed is cursor-paged, and every cursor is opaque base64url JSON. There are three kinds:

/** `n` counts entries served so far (feed injectors use it to keep slot spacing across pages). */
export type ChronoCursor = { k: 't'; t: number; id: string; n?: number };
export type ScoreCursor = { k: 's'; s: number; id: string; n?: number };
export type SnapshotCursor = { k: 'snap'; sid: string; o: number; n?: number };

The chronological cursor is a keyset of createdAt plus _id. New posts land above it, so page two never shifts when someone posts while you scroll, and the id tie-break means two posts with the same millisecond are neither skipped nor repeated:

export function afterChrono(c: ChronoCursor | null, field = 'createdAt', idField = '_id'): Record<string, unknown> {
  if (!c) return {};
  const t = new Date(c.t);
  const id = new Types.ObjectId(c.id);
  return { $or: [{ [field]: { $lt: t } }, { [field]: t, [idField]: { $lt: id } }] };
}

decodeCursor is strict: a bad kind, a non-finite number, an invalid id, a negative or fractional offset, or anything over 400 characters returns null, and the feed restarts from the top instead of throwing. The unit tests cover each case.

One more rule lives in lib/feed/entries.ts. Rows are filtered by visibility page by page, so a page can scan rows it then hides, and "the cursor always points at the last row scanned, never at the last one returned, so nothing is skipped or repeated."

Ranked pages from a frozen snapshot

Keyset cursors don't work for "For you": scores move as people react, so paging by score would repeat and skip posts. We rank once and freeze the result.

On the first page, windowOf reads the newest 150 rows from each source and computes a floor: the oldest point where every source is still complete. A source that returned fewer rows than requested is complete all the way down. One that hit the limit is only complete above its last row. Interest-based and popular posts from the last two days join the candidates if they sit above that floor. Everything is ranked by recency decay, engagement, author affinity, interest match and the author's plan reach boost, and the top 400 are written to a FeedSnapshot document, which a TTL index expires after three hours.

Later pages carry a snap cursor with the snapshot id and an offset. When the snapshot runs out, the feed continues chronologically below the floor, and the floor is what makes that hand-off safe:

if (r.offset < ids.length) return { entries: r.entries, next: { k: 'snap', sid: String(snap._id), o: r.offset } };
if (!snap.floorId) return { entries: r.entries, next: null };
const floor: ChronoCursor = { k: 't', t: snap.floor.getTime(), id: String(snap.floorId) };
if (r.entries.length >= limit) return { entries: r.entries, next: floor };
const rest = await pageChrono(viewer, a, continuation, floor, limit - r.entries.length, { ...opts, seen });

Every post at or above the floor was a ranking candidate. Everything below it is reached by the chronological continuation. A seen set removes any overlap at the boundary. If a snapshot has expired, or belongs to someone else (the lookup filters on ownerId), the feed falls back to chronological paging rather than erroring. With the feed.ranking flag off, home is purely chronological. Discover uses the same snapshot machinery with its own scoring.

Ad slots through FEED_INJECTORS

Auctions, targeting, frequency caps and budgets belong to the ads module, not the feed. The feed exposes one hook instead:

export type FeedSlotPosition = { feed: FeedName; offset: number; key?: string };
export type FeedSlotInjector = (viewer: Viewer | null, posts: FeedEntry[], position: FeedSlotPosition) => Promise<FeedEntry[]>;

export async function runInjectors(viewer: Viewer | null, entries: FeedEntry[], position: FeedSlotPosition): Promise<FeedEntry[]> {
  let out = entries;
  for (const inject of FEED_INJECTORS) {
    try {
      out = await inject(viewer, out, position);
    } catch (err) {
      console.error('[feed] injector failed', err);
    }
  }
  return out;
}

loadFeed calls it last, after ranking and visibility filtering, for every feed. A failing injector is logged and the page is served without it. A broken ad server should never cost anyone their feed.

The ads module registers its injector once per process, guarded by a flag on globalThis so hot reloads don't add it twice.

Spacing has to survive pagination. If each page of 15 just put an ad after every sixth post, the rhythm would reset at every page boundary. So every cursor carries n, the number of entries served so far, and slots are counted across the whole stream:

export function adSlots(offset: number, length: number, spacing: number = AD_DELIVERY.feedSpacing): number[] {
  const out: number[] = [];
  for (let i = 0; i < length; i++) if ((offset + i + 1) % spacing === 0) out.push(i);
  return out;
}

The unit test pins the behaviour: adSlots(0, 15) gives [5, 11] and adSlots(15, 15) continues with [2, 8, 14]. n counts posts, not ads, because loadFeed adds r.entries.length before injection.

The injector itself is short. It only runs on home, following, discover, popular and tag, never on whispers, profiles, bookmarks, communities or search. It skips viewers on an ad-free plan and respects the ads.delivery flag. It takes the page's top hashtags as a contextual signal and asks serveAds for one ad per slot, with at most one per advertiser per page.

Trade-offs and what we're cautious about

  • The threshold is a constant. FANOUT_THRESHOLD is 5,000 followers for every deployment. It's a parameter of isPushed for tests, but operators can't tune it without a code change.
  • A snapshot is a write on a read. Each fresh "For you" session inserts a document of up to 400 ids. The three-hour TTL keeps the collection small, but it is still a write on a path that is otherwise read-only.
  • Pushed history is bounded. FeedItems expire after 30 days. Older pushed posts drop out of home, though they stay on profiles and in the other feeds.
  • The "new posts" count is an estimate. It adds up counts from the pushed, pulled and community sources, capped at 99, and doesn't apply the full visibility filter. It's a hint to refresh, not an exact number.
  • Injector order is implicit. Injectors run in array order, each seeing the previous one's output. A second injector would need to account for slots already taken.

Wrapping up

Pushing posts from ordinary accounts and pulling from large ones keeps both sides cheap. Keyset cursors keep chronological pages stable, a frozen snapshot with a floor does the same for ranked ones, and the injector hook keeps the ad auction out of the feed code.

SocialPro is available on our marketplace, and the live demo lets you scroll the home feed and Discover yourself. If you're designing a feed or timeline for your own product, this is the kind of work our SaaS engineering team does.

DS

Written by

Dilawar Singh

CTO, DEV3 Technologies · ClinicPro, Inventra & WorkPro

Dilawar Singh is the CTO of DEV3 Technologies. He leads the engineering of DEV3’s own products — ClinicPro, Inventra and WorkPro — which are built, sold and supported by the DEV3 team from Mohali, India.

Keep reading

All articles