John
← Blog

Rank at Write, Diversify at Read: Rebuilding a Marketplace Feed Under Load

Rank once at write. Diversify per view. How a marketplace feed survived filter cardinality and write churn.

Every catalog request on a multi-merchant marketplace is a negotiation between three parties who all want something different. The business wants strong products first: good sales velocity, high ratings, fresh listings, things actually in stock. The marketplace wants fairness, so no single seller owns the whole scroll. And the infrastructure wants speed, even when a shopper stacks five filters on top of each other: condition, location, brand, price.

This is the story of the default browse feed for exactly this kind of marketplace: why the first version looked done and quietly rotted instead, and what replaced it. A split between a write path that ranks and a read path that shapes turned out to be the right seam for something that hadn’t been built yet, personalization.

The one that looked done

The first version treated the feed as a cache problem, full stop. A cache miss meant pulling candidates from Postgres with the request’s filters baked into the SQL, scoring them on sales, rating, and recency, interleaving sellers so page one wasn’t a wall of one merchant, and writing the whole ordered ID list into Redis as JSON. Reads sliced the page out of that list, dropped any ID that no longer passed eligibility, and hydrated the surviving rows from Postgres.

It had a decent story for freshness. Time buckets gave each list a natural expiry, and if the current bucket was cold but the previous one was still warm, the read path served the stale list immediately and rebuilt in the background. Classic stale-while-revalidate, and it worked. Warm hits were cheap, merchant spread held steady within a bucket, and if the whole thing caught fire the feature could be flipped off in favor of plain SQL.

Then two things happened that the design hadn’t planned for: people kept editing products, and shoppers kept combining filters.

Nothing on the write side invalidated the cache. A product going out of stock, a price change, a review landing, none of it touched the cached list. Eligibility filtering kept pages correct (bad IDs got dropped at read time), but it didn’t repair the order and it didn’t backfill the gap left behind. After any burst of inventory churn, shoppers started getting visibly short pages.

Every filter combination was its own cold start. Condition times location times brand times price meant a combinatorial explosion of cache keys, and each new combination meant a fresh, expensive SQL build from scratch. There was no shared foundation those variants could cheaply slice from. Every facet view paid the full ranking cost again.

Underneath both problems sat a third one: diversity was frozen the moment the list was written. Once cached, a later facet view could still open on a wall of one merchant’s inventory, because nothing ever revisited the visible prefix after the fact.

V1 wasn’t wrong so much as scoped to a world where filters were few and writes were rare. Neither stayed true.

Finding the actual seam

The fix wasn’t a better cache. It was noticing that the design had been asking three different questions in one place, at one cost, on one request.

Question When it should actually run Why
Who is the best product? Write time Quality needs joins across orders, reviews, and inventory, too expensive to recompute per browse request.
How should merchants appear on the page? Snapshot build Diversification depends on page size and a sliding window; one ranked pool can feed many facet views.
What does this specific user see right now? Read time Stock, price, and user-specific fields have to stay authoritative in Postgres.

V1 had fused all three into a single SQL query per filter combination. That’s why every facet was a cold start, and why invalidation was awkward: there was no layer boundary to invalidate cleanly.

The rewrite drew that boundary explicitly. Rank once for everyone, at write time. Diversify per view, at read time.

V2: a ranked store, and disposable snapshots on top of it

On the write side, every eligible product gets a quality score computed in Postgres: normalized sales, rating weighted by review volume, recency decay, inventory, and a small explore boost so thin merchant catalogs aren’t buried forever. The exact weighting is a product call, not an engineering one. What matters is that the score is durable and cheap to recompute.

Those scores publish into Redis as three things. Sorted sets per catalog segment, where score equals quality. The product metadata a read actually needs (merchant, price, facet tags). And bitmaps for the discrete facets (condition, brand, location, wholesale, status), so filtering can happen by intersecting bitmaps instead of re-querying SQL. Publishing runs incrementally on product and review changes, with periodic full refreshes of the busiest segments so the “all products” view doesn’t slowly drift out of sync.

On the read side, a request that qualifies for the diversified feed resolves a segment, asks a snapshot layer for an ordered ID list keyed to that segment and facet combination, slices the page with a handful of extra IDs held in reserve, and hydrates from Postgres in that order. If a few IDs fail eligibility mid-hydration, someone sold out between snapshot and request, the reserved IDs backfill the gap. A couple of rounds of that, never a full re-rank.

Listing pipeline: online candidate retrieval, diversification, pagination, and hydration over a Redis and Postgres serving layer fed by an offline quality-score pipeline

Online path shapes the page; the offline pipeline ranks once and feeds Redis and Postgres.

Large segments walk the ranked store and intersect facet bitmaps in memory. Small or empty segments skip straight to a narrow SQL query instead of consulting an incomplete Redis view. Both paths share one important thing: a short-lived, facet-less base pool per segment. The expensive fetch happens once, and every facet variant rebuilds from that shared pool instead of hitting the database again. That single change is the direct answer to V1’s per-fingerprint cold-build problem.

And if anything in the ranked path throws (Redis is down, a segment is missing, whatever), the whole thing falls open to ordinary SQL. Shoppers lose merchant interleaving and quality ranking for that one request. They don’t lose the page. A plain correct page beats a clever empty one every time.

Three decisions that carried the design

Invalidate by epoch, not by deletion. Snapshots are keyed by segment, facet hash, and config version. Instead of walking Redis deleting every variant of a segment, a write just increments a segment epoch. Any snapshot pointer carrying an older epoch is treated as a miss on the next read. Bursts of edits, a stock flip storm, a batch price update, debounce into one epoch bump per window, so you pay for one invalidation wave instead of one per write. The tradeoff is coarseness: a single product edit invalidates every facet snapshot on that segment, whether or not the edit actually touched that facet’s results. Coarse, but cheap and provably correct, which beat clever and occasionally wrong.

Stale-while-revalidate, specifically to survive invalidation storms. When an epoch bumps, nobody should block on a synchronous rebuild. A fresh epoch match returns immediately. A stale one, where the epoch moved but a last-good body still exists, returns that last-good body now and rebuilds once in the background, behind a single-flight lock so only one worker pays the rebuild cost. Only a true cold miss, no last-good body at all, blocks a request on a synchronous build. Readers occasionally see slightly stale ordering right after a bust; that’s a far better trade than a latency spike or a thundering herd hitting Postgres at once. The base pool carries its own separate TTL from the snapshot epoch. Two independent clocks, on purpose, so ranking membership can lag a write by a bounded window without forcing every facet rebuild back onto the hot path.

Diversify in three passes, not one. First, a cheap round-robin interleave with a sliding-window merchant cap: nothing dominates a window, and anything that would violate it gets rotated to later, with a full rank-order tail appended if the window ever stalls, so the snapshot stays complete. Second, a tier-preserving shuffle. Pure ranked order looks identical to a shopper on every visit, so items get shuffled within equal-score tiers using a time-bucket seed. High tiers still beat low tiers, but there’s rotation inside a tier. Third, a prefix repair pass, since that shuffle can accidentally re-cluster merchants right at the top of the page. A final walk over the visible prefix swaps items minimally to restore the window constraint. The shuffle buys variety. The repair buys fairness on the screen shoppers actually see.

Why this is the right place to hang personalization

Right now, every shopper sees the same quality ranking. There’s no user identity anywhere in the ranker, and that’s deliberate, not an oversight. Join-heavy quality computation belongs on the write path, computed once, shared by everyone. Page shaping (facets, merchant spread, hydration) belongs on the read path, computed per view. Personalization is a third thing, and it should live where the second thing lives: as a blend on the read path, not a rewrite of the ranked store underneath it.

Concretely, that means keeping the global quality scores and ranked segments as one shared candidate spine. It means deriving a per-user affinity signal from views, clicks, and orders, and blending it in at snapshot build, or as a thin re-score over just the page window, using metadata already published for read time. It means running diversification after that blend, not before: a personalized feed that collapses onto one merchant’s catalog has failed the actual job of the marketplace. And it means failing open exactly like the ranked path already does. If affinity is cold, missing, or slow, serve global quality. Personalization should be additive. It should never be the thing standing between a shopper and a page that loads.

Anonymous and cold-start users just stay on global ranking until there’s enough signal to bother blending. The affinity layer hasn’t shipped yet. The point of V2 was making sure it could ship later without re-ranking per user in Redis, and without teaching the write path anything about individuals at all.

Known limitations

None of these are bugs so much as tradeoffs accepted at the current scale, worth revisiting if catalog size, facet cardinality, or multi-region consistency jumped a tier.

  • Coarse invalidation. One write busts every facet snapshot for its segment, debounced, never eliminated.
  • Two clocks. Snapshot epoch and base-pool TTL drift independently; candidate membership can lag a write briefly.
  • Uneven publish cadence. Full-catalog segments get scheduled rebuilds. Quieter category segments ride on incremental publish and can lag until something touches them.
  • Price isn’t a bitmap. It’s a continuous range, so it filters after metadata decode rather than by intersection.
  • No per-request diversity pass. Heavy facet combinations can still cluster merchants on a page. Repair only runs at snapshot build time, not after the snapshot freezes.

Alternative design choices

A few decisions here could have gone another way, and it’s worth naming what got passed on and why.

Ranking and page shaping could have stayed fused, with more caching thrown at the cold-start problem. That path was rejected because sharing one base candidate set across every facet variant, instead of re-deriving it per filter combination, is what actually fixed the combinatorial blow-up. Invalidation could have meant deleting every stale key outright. That was rejected too, because epochs plus stale-while-revalidate survive write bursts that delete-everything and block-on-rebuild both fall over under. Eligibility failures could have triggered a re-rank in SQL. Instead, hydrating with reserved backfill IDs turned out far cheaper than re-ranking inside a live request. The ranked path could have been made a hard dependency. It wasn’t, because the feed is a view, not the source of truth, and failing open to the database beats a clever empty page. And personalization could have meant a separate ranker per user. Instead, one global store serving many users, with affinity blended in after retrieval, scales without ever becoming a hard dependency for a page to render.

Most of the actual engineering here wasn’t the ranking math or the diversification logic. It was deciding, line by line, what runs once for everyone, what runs once per filter view, what has to stay on the request that returns real rows to a real shopper, and what can simply wait until you know who’s scrolling.