Design a recommendation system
Design the "For you" feed of a large online marketplace: 200 million listings, 30 million shoppers a day, and a home page that should show each of them 50 things they're likely to want, in under 150 milliseconds. The same shape powers "up next" on video sites, playlists in music apps and "people also bought" on product pages: find a few hundred good candidates cheaply, rank them carefully, then make the list varied and fair.
1. The prompt
"Design the personalised recommendations on a marketplace's home page."
What makes it hard:
- Too many items to score. No model can score 200 million listings for every page view in 150 ms. The design is a funnel.
- Two clocks. Models train on months of data overnight, but a shopper who just searched for hiking boots expects boots on the next page.
- It learns from itself. People can only click what was shown, so yesterday's recommendations shape tomorrow's training data. Without care, new items never get a chance and the feed narrows.
2. Frame the problem
Which surface?
The home page "For you" feed: about 50 items, refreshed each visit. "Similar items" on product pages is a follow-up.
Each surface has a different goal and latency budget; pick one and design it well.
What are we optimising?
Purchases per visit, with guardrails: returns, complaints and seller fairness must not get worse.
Clicks alone reward clickbait; the target decides the training labels (9.5).
How big?
200 M active listings, 2 M new ones a day; 100 M monthly shoppers, 30 M a day, ~5 home visits each.
2 M new items a day means cold start is constant, not an edge case.
How fresh?
Within the same visit: what someone looked at a minute ago should count.
Needs a near-line path alongside the nightly batch (9.3).
Constraints?
Never show sold-out or banned items, nothing the person already bought (for one-off goods), at most a few items per seller in one feed.
Business rules live in a re-ranking step, not in the model.
3. What it must do
- Return a ranked, varied list of about 50 items for a shopper, quickly.
- React within the session to what they view, search and add to their basket.
- Learn continuously from what was shown and what happened next.
- Handle the new: new shoppers with no history, and new listings nobody has seen.
Not now: search ranking (similar machinery, different input), email recommendations, sponsored placements (that's D41).
4. How well it must do it
| Target | Why this number |
|---|---|
| Feed served in under 150 ms at p99 speed | The home page waits for it; slower pages sell less. |
| Session signals used within 30 s freshness | The next page should reflect the last few clicks. |
| Always an answer: a fallback list if any part fails surviving failures | An empty home page is worse than a generic one. |
| Every new listing shown to some shoppers within a day fairness | Sellers leave a marketplace where new items are invisible. |
| No personal data leaks through recommendations privacy | "Because you bought…" on a shared screen can reveal too much. |
5. Numbers that change the design
| Quantity | Working | → so we… |
|---|---|---|
| Feed requests | 30 M × 5 = 150 M a day ≈ 1,700 a second, ~6,000 at peak | → so serving is a modest fleet; the cost is per-request model work |
| Scoring everything | 200 M items × 20 µs per score = 4,000 s of CPU per request | → so retrieve ~1,000 candidates cheaply, then rank only those (1,000 × 20 µs = 20 ms) |
| Item embeddings | 200 M × 128 numbers × 4 bytes ≈ 100 GB; compressed to 32 bytes each ≈ 6.4 GB | → so an approximate nearest-neighbour index fits in memory, sharded or compressed |
| Impression logs | 150 M feeds × 50 items ≈ 7.5 B impressions a day | → so a stream into the warehouse, sampled negatives for training |
| New listings | 2 M a day, with no clicks yet | → so content-based embeddings and an exploration budget (9.4) |
Illustrative numbers.
6. The nouns
- Shopper and item (a listing), each with an embedding: a vector where similar tastes and similar items sit close together.
- Event: an impression (shown at position 7), click, add to basket, purchase, return.
- Candidate: an item proposed by one retriever, with the retriever's name and score.
- Feature: a number the ranker uses, like "this shopper's purchases in this category, last 30 days".
- Model version: trained weights plus the exact feature definitions they expect.
events (shopper_id, ts) → item_id, kind, position, surface, request_id (stream → warehouse) item_features item_id → price, category, seller, stock, 7-day sales, embedding_v12 (online store) shopper_features shopper_id → category affinities, price band, embedding_v12, last_50_views (online store) co_bought item_id → [(item_id, score)] top 200 (nightly) ann_index embedding_v12 → item_id (memory, sharded) feed_log request_id → model version, candidates, scores, final order (for training and debugging)
7. The contract
GET /v1/feed?surface=home&limit=50&cursor=… → [{item_id, reason?}], request_id, next cursor
POST /v1/events [{request_id, item_id, kind: impression|click|basket|purchase, position, ts}] (batched by the app)
Internal: POST /rank {shopper_id, candidates[], context} → scores (gRPC, same data centre)
The shopper comes from the auth token. The request_id ties every later click and purchase back to exactly what was shown and by which model version: without it, training data can't be trusted.
8. First working version
Start simple and still correct: a nightly job computes "bought together" lists and popular items per category; the feed service merges a shopper's recent views with those lists, filters, and returns. Then replace the merge with a retrieve → rank → re-rank pipeline, keeping the simple version as the fallback.
8.1 Serving one feed
8.2 The learning loop
9. Make it hold up
9.1 Retrieve cheaply, rank carefully speed
Each retriever is good at one kind of candidate, so several run in parallel, each returning a couple of hundred: item-to-item ("bought together" with what you viewed), embedding search (items near your taste vector, from a two-tower model), trending in your region and categories, and fresh items for exploration (9.4). Retrievers can be wrong cheaply, because the ranker sees everything they propose. Measure each one by how often it supplies what people end up buying.
9.2 Features that mean the same thing in training and serving correctness
| Approach | Why | |
|---|---|---|
| ✗ Bad | Data scientists compute features in SQL for training; engineers rewrite them in the service for serving | Two implementations drift apart ("last 30 days" includes today in one, not the other) and the model quietly gets worse in production: training-serving skew. |
| ~ Good | Log the features exactly as served, and train on the logs | No skew, but a new feature can only be trained on after it has been logged for weeks. |
| ✓ Great | A feature store: each feature defined once, computed by batch and stream jobs into an offline store (history, for training) and an online store (latest value, for serving); training joins features as of the time of each event, never later; plus logging served features to check for drift | One definition, both paths; no leaking the future into training; skew is measured, not discovered. |
9.3 Reacting within the session freshness
Three speeds, each cheap at its own job:
- Batch, nightly: train models, rebuild embeddings, co-bought lists and the ANN index.
- Near-line, seconds: a stream job reads click and view events and updates the shopper's online features ("last 50 views", category counts) and the session embedding (an average of recently viewed item embeddings).
- Online, per request: the retrievers query with the latest session embedding, and the ranker reads features fresh from the online store.
So the boots viewed a minute ago change which candidates come back and how they're scored, without retraining anything.
9.4 New items, new shoppers and feedback loops fairness
| Problem | What we do |
|---|---|
| A new listing has no clicks, so no collaborative signal | Embed it from its title, photos, category and price with a content model, so it lands near similar items in the ANN index on day one. |
| The ranker never shows what it isn't sure about | Reserve a few slots per feed (say 3 of 50) for exploration: items with few impressions, chosen with a bandit rule so promising ones get more traffic. The lab below shows why. |
| A new shopper has no history | Popular items in their region and the category they arrived from; a few taste questions at sign-up; then the session signals take over within minutes. |
| Items shown at the top get clicked because they're at the top | Log the position of every impression and correct for it in training (position as a feature, set to a fixed value when serving), so the model learns appeal, not placement. |
| The feed narrows to one interest | The re-ranker enforces variety: at most N per category and per seller in any 10 items. |
9.5 Knowing it works quality
- Offline first: replay last week's sessions and check whether bought items were in the top 50 (recall) and how high (ranking metrics such as NDCG). Cheap, fast, and only a filter: a better offline score often doesn't win online.
- Then an A/B test: 1–5% of shoppers get the new model; compare purchases per visit and the guardrails (returns, complaints, seller spread, latency) for two weeks, to cover weekly patterns.
- Roll out like code: a model version is deployed with its feature definitions, canaried, and can be rolled back in minutes (Module 27).
- Watch for drift: feature distributions and click rates by model version, daily.
9.6 The whole design after the deep dives
10. Interviewer follow-ups
The ranker is down.
Serve the candidates in a simple order (retriever score, popularity), filtered by the re-ranker. Worse than personalised, much better than empty. If retrieval is down too, serve a cached popular list per region, refreshed every few minutes.
Black Friday: 10× traffic.
Pre-scale the ranker fleet (the schedule is known), shrink candidates per retriever from 200 to 100, and cache feeds for a few minutes for logged-out visitors. Shed the exploration slots last, not first, or new sellers lose their biggest day.
"Because you bought…" revealed a gift on a shared tablet.
Sensitive categories (health, gifts, adult) are excluded from explanations and from home-page retrieval unless the shopper opts in; recently bought gifts can be hidden. Explanations come from an allow-list, never raw history.
How do you add a "similar items" carousel on product pages?
Reuse retrieval: item-to-item lists and the item's embedding neighbours, then a lighter ranker with the product as context. Precompute for popular items and cache.
A seller games the system with fake clicks.
Train on purchases and long engagement more than clicks, discount clicks from new or suspicious accounts, and cap how fast an item's features can change in a day. Fraud signals from payments feed back as labels.
Why not one big model end to end?
It can't score 200 M items per request. Two stages let each part do what it's good at: retrieval for recall at low cost, ranking for precision on a small set.
11. When things break
| Failure | What users see | How we detect it | What limits the damage |
|---|---|---|---|
| Ranker slow or down | A less personal feed | Latency and errors per stage | Timeout per stage, fallback ordering |
| Stream job lags | Feed ignores the last few minutes | Consumer lag | Nightly features still there; catches up |
| Bad model version | Fewer purchases, odd items | Canary metrics by version | Automatic rollback; previous version kept warm |
| Feature pipeline bug | Gradual quality loss | Drift checks on feature distributions | Training refuses data that fails validation |
| ANN index build fails | Embedding retriever on yesterday's index | Build job alert | Serve the last good index; other retrievers unaffected |
12. Wrap-up and the clock
Trade-offs we made:
- A funnel (several cheap retrievers, one careful ranker, a rules-based re-ranker), accepting more moving parts for latency.
- Three speeds: nightly training, near-line features, per-request scoring.
- A feature store with point-in-time training data, to avoid skew and leakage.
- A small, explicit exploration budget, paid for in a little short-term click rate.
- Purchases with guardrails as the target, not clicks.
What we'd watch: p99 per stage · recall of each retriever · purchases per visit by model version · share of impressions going to new items · feature drift · stream lag.
At 10×: more ranker replicas (the per-request cost); a sharded ANN index; the batch side grows with data, not traffic.
A 60-minute interview
| 0–5 | Frame: surface, objective, scale, freshness |
| 5–12 | Must do, how well, the "score everything" number |
| 12–18 | Nouns, events with request IDs |
| 18–30 | First version, then retrieve → rank → re-rank |
| 30–52 | Deep dives: funnel (9.1), features (9.2), new items (9.4) |
| 52–60 | Evaluation, follow-ups, wrap-up |
A 90-minute interview
| 0–8 | Frame, including guardrails and privacy |
| 8–16 | Must do, how well, numbers |
| 16–24 | Nouns and contract |
| 24–38 | First version and the learning loop |
| 38–78 | Deep dives 9.1–9.5 and the final picture |
| 78–86 | Follow-ups: outages, peak day, fraud |
| 86–90 | Trade-offs, metrics, 10× |
| Level | What a strong answer shows |
|---|---|
| Mid-level | Precomputed lists (bought together, popular), a feed service, event logging, a cache. |
| Senior | The funnel with numbers, embeddings and ANN retrieval, a feature store, near-line freshness, cold start, A/B testing with guardrails, fallbacks. |
| Staff+ | Feedback loops and position bias, exploration as a budget, point-in-time correctness, model rollout and drift, privacy of explanations, marketplace fairness. |
Common mistakes:
- One model scoring the whole catalogue per request.
- Optimising clicks and calling it success.
- Training and serving features computed by different code.
- No plan for new items or new users.
- No request ID linking outcomes to what was shown.
13. Try it, check yourself, read more
The exploration lab. Five established items have years of clicks. A new item arrives with none. The feed shows the item with the best estimated click rate, except for a share of impressions spent exploring at random. Choose how good the new item really is and how much to explore; each setting is simulated 20 times over 20,000 impressions.
The established items' true click rates are 4.0%, 3.5%, 3.0%, 2.5% and 2.0%, each with 4,000 past impressions. Exploration here is "epsilon-greedy", the simplest bandit rule; real systems use smarter ones (Thompson sampling, upper confidence bounds).
Scoring one item takes 20 µs and there are 200 M items. How do you serve a feed in 150 ms?
A model is great offline and worse in production, and the training features were computed by a different code path from serving. What is this called?
Why does a purely greedy feed never discover a great new item?
What should the A/B test's main metric be for a marketplace feed?
| Resource | Why |
|---|---|
| Deep Neural Networks for YouTube Recommendations (2016) | The classic two-stage paper: candidate generation, then ranking. |
| Wide & Deep Learning for Recommender Systems (2016) | A ranking model that combines memorised and generalised signals, from Google Play. |
| System design for recommendations and search (Eugene Yan) | How many companies split offline and online, retrieval and ranking. |
| Feast documentation | An open-source feature store: offline and online stores, point-in-time joins. |