ML system design — feeds, search, ads, RAG, and every infra layer underneath
A deep, end-to-end ML system-design reference built around the prompts top companies actually ask: design a feed, watch-next video ranking, search, an ads system, a RAG product. It is engineered to kill the thing that trips up most ML candidates in the room: the pure scalability / infra layer — Kafka, streaming, caching, GraphQL, datastores, model serving. By the end you should narrate "we publish engagement events to a Kafka topic partitioned by user_id, a Flink job keeps windowed aggregates in an online feature store" without a flicker.
Run the same 8-step script on every prompt (ch1). The interview is won or lost on whether you sound fluent about infra: Kafka is a partitioned, replayable append-only log — own backpressure, idempotency, exactly-once vs at-least-once (ch2); caching is about where, invalidation, stampede/hot-key protection, and fan-out-on-write vs fan-out-on-read (ch3); GraphQL is one typed endpoint with resolvers + DataLoader to kill N+1, federated across Go subgraphs at Reddit (ch4); pick Postgres/memcached/Cassandra/Redis/RabbitMQ for the right reason, with CDC + multi-region (ch5); serve models with dynamic batching behind a feature store under a p99 budget (ch6). Then ch7–ch12 are full worked designs (home feed, comment ranking, notifications, community recs, search, safety). ch13 is the cheat-sheet. ch14–ch16 are three more full 8-step designs (content safety, ads ranking, search ranking); ch17–ch18 are the two real darkinterview ML-SD questions (#18 Video Recommendation built around data-flow/logging/observability, cross-linked to q18; #19 Feature Store on the production side, cross-linked to q19); ch19 is "what reads as senior/staff."
New, grounded in verbatim 1Point3Acres staff intel: the 2026 Staff MLE offer was for Reddit Answers (RAG over Reddit) — so ch20 is a full RAG worked design (query understanding → hybrid retrieval → rerank → grounded/cited generation → faithfulness eval). The staff ML-SD was literally "design a watch-next recommender for the mobile video browser," and interviewers grilled logging/observability/data-flow over the model — ch17 is deepened for exactly that. The staff ML case study round is modeling-only (e.g. ad-click) — ch21 is that format with ad-click + dwell-time fully worked. ch22 = "design a subreddit chatroom" (a reported coding/design hybrid). ch23 = the 10 most-likely prompts + a 60-second opening for each. ch24 = rapid-fire follow-up drills. ch25 = two more reusable architecture diagrams. Hedge the two flagged claims (Debezium "reportedly"; two-tower is confirmed only for notification retrieval, not the home feed). Questions are candidate-reported (darkinterview + 1Point3Acres), not official.
Most candidates lose ML system-design rounds not because they lack knowledge but because they wander. The fix is a fixed script you run on every prompt — "Design Reddit's feed," "Design Instagram Reels," "Design a notification system" — all collapse onto the same 8 steps. Memorize the spine; vary the organs.
1.1 The 8 steps
| # | Step | What you actually say / produce | Time (of 45 min) |
|---|---|---|---|
| 1 | Clarify problem + objective | What surface? Who's the user? What do we optimize — short-term engagement (clicks, dwell) vs long-term health (retention, healthy communities, not regret/outrage)? Name the proxy metric and the guardrail metric. | 4–6 min |
| 2 | Requirements + scale math | Functional (rank a feed, return top-K, update on vote). Non-functional (p99 latency, availability, freshness, cost). Then back-of-envelope QPS / storage / write volume. | 4–6 min |
| 3 | ML problem framing | Cast as funnel: retrieval → ranking → re-ranking. Define the label (implicit: upvote/comment/dwell/hide). Online vs batch. What's the loss. | 4–5 min |
| 4 | Data + feature pipeline | Event logging → stream (Kafka) → real-time + batch features → feature store. Training data join. Label leakage / point-in-time correctness. | 5–7 min |
| 5 | Model | Candidate gen (multi-source), ranker (multi-task DNN), re-ranker (diversity/freshness/integrity). Architectures + why. | 6–8 min |
| 6 | Serving architecture + APIs | Request path, services, model server, caches, API layer (GraphQL/REST). Draw the boxes. | 5–7 min |
| 7 | Scale / latency / caching / reliability | Where the bottleneck is, how you cache, shard, degrade gracefully, kill-switch, backpressure. This is your weak spot — over-invest here. | 5–7 min |
| 8 | Eval + A/B + monitoring + feedback loop | Offline metrics, online A/B with guardrails, drift/skew monitoring, the loop back into training. | 4–6 min |
1.2 Scale estimation — do it out loud (Reddit ballpark)
Interviewers want to see you reason with numbers, not recite exact figures. Use round, defensible estimates and show the arithmetic. Reddit publicly is in the ~100M+ daily-active range, with on the order of 1M+ posts/day and far more comments and votes.
Back-of-envelope: home-feed read path
- DAU ≈ 100M. Suppose each opens the app ~5 sessions/day, each session triggers ~3 feed fetches → 1.5B feed requests/day.
- Avg QPS = 1.5e9 / 86,400 ≈ ~17k QPS. Peak is typically 2–4× average → ~50–70k QPS design target.
- Each feed fetch returns ~25 posts. If we rank ~500 candidates per request, that's ~8.5M ranker scorings/sec at peak — this is why ranking must be batched on GPU and retrieval must be cheap ANN.
- Latency budget: users feel jank above ~150–200ms for a scroll. Target p99 ≈ 150ms end-to-end for the feed.
Write path: votes & posts
- Posts: ~1M/day ≈ 12/sec avg, ~50/sec peak — trivial for the datastore; the cost is downstream fan-out and indexing.
- Votes: the heavy write. If each DAU casts ~20 votes/day → 2B votes/day ≈ 23k QPS avg, ~70k peak. Votes are the canonical case for an async queue (RabbitMQ) — you ack the user instantly and apply the vote behind the scenes.
- Comments: say 50M/day ≈ 600/sec avg. Each comment is a small row but participates in nested-tree reads.
- Engagement events (impressions, dwell, scroll, click, hide): the firehose. Easily tens of millions/sec at peak across all surfaces — this is exactly the Kafka workload Reddit confirms (500+ brokers, tens of millions of msgs/sec).
Storage napkin
- Posts: 1M/day × 365 × ~3 KB ≈ ~1 TB/year of post rows (text + metadata; media in object storage/CDN separately).
- Engagement log: tens of millions/sec × ~200 bytes is petabyte-scale/year — this lives in a data lake / columnar store, with Kafka retention measured in days, not forever.
1.3 Objective: engagement vs long-term health
Always separate the proxy you train on from the true objective. Pure click/dwell maximization degrades into rage-bait and clickbait. Senior framing: optimize a multi-task blend (upvote, comment, dwell, share) with explicit negative signals (hide, downvote, report, "not interested") and treat long-term retention / session frequency / healthy-community participation as the guardrail you defend in A/B tests. Reddit's culture explicitly weights community health ("Remember the Human"), so naming integrity/health as a first-class objective lands well.
1.4 Timeboxing a 45–60 min round
| Phase | Minutes (45-min round) | Goal |
|---|---|---|
| Clarify + objective + requirements + scale math (steps 1–2) | 0–10 | Lock scope, show numeric reasoning |
| ML framing + data/feature pipeline (steps 3–4) | 10–22 | Funnel, labels, Kafka/stream/feature store |
| Model (step 5) | 22–30 | Cand gen, ranker, reranker — your strength, be crisp |
| Serving + scale/caching/reliability (steps 6–7) | 30–40 | Your weak spot — over-invest, draw the boxes |
| Eval/A/B/monitoring + deep-dive Q&A (step 8) | 40–45 | Close strong, invite drill-down |
For a 60-min round, expand step 7 and leave 10 min for the interviewer's deep-dive. Watch the clock and announce phase transitions ("I've framed the model; let me move to serving and scale, which is where the interesting tradeoffs are").
Kafka is a distributed, durable, replayable append-only log. Producers append records to topics; each topic is split into partitions (the unit of parallelism & ordering); each record has an offset. Consumers in a consumer group each own a subset of partitions and track their offset. Ordering is guaranteed only within a partition, so you choose a partition key (e.g. user_id) to keep related events ordered and co-located. On top of Kafka you run a stream processor (Flink / Kafka Streams) that does windowed, stateful aggregation to compute real-time features. Reddit confirms 500+ brokers, tens of millions of msgs/sec, and Flink Stateful Functions executing Lua rules for content safety.
2.1 What Kafka actually is (the mental model)
Forget "message queue." The right mental model is a commit log you can rewind. Every record written to a partition gets a monotonically increasing offset (0, 1, 2, …) and is stored on disk for a configured retention period. A consumer is just a cursor reading forward through offsets. Because the log is durable and replayable:
- Multiple independent consumers can read the same data at their own pace (a training-data exporter, a real-time feature job, a fraud detector — all reading the same engagement topic).
- Replay / backfill is trivial: reset a consumer's offset to reprocess history (e.g. recompute a feature after a bug fix).
- Producers and consumers are decoupled — Kafka is the shock absorber (buffer) that absorbs traffic spikes so the slow consumer isn't crushed (this is backpressure handling, see ch13).
2.2 Topics, partitions, offsets, consumer groups
| Concept | What it is | Why it matters in an interview |
|---|---|---|
| Topic | A named stream of records (e.g. user.engagement, post.created). | Topics are your event taxonomy. One per logical event type. |
| Partition | An ordered, append-only shard of a topic. Throughput & ordering unit. | More partitions = more parallel consumers = more throughput. But ordering only holds within a partition. |
| Offset | The position of a record in a partition. | Consumers commit offsets to record progress; replay = move the offset back. |
| Partition key | A field hashed to choose the partition. | The single most important Kafka decision. Key by user_id → all of a user's events are ordered & on one partition → a stateful per-user aggregate is easy. |
| Consumer group | A set of consumers that jointly consume a topic; Kafka assigns each partition to exactly one consumer in the group. | Scale a consumer horizontally by adding instances to its group (up to #partitions). Two different groups each get the full stream. |
| Replication factor | Copies of each partition across brokers (leader + followers). | RF=3 is standard → survives 2 broker failures. This is your durability/availability story. |
| Broker | A Kafka server hosting partitions. Reddit runs 500+. | Cluster size scales throughput and storage; partitions are spread across brokers. |
2.3 Ordering, delivery semantics, retention
Ordering
Guaranteed per partition only. There is no global order across partitions. If you need per-entity order, key by that entity. If you key by user_id, a single user's vote-then-unvote arrives in order; two different users may interleave arbitrarily — which is fine.
Delivery semantics
- At-most-once: commit offset before processing → may lose on crash. Rare.
- At-least-once: process then commit → may reprocess on crash (duplicates). The common default; pair with idempotent consumers.
- Exactly-once: Kafka transactions + idempotent producer (read-process-write atomic). Real but adds latency/complexity; reserve for money/counters that must not double.
Retention vs compaction
Time/size retention: drop records older than N days. Good for event streams (engagement, clicks).
Log compaction: keep only the latest value per key. Good for "current state" topics (e.g. latest user profile). Reddit's engagement firehose is retention-based; you don't keep tens of millions/sec forever.
Throughput vs latency
Batch + compress producers → higher throughput, slightly higher latency (linger.ms). More partitions → more parallelism but more overhead and rebalances. You tune for the workload: firehose = throughput; alerting = latency.
2.4 Stream processing: Flink / Kafka Streams
Kafka moves bytes; a stream processor computes on them. The two ideas that confuse people are windowing and watermarks.
Windowing
You aggregate over time slices: tumbling (fixed, non-overlapping, e.g. votes-per-minute), sliding/hopping (overlapping, e.g. clicks in the last 5 min, updated every 1 min), session (group by gaps of inactivity, e.g. one browsing session). For a feed, "engagement velocity in the last 10 minutes" is a sliding window.
Event time vs processing time & watermarks
Events arrive late and out of order (mobile networks, retries). Event time = when it happened; processing time = when we saw it. A watermark is the processor's assertion "I believe I've now seen all events up to time T" — it lets a window fire (emit results) while tolerating bounded lateness. This is the whole reason Flink exists: correct windowed aggregates under out-of-order arrival.
Stateful operators & checkpoints
A per-user counter is state. Flink keeps it locally (RocksDB-backed) keyed by the partition key and checkpoints it to durable storage so a crashed task restores exactly. Reddit confirmed: Flink Stateful Functions run Lua rules for content-safety/moderation — small stateful actors evaluating rules over the stream in real time.
2.5 The canonical pipeline (memorize this diagram)
apps / web Kafka (500+ brokers, tens of M msgs/s) stream compute serving
┌───────────┐ produce ┌──────────────────────────────────┐ consume ┌─────────────────┐ write ┌──────────────────┐
│ client │ ─────────▶ │ topic: user.engagement │ ─────────▶ │ Flink job │ ───────▶ │ ONLINE feature │
│ SDK / API │ event │ partitioned by user_id │ │ - keyBy user_id │ │ store (Redis/KV) │
└───────────┘ (impr, │ RF=3, retention=7d │ │ - sliding 10m │ │ sub-10ms reads │
click, │ [p0][p1][p2]...[pN] │ │ windows │ └────────┬─────────┘
dwell, └──────────────────────────────────┘ │ - watermarks │ │
vote, │ │ - stateful aggs │ ▼
hide) │ (same topic, 2nd group) └───────┬─────────┘ ┌──────────────┐
▼ │ also sink │ ranker reads │
┌──────────────────┐ ▼ │ features at │
│ data-lake sink │ ┌───────────────┐ │ request time │
│ (S3/parquet) │ ◀── BATCH features│ offline store │ └──────────────┘
│ training corpus │ join on this │ (warehouse) │
└──────────────────┘ └───────────────┘
2.6 Streaming vs batch — when to use which
| Dimension | Streaming (Kafka + Flink) | Batch (Spark / warehouse) |
|---|---|---|
| Freshness | Seconds — "engagement velocity in last 10 min", "is this trending now" | Hours/daily — "user's 30-day topic affinity" |
| Use for | Real-time features, integrity/safety, counters, trending, online learning signals | Heavy historical features, training-set joins, embeddings (re)training |
| Cost | Always-on compute, harder ops, stateful | Cheap, restartable, simpler |
| Correctness risk | Out-of-order, late data, watermark tuning | Stale by definition; simpler semantics |
2.7 Fluency drills
Because the feature we want is per-user (their recent engagement, session, velocity). Keying by user_id co-locates and orders all of a user's events on one partition, so a stateful operator keeps one local aggregate per user with no cross-partition shuffle. If we instead needed per-post aggregates (trending), we'd key by post_id, or run a second job keyed differently.
It reprocesses from the last committed offset, producing duplicates. So consumers must be idempotent — e.g. dedupe by event_id, or upsert into the feature store so reprocessing converges to the same state. If duplicates are unacceptable (counters that must be exact), use Kafka transactions for exactly-once.
Tens of millions of writes/sec would obliterate Postgres, give no replay, and couple producers to a slow store. Kafka buffers the firehose, decouples producers from many consumers, and is replayable. The DB only ever sees aggregated, low-rate writes from the stream job.
2.8 Backpressure & flow control (the question they push on)
"What happens when a consumer can't keep up?" is the single most common Kafka follow-up. The right answer starts with: Kafka is the backpressure. Because it's a durable log, a slow consumer doesn't drop data or stall the producer — it simply falls behind, and you observe that as consumer lag (the gap between the latest offset and the consumer's committed offset). The system absorbs the spike on disk and the consumer catches up when load subsides.
How backpressure flows end-to-end
- Producer → Kafka: if the producer's send buffer fills (brokers slow), the producer's send() blocks once buffer.memory is exhausted (max.block.ms). That backpressure propagates up to the app, which sheds load or queues at the edge.
- Kafka → consumer: the consumer pulls at its own pace (Kafka is pull-based, not push) via max.poll.records — it never gets flooded. Lag is the signal.
- Consumer → downstream (feature store/DB): if the sink is slow, the consumer slows its poll loop, lag grows, and you autoscale the consumer group (add instances up to #partitions) or scale the sink.
What you actually do about lag
- Alert & autoscale on lag (e.g. KEDA scales consumer pods on Kafka lag metric).
- Over-partition up front so you have headroom to add consumers (you can't exceed #partitions in a group).
- Bounded staleness as graceful degradation: the read path serves last-known feature values rather than blocking on a lagging feature job (ties to ch19).
- Shed or sample the lowest-value events at the edge under sustained overload; never let a backlog become unbounded latency on the user path.
"Kafka is our backpressure: a slow consumer just accrues lag on a durable log instead of dropping data or stalling producers. We alert and KEDA-autoscale the consumer group on lag up to the partition count, and the read path degrades to bounded-staleness features rather than blocking — so a stream-processing slowdown never becomes user-visible latency."
2.9 Idempotency, exactly-once vs at-least-once (deep)
This is where senior candidates separate from mid-level. Distinguish three different "exactly-once" claims and never overclaim.
| Guarantee | Mechanism | Cost / catch | Use for |
|---|---|---|---|
| At-most-once | Commit offset before processing | Loses data on crash | Almost never (lossy metrics at best) |
| At-least-once | Process then commit offset | Duplicates on retry/crash → consumer must be idempotent | The default. Engagement features, logging |
| Idempotent producer | Producer ID + per-partition sequence number; broker drops dupes | Removes producer-retry dupes only (one partition, one session) | Always turn it on; cheap |
| Exactly-once (EOS) | Transactions: atomic read-process-write across topics + offset commit; consumers read read_committed | Throughput hit, latency, complexity; only within Kafka (not to an external DB unless that sink is transactional/idempotent) | Counters/money that must not double |
Three concrete ways to make a consumer idempotent
- Upsert by natural key: write the aggregate keyed by (user_id, window) so reprocessing the same window overwrites to the identical value. State-convergent; no dedup store needed.
- Dedup set with TTL: keep recently-seen event_ids in Redis with a TTL covering the max replay horizon; drop duplicates. Bounded memory, needs a dedup store.
- Monotonic/conditional writes: only apply an event if its sequence/timestamp exceeds what's stored (compare-and-set). Naturally idempotent and order-tolerant.
2.10 Kafka capacity math (do it out loud)
Partition count for the engagement firehose
Suppose peak 20M events/sec, each ~200 bytes → ~4 GB/sec ingress. A single partition sustains roughly tens of MB/sec write comfortably (call it ~30 MB/s to leave headroom). So you need on the order of 4 GB/s ÷ 30 MB/s ≈ ~130 partitions just for throughput, and you'd round up (e.g. 256) for consumer parallelism and future headroom. With RF=3 the on-broker write amplification is 3×, so ~12 GB/s of replicated write spread across 500+ brokers ≈ tens of MB/s/broker — comfortable. This is exactly why Reddit runs 500+ brokers.
Retention & disk
4 GB/s × 86,400 s/day ≈ ~350 TB/day of raw events; at RF=3 that's ~1 PB/day on disk. So retention is measured in days, not weeks — you keep ~3–7 days for replay/backfill and sink the long tail to S3/parquet (cheap, columnar) for training. Confirm the consequence: "Kafka is the replay buffer, the lake is the archive."
Consumer count
If a single Flink subtask processes ~100k events/sec, 20M/sec needs ~200 parallel subtasks — which is why you provisioned ≥200 partitions (parallelism is capped by partitions). The arithmetic ties partition count, broker count, and consumer parallelism into one consistent story.
Max of three constraints: (1) throughput — target bytes/sec ÷ per-partition write rate; (2) consumer parallelism — you need ≥ as many partitions as the max consumers you'll ever want in one group; (3) key cardinality / skew — enough partitions that hashing your key spreads evenly. Then round up for headroom, because increasing partitions later reshuffles keys and breaks per-key ordering. Over-provision modestly, don't go wild (each partition has open-file + replication + rebalance overhead).
Keying by user_id puts all their events on one partition → that consumer is saturated while others idle. Options: (1) add a salt to the key for hot users (user_id:shard_0..k) and merge downstream — trades per-user ordering for balance; (2) detect hot keys and route them to a dedicated higher-capacity consumer; (3) if strict per-user order isn't required for that feature, key by (user_id, event_id). Name the tradeoff: salting breaks single-partition ordering, so only do it for commutative aggregates (counts, sums).
Likely a rebalance storm: a consumer's processing exceeds max.poll.interval.ms, the broker thinks it's dead, kicks it, reassigns partitions, and the cycle repeats (a "stop-the-world" rebalance pauses the whole group). Fixes: shrink max.poll.records so each poll loop finishes in time, move heavy work off the poll thread, raise the poll interval, and use cooperative/incremental rebalancing so only moved partitions pause instead of the whole group. This is a real ops failure mode worth naming.
A schema registry (Avro/Protobuf) enforces compatibility: producers register schemas, consumers fetch by ID, and the registry rejects breaking changes. Use backward-compatible evolution (add optional fields with defaults; never remove/repurpose a field) so old consumers keep reading new data and you can deploy producers and consumers independently. Mentioning a schema registry signals you've shipped event pipelines.
Caching is three questions: where (browser → CDN → app cache → DB cache), what pattern (cache-aside / read-through / write-through / write-back), and how do I avoid the failure modes (stale data via invalidation/TTL; stampede/thundering-herd via request coalescing + early recompute + jitter; hot keys via replication/local cache). For feeds specifically, the central decision is fan-out-on-write (precompute each user's feed when content is created) vs fan-out-on-read (assemble at read time) — the classic timeline tradeoff. Reddit confirms memcached in front of Postgres, plus Redis, with Fastly as the edge CDN.
3.1 Cache layers — where caches live
user ──▶ browser cache ──▶ CDN / edge (Fastly) ──▶ app-tier cache (memcached/Redis) ──▶ DB buffer cache ──▶ disk
(private, per-user) (static + cacheable (hot rows, rendered (Postgres pages
API responses, global) fragments, sessions) in RAM)
closer to user = faster + cheaper, but harder to invalidate and more stale ───────────────────────────────▶
The rule: cache as close to the user as the freshness requirement allows. A subreddit's static assets → CDN with long TTL. A logged-in user's personalized feed → app-tier cache with short TTL (can't go on a shared CDN because it's per-user and changes fast).
3.2 The four write/read patterns
| Pattern | How it works | Pros / cons | When |
|---|---|---|---|
| Cache-aside (lazy) | App checks cache; on miss, reads DB, then populates cache. App owns the cache. | Simple, resilient (cache down ≠ outage). Cons: first read is slow; possible staleness. | Default. Reddit's memcached-in-front-of-Postgres is essentially this. |
| Read-through | App talks only to the cache; cache fetches from DB on miss. | Cleaner app code; cache library owns loading. Cons: cache becomes critical path. | When a cache library/proxy manages loading for you. |
| Write-through | Write goes to cache and DB synchronously. | Cache always fresh. Cons: write latency = both; caches data that may never be read. | Read-heavy data you'll re-read soon. |
| Write-back (write-behind) | Write to cache, async flush to DB. | Fast writes, batches DB load. Cons: data loss if cache dies before flush; complex. | High write volume, tolerable durability gap (e.g. counters with async persistence). |
3.3 Eviction & invalidation
Eviction policies
- LRU — evict least recently used. Good general default; matches temporal locality of feeds.
- LFU — evict least frequently used. Better for stable popularity (a perennially hot subreddit).
- TTL — expire after N seconds regardless of use. Your primary freshness lever.
- FIFO / random — rare; cheap.
Invalidation strategies
- TTL-based: simplest; accept staleness up to TTL.
- Write-time invalidation: on update, delete (don't update) the cache key → next read repopulates. "Delete, don't write" avoids races.
- Event-driven: a Kafka topic of change events invalidates caches downstream (ties ch2 to ch3).
- Versioned keys: embed a version in the key (feed:u123:v7); bump version to invalidate atomically.
3.4 The failure modes you must name
Thundering herd / cache stampede
A hot key expires (or the cache flushes) and thousands of concurrent requests all miss simultaneously and hammer the DB with the same expensive recompute → DB falls over. Fixes, in order of preference:
- Request coalescing / single-flight: only the first miss recomputes; everyone else waits on that one in-flight result. (Also called "promise caching" / "dogpile lock".)
- Early/probabilistic recompute: refresh the value before it expires, with a probability that grows as TTL approaches — so it's renewed by one request while the old value still serves the rest.
- TTL jitter: add randomness to TTLs so 10,000 keys don't expire at the same instant.
- Stale-while-revalidate: serve the stale value and recompute in the background.
Hot key
One key (a viral post, r/popular front page) gets so much traffic that the single shard/node holding it is saturated. Fixes: replicate the hot key across nodes, add a small local in-process cache in front of the shared cache (L1/L2), or shard the value (split a counter into N sub-counters summed on read).
Cache penetration & avalanche
Penetration: requests for keys that don't exist bypass the cache and always hit the DB — fix with negative caching (cache the "not found"). Avalanche: mass simultaneous expiry or a cold cache after restart — fix with jitter and warm-up.
3.5 Feed-specific caching: fan-out-on-write vs fan-out-on-read
This is the single most-asked feed scalability tradeoff — the Twitter-timeline problem applied to Reddit. The question: when do we assemble a user's feed?
| Fan-out-on-write (push) | Fan-out-on-read (pull) | |
|---|---|---|
| Write cost | High — write the post into every subscriber's feed list | Low — store once |
| Read cost | Low — feed is precomputed, O(1) lookup | High — gather + merge + rank at request time |
| Freshness | Slight lag (fanout job) | Always current |
| Failure mode | Celebrity / huge-subreddit fanout explosion (millions of writes per post) | Slow reads, repeated work for active users |
3.6 Reddit's confirmed caching stack
- memcached in front of Postgres — the workhorse cache-aside layer for hot rows and rendered fragments.
- Redis — richer data structures (sorted sets for rankings/leaderboards, counters, rate-limiter state, queues).
- Fastly CDN at the edge — caches static assets and cacheable responses globally, plus edge logic.
memcached: dead-simple, multithreaded, pure LRU key→blob cache — great for a big flat object cache. Redis: single-logical-threaded but with rich types (sorted sets, hashes, lists), persistence options, pub/sub, Lua scripting — use it when you need data structures (a sorted set is perfect for "top posts by hot-score"), atomic counters, or rate-limiter state. Reddit uses both for these different jobs.
3.7 Cache capacity & hit-rate math (do it out loud)
Sizing the feed cache
Cache the ranked feed (just ~25 post IDs + scores ≈ 1 KB) for active users with a short TTL. If ~20M users are active in a TTL window and each entry is ~1 KB → ~20 GB of cache — a handful of Redis nodes. Compare to caching hydrated feeds (post bodies, ~50 KB each) → ~1 TB; so you cache IDs, not payloads, and hydrate fragments from a separate object cache. That decision falls straight out of the arithmetic.
Hit rate and the latency it buys
If cache p50 read = 1ms and DB/compute p50 = 40ms, effective latency = h·1 + (1−h)·40. At 95% hit rate that's ~3ms; at 80% it's ~9ms; at 50% it's ~20ms. So a 15-point hit-rate drop triples latency. Consequence: protect hit rate aggressively (stampede control, warm-up after deploys, jittered TTLs) — and quote effective latency as a function of hit rate, not a single number.
Working-set rule of thumb
Aim to fit the hot working set (Zipf-distributed: a small fraction of posts gets most reads) in RAM. For Reddit, the front-page / r/popular hot set is tiny relative to the catalog, so a modest cache captures most reads — quantify "the top ~1% of posts serve ~50%+ of impressions" to justify the cache size.
3.8 Hot-key & stampede, the deep version
The two failure modes interviewers drill hardest. Be able to draw both and give the layered fix.
STAMPEDE (one key expires, herd hits DB) HOT KEY (one key, one node saturated)
t0: key TTL expires viral post → 1 cache node gets 100k QPS
t0: 5000 concurrent reqs all MISS that node's CPU/NIC saturates, p99 explodes
───▶ 5000 identical expensive recomputes ──▶ DB other nodes idle (uneven load)
FIX (layered): FIX (layered):
1) single-flight: 1 recomputes, rest wait 1) L1 in-process cache in front of shared cache
2) probabilistic early refresh before TTL 2) replicate the hot key to N nodes, read any
3) TTL jitter (no synchronized expiry) 3) split a hot counter into K shards, sum on read
4) stale-while-revalidate (serve old, refresh) 4) detect hot keys at runtime, promote to L1
3.9 More fluency drills
Updating races: two concurrent writers can interleave so the cache ends up with the older value (write A reads, write B reads, B writes cache, A writes cache → stale). Deleting (invalidate) is idempotent and forces the next read to repopulate from the source of truth, so the worst case is an extra miss, not permanent staleness. The pattern is "write DB, then delete cache key." (For extra safety against the read-repopulates-stale race, use a short TTL or the delayed double-delete trick.)
CDC + event-driven invalidation: tail the DB write-ahead log (Debezium reportedly at Reddit), emit row-change events to a Kafka topic, and have a consumer in each region invalidate the affected cache keys. This avoids the dual-write problem (where the app writes both DB and cache and they can diverge on partial failure) — the cache is invalidated as a consequence of the committed DB change. Ties ch5 (CDC) to ch3.
Caching is a graceful-degradation tool. With stale-while-revalidate, if the ranker or candidate-gen is down, we serve the last cached ranked feed (degraded freshness, never blank). Negative caching prevents a flood of misses from a deleted entity. A warmed cache after a deploy avoids a cold-start avalanche. So the cache is part of the availability story, not just latency.
No — CDN caches are shared and keyed by URL, so per-user content either can't be cached there or risks leaking one user's feed to another. Personalized feeds go in the app-tier cache (Redis) keyed by user. The CDN (Fastly) caches shared cacheable responses (static assets, public subreddit pages, logged-out front pages) where one cached copy serves everyone.
"I cache ranked feed IDs (≈1 KB), not hydrated payloads, with a short jittered TTL; effective latency is h·cache + (1−h)·compute, so I protect hit rate with single-flight on miss and stale-while-revalidate. Personalized feeds live in app-tier Redis keyed by user, never the shared CDN; CDC-driven invalidation keeps derived caches consistent without dual-writes."
GraphQL is one typed endpoint where the client asks for exactly the fields it wants in a single query; the server resolves each field with a resolver function. It fixes REST's over-/under-fetching (no more 5 round-trips to build a screen). Its signature danger is the N+1 problem, solved by DataLoader batching. At scale you split the schema across teams with Federation — independent subgraphs (Reddit runs Go subgraphs / Domain Graph Services) composed into one supergraph by a gateway. Persisted queries let clients send a hash instead of the full query for caching/security. The feed service exposes a GraphQL field (e.g. homeFeed) whose resolver calls the ranking service and hydrates post objects from other subgraphs.
4.1 GraphQL vs REST
| REST | GraphQL | |
|---|---|---|
| Endpoints | Many (/posts, /users/:id, /comments) | One (/graphql), queried by shape |
| Fetching | Server decides payload → over-fetch (too much) or under-fetch (need more calls) | Client requests exact fields → no waste, one round-trip per screen |
| Typing | Convention (OpenAPI optional) | Strongly typed schema is mandatory — self-documenting, introspectable |
| Versioning | /v1, /v2 | Evolve schema; deprecate fields instead of versioning |
| Caching | Easy HTTP caching by URL | Harder (POST, one URL) → use persisted queries + field-level caches |
| Risk | Chatty clients, N round-trips | N+1 resolver problem, expensive arbitrary queries (need depth/cost limits) |
4.2 Schema & resolvers
The schema declares types and their fields; a resolver is the function that returns a field's value. A query walks the type graph, calling resolvers.
# schema
type Query { homeFeed(first: Int, after: String): FeedConnection! }
type FeedConnection { edges: [FeedEdge!]! pageInfo: PageInfo! }
type FeedEdge { cursor: String! node: Post! }
type Post {
id: ID!
title: String!
score: Int!
author: User! # resolved from the USER subgraph
subreddit: Subreddit! # resolved from the COMMUNITY subgraph
topComments(first: Int): [Comment!]!
}
# the homeFeed resolver (pseudo)
homeFeed(parent, args, ctx) {
const userId = ctx.auth.userId
const postIds = rankingService.getRankedFeed(userId, args.first, args.after) # ML funnel
return postIds.map(id => ({ node: ctx.loaders.post.load(id) })) # DataLoader!
}
Notice the feed resolver does the boring part — call the ranking service (your ML funnel) for ordered post IDs — and then hydrates each post. The hydration is where N+1 bites.
4.3 The N+1 problem and DataLoader
The bug
The feed returns 25 posts. Each Post.author resolver naively does SELECT * FROM users WHERE id = ?. That's 1 query for the feed + 25 for authors = N+1 queries. Add subreddit and it's 1 + 25 + 25. This is the classic GraphQL footgun.
The fix: DataLoader (per-request batching + caching)
A DataLoader collects all the IDs requested within a single tick of the event loop and issues one batched query (SELECT * FROM users WHERE id IN (...)), then distributes results back to each caller. It also de-dupes within the request (two posts by the same author → one fetch). Net: N+1 collapses to a small constant number of batched calls.
const userLoader = new DataLoader(async (ids) => {
const rows = await db.users.findMany({ where: { id: { in: ids } } }); // ONE query
return ids.map(id => rows.find(r => r.id === id)); // preserve order
});
// in resolver: author: (post) => userLoader.load(post.authorId)
4.4 Federation / Domain Graph Services
At Reddit's scale you don't want one giant GraphQL monolith. Federation lets each domain team own a subgraph (Posts, Users, Communities, Comments) — each its own service with its own schema slice — and a gateway/router composes them into one supergraph the client queries. A Post type can be extended across subgraphs: the Posts subgraph owns id, title, score; the Users subgraph contributes author; the router stitches them via entity keys. Reddit confirmed: GraphQL Federation with Go subgraphs (Domain Graph Services).
┌──────────────────────────┐
client ───────▶ │ GraphQL Router / Gateway │ (composes the supergraph)
one query └───────────┬──────────────┘
│ fans the query to the owning subgraphs
┌─────────────────────┼─────────────────────┬──────────────────────┐
▼ ▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐ ┌─────────────┐
│ Posts svc │ │ Users svc │ │ Comm. svc │ │ Comments svc│ (Go subgraphs)
│ id,title, │ │ author, │ │ subreddit │ │ comment tree│
│ score │ │ karma │ │ rules │ │ │
└────────────┘ └────────────┘ └────────────┘ └─────────────┘
4.5 Persisted queries, cost control, and when GraphQL hurts
Persisted queries
Client registers queries ahead of time and sends a hash instead of the full text. Benefits: smaller payloads, server can allowlist only known queries (blocks arbitrary expensive ones), and the hash becomes a cache key (even on a CDN).
Cost / depth limiting
Because clients write arbitrary queries, you must enforce max depth, complexity budgets, and pagination caps to prevent a single query from fanning out into a denial-of-service.
When GraphQL hurts
- Simple internal service-to-service calls (gRPC/REST is leaner).
- Heavy file uploads / streaming.
- When the team can't invest in DataLoader discipline and cost limits — you get N+1 and runaway queries.
When it shines
- Many client types (iOS/Android/web) needing different field shapes.
- Composite screens (a feed item = post + author + sub + counts) from many services.
- Rapidly evolving product surfaces.
4.6 Caching a GraphQL feed (where the layers live)
GraphQL loses the easy URL-based HTTP caching of REST (everything is a POST to one endpoint), so caching becomes layered and field-aware:
| Layer | What it caches | Mechanism |
|---|---|---|
| Edge / CDN (Fastly) | Whole responses for shared, public queries (logged-out front page) | Persisted-query hash as cache key (GET-able) |
| Resolver / per-request | De-dupes & batches within one query | DataLoader (request-scoped) |
| Field / entity cache | Hot post objects, counts, author cards | memcached/Redis keyed by entity ID |
| App / domain cache | Ranked feed ID list per user | Redis, short TTL (ch3) |
The senior point: personalized fields can't be edge-cached (per-user), so you cache the building blocks (post objects, counts) that are shared across users and recombine them per request. A feed query for two users shares 90% of its hydrated post objects — cache those once.
4.7 Federation, multi-region & resilience
- Subgraph failure isolation: if the Comments subgraph is down, the router can return the rest of the query with a null + error for that field (partial response) instead of failing the whole feed — a built-in graceful-degradation lever. Mark non-critical fields @nullable so a feed still renders without top-comments.
- Multi-region: run the router + subgraphs in each region; route users to the nearest region; subgraphs read region-local replicas (ties to ch5 multi-region). The router fans out to local subgraphs to keep p99 low — cross-region calls in a resolver are a latency trap.
- Timeouts & budgets per subgraph: the router enforces a per-subgraph timeout so one slow service can't blow the whole query's p99; over-budget fields degrade to null/cached.
- Query cost limits protect every subgraph from a single expensive client query fanning out into a self-inflicted DoS.
4.8 Fluency drills
(1) Client sends a persisted-query hash + variables. (2) Fastly checks if it's a cacheable shared query — for a personalized feed it isn't, so it forwards to the router. (3) Router authenticates, enforces depth/cost limits, resolves homeFeed against the Posts/feed subgraph, whose resolver calls the ranking service for ordered post IDs. (4) For each post the router resolves cross-subgraph fields (author from Users, subreddit from Communities) via entity keys, each batched through a DataLoader → one query per field type, not per post. (5) Counts/fragments come from memcached; truth from Postgres. (6) Compose and return. No N+1, partial-response on a subgraph timeout.
Team autonomy and blast-radius isolation. Each domain team owns a subgraph (Posts, Users, Communities, Comments) with its own schema, deploy cadence, datastore, and on-call. The router composes them into one supergraph the client sees as a single API. A monolith would couple all teams to one deploy and one failure domain. Reddit runs exactly this: GraphQL Federation with Go Domain Graph Services.
It collapses N queries into one WHERE id IN (...), which the DB executes as a single index scan — far cheaper than N round-trips (each with network + parse + plan overhead). For very hot entities you back the loader with memcached so the batched query often doesn't reach Postgres at all. The win is round-trip elimination + per-request dedup, not magic.
Subscriptions (WebSocket) work but are stateful and expensive at Reddit's connection count. For high-fanout counters, polling a cheap cached count field or pushing via a lighter channel is usually better; reserve subscriptions for genuinely live, lower-cardinality surfaces (live threads, chat). Name the tradeoff: persistent connections don't scale like stateless request/response.
"The feed is a federated GraphQL query: the router resolves ordered IDs from the ranking subgraph, then hydrates author/subreddit/counts across Go Domain Graph Services through request-scoped DataLoaders so we batch one query per field type and kill N+1. Persisted-query hashes give us edge caching and an allowlist; per-subgraph timeouts and nullable non-critical fields give partial-response graceful degradation."
Postgres for relational, transactional truth (users, posts, votes-of-record). memcached/Redis for caching and KV (counters, sorted sets, rate-limit state). Cassandra for write-heavy, horizontally-scaled, denormalized data where you design around the access pattern (wide-column, partition keys, tunable consistency) — Reddit uses it for newer features. RabbitMQ for async jobs (votes, link submission, notifications). CDC streams DB changes to Kafka (reportedly Debezium — hedge). Know sharding/replication and CAP in practice. Vector DBs (Reddit evaluated Milvus/FAISS/Vertex AI Vector Search/Solr ANN) serve embeddings for retrieval: HNSW (fast, memory-hungry) vs IVF-PQ (compressed, tunable recall).
5.1 The decision table
| Store | Model | Strengths | Use at Reddit |
|---|---|---|---|
| Postgres confirmed | Relational, ACID | Joins, transactions, strong consistency, mature | Core data: users, posts, votes-of-record |
| memcached confirmed | KV cache | Simple, fast, multithreaded LRU | Cache-aside in front of Postgres |
| Redis confirmed | KV + data structures | Sorted sets, counters, pub/sub, Lua, rate-limit state | Rankings, counters, rate limiting, queues |
| Cassandra confirmed | Wide-column (Dynamo-style) | Linear write scale, multi-DC, tunable consistency, no single master | Newer / write-heavy features |
| RabbitMQ confirmed | Message broker (queues) | Async work, routing, retries, DLQs | Async jobs: voting, link submission, notifications |
| Vector DB / ANN industry | Embedding index | Sub-linear nearest-neighbor search | Evaluated Milvus/FAISS/Vertex Vector Search/Solr ANN for retrieval |
5.2 Cassandra in depth (the one people fumble)
Data model: query-first, denormalized
Unlike Postgres, you do not model entities and join later. You model queries: design the table so the read you need is a single partition lookup. The partition key determines which node holds the data; the clustering key orders rows within a partition. Denormalization (storing the same data multiple ways) is expected — disk is cheap, scattered reads are not.
-- "give me a user's feed entries, newest first"
CREATE TABLE feed_by_user (
user_id uuid,
bucket int, -- time bucket to cap partition size
created_at timestamp,
post_id uuid,
score double,
PRIMARY KEY ((user_id, bucket), created_at)
) WITH CLUSTERING ORDER BY (created_at DESC);
Tunable consistency
Per-query you choose consistency level: ONE, QUORUM, ALL. The classic rule: if R + W > N (read replicas + write replicas exceed replication factor) you get strong consistency for that operation; otherwise eventual. So QUORUM reads + QUORUM writes at RF=3 (2+2>3) gives strong consistency while tolerating one node down. This is CAP made tunable.
When to pick Cassandra over Postgres
- Write throughput beyond a single primary (votes/engagement at huge scale).
- Predictable single-partition access patterns (no ad-hoc joins).
- Multi-region, always-writable (no single point of failure).
- Avoid when you need ad-hoc queries, transactions across partitions, or strong relational integrity — that's Postgres.
5.3 Feed / comment / vote schema & partitioning
Votes
A vote is (user_id, post_id, dir, ts). Don't synchronously update a post's score in Postgres on every vote (write hotspot + lock contention). Instead: enqueue to RabbitMQ → a worker updates an aggregate (a Redis sorted set / counter) and persists periodically. Show the user their own vote optimistically.
Comments (nested tree)
Store (comment_id, post_id, parent_id, author, ts, body). Reads need the subtree. Options: adjacency list (parent_id, recurse — simple, many reads), materialized path (store the path string, range-scan a subtree), or closure table. Paginate deep threads with "continue this thread" (ch8).
Posts
Relational in Postgres for the source of truth; denormalized copies in Cassandra/Redis for hot read paths (per-sub hot lists as sorted sets keyed by hot-score). Media lives in object storage behind the CDN, not the DB.
5.4 RabbitMQ vs Kafka (don't conflate them)
| RabbitMQ (broker / queue) | Kafka (log / stream) | |
|---|---|---|
| Model | Smart broker, dumb consumer; messages routed & removed on ack | Dumb broker, smart consumer; records retained & replayable by offset |
| Best for | Task queues, RPC-ish jobs, per-message routing, retries/DLQ | High-throughput event streams, replay, many consumers, stream processing |
| At Reddit | Async jobs: votes, link submission, notifications dispatch | Engagement firehose, real-time features, safety stream |
| Throughput | High, but not log-scale | Tens of millions msgs/sec (Reddit) |
5.5 CDC, sharding, replication, CAP
- CDC (Change Data Capture): tail the DB's write-ahead log and emit row changes as events to Kafka, so caches/search/derived stores stay in sync without dual-writes. Reddit reportedly uses Debezium for CDC hedge: single source.
- Sharding: partition data across nodes by a shard key (hash or range). Hash spreads load evenly; range enables scans but risks hotspots. The hard parts: rebalancing and cross-shard queries.
- Replication: leader-follower (Postgres streaming replicas — reads scale, writes don't) vs leaderless (Cassandra — any replica writable). Async replication = possible read-after-write staleness on followers.
- CAP in practice: under a partition you choose consistency or availability. Postgres-with-sync-replica leans CP; Cassandra-at-low-consistency leans AP. Real systems tune per operation — "the vote-of-record is CP, the feed read is AP."
5.6 Vector DBs & ANN for retrieval
Why ANN
Retrieval scores a query embedding against millions of item embeddings. Exact nearest-neighbor is O(N·d) — too slow. Approximate nearest neighbor (ANN) trades a little recall for orders-of-magnitude speed. Reddit evaluated Milvus, FAISS, Vertex AI Vector Search, and Solr's ANN industry/eval.
HNSW vs IVF-PQ
| HNSW (graph) | IVF-PQ (inverted file + product quantization) | |
|---|---|---|
| Idea | Navigable small-world graph; greedy descent to neighbors | Cluster space into cells (IVF); compress vectors (PQ) |
| Recall/latency | Excellent recall, very low latency | Tunable; PQ compresses memory at some recall cost |
| Memory | High (stores full graph + vectors in RAM) | Low (quantized) — fits far more vectors per node |
| Use when | Latency-critical, corpus fits in RAM | Huge corpora where memory dominates cost |
Tuning knobs: HNSW's efSearch/M trade recall vs speed; IVF's nprobe (cells searched) trades recall vs speed. Always quote a recall@K vs p99 latency operating point — that's the senior detail.
5.7 CDC in depth (the dual-write fix)
The problem CDC solves
You commit a post to Postgres and also need it in search, the cache, and a Cassandra read-model. The naive dual-write (app writes DB then writes the others) is broken: if the second write fails after the first commits, the systems diverge with no clean rollback. Change Data Capture fixes this by making the DB's commit the single source of truth: a connector tails the write-ahead log (Postgres logical replication slot) and emits each committed row change as an event to Kafka. Downstreams (search index, cache invalidator, derived stores) consume that ordered stream. Reddit reportedly uses Debezium single source — hedge.
Why it's better
- Exactly the committed changes, in order — no phantom writes, no lost updates; the log is the truth.
- Decoupled & replayable — add a new consumer (a new search index) later and replay history; no app change.
- This is the "outbox/CDC" pattern that turns "keep N systems in sync" into "everyone is a Kafka consumer of one ordered change stream."
Catch: schema changes must be handled in the connector; and ordering/at-least-once means consumers still need to be idempotent (ch2).
5.8 Multi-region: the patterns and the tradeoffs
| Topology | How | Consistency | Use |
|---|---|---|---|
| Single-region + read replicas | One primary; followers in-region for read scale | Strong on primary; replica lag on followers | Default for relational truth (Postgres) |
| Active-passive (DR) | One active region; standby promoted on failover | Strong; failover has RPO/RTO | Disaster recovery for the system of record |
| Active-active (AP store) | All regions writable; async cross-region replication | Eventual; conflict resolution (LWW / CRDT) | Cassandra-style high-availability data |
| Geo-partitioned | Each region owns a data shard | Strong within home region | Data-locality / residency needs |
- Reads are easy, writes are hard. Serve reads from the nearest region (replica/edge); route writes to a primary or accept eventual-consistency convergence. Cross-region round-trips (~50–150ms) destroy a 150ms feed budget, so keep the read path region-local.
- Per-data CAP choice: "the vote-of-record can tolerate a few seconds to converge (AP, active-active Cassandra); a username uniqueness check must be strongly consistent (CP, single primary)." Choosing per dataset is the senior move.
- Kafka multi-region: MirrorMaker/replication ships the engagement stream cross-region so each region's feature jobs read locally.
5.9 Datastore capacity math (do it out loud)
Postgres write ceiling → why votes are async
A single Postgres primary handles on the order of a few thousand write transactions/sec before lock/WAL contention bites. Peak votes ≈ 70k/sec (ch1) → 10–20× over a single primary. Consequence (say it): "votes can't be synchronous row-updates on the post score — enqueue to RabbitMQ, aggregate in Redis/Cassandra, persist periodically; Postgres only sees low-rate aggregated writes." The number forces the architecture.
Comment storage growth
50M comments/day × ~1 KB × 365 ≈ ~18 TB/year of comment rows — fine for partitioned Postgres or Cassandra, but the read pattern (subtree fetch) drives the storage choice (materialized path for range-scan), not the volume.
Replica count for read QPS
If a hot read is 17k QPS (feed) and one replica serves ~5k QPS comfortably, you need ~4 replicas plus the cache absorbing 90%+ — so really ~1–2 replicas behind a 90%-hit cache. Show how the cache (ch3) collapses the replica count; that's the integrated answer.
When you need linear write scale with no single primary and multi-region always-writable, and your access pattern is a known single-partition lookup (no ad-hoc joins). Sharded Postgres can scale reads and partition writes but reintroduces a primary per shard, cross-shard transactions are painful, and rebalancing is operationally heavy. If I need joins/transactions/strong relational integrity, I stay on Postgres; if I need a write-heavy, denormalized, multi-DC read-model, Cassandra. Pick by access pattern, not habit.
Per dataset. Vote-of-record in the queue → durable, but the displayed aggregate score is eventually consistent (fine — show the user their own vote optimistically). Username/uniqueness, payments, ban state → strongly consistent (CP). Feed ranking → eventually consistent / bounded-staleness (a few seconds stale is invisible). I never promise global strong consistency where it isn't needed — it costs latency and availability I can spend elsewhere.
In a quorum store with replication factor N, if the replicas you read from (R) plus the replicas you write to (W) exceed N, the read and write sets must overlap on at least one replica that has the latest value → you read your writes (strong consistency for that op). At N=3, QUORUM read (R=2) + QUORUM write (W=2) gives 2+2>3, so strong, while tolerating one node down. Drop to R=1/W=1 and you get speed + availability but possible staleness. That's CAP made a per-query dial.
"Postgres is the relational truth but a single primary tops out around a few thousand writes/sec, so the 70k-peak vote firehose goes async via RabbitMQ into Redis/Cassandra aggregates. Derived stores stay in sync via CDC off the Postgres WAL into Kafka — no dual-writes. I choose consistency per dataset: vote-of-record durable but aggregate eventually-consistent, uniqueness strongly consistent, feed bounded-staleness."
Split inference into batch (precompute embeddings/candidate lists offline, refresh hourly/daily) and online (the ranker, called per request under a p99 budget). Serve models on Triton / TorchServe / Ray Serve with dynamic batching (group concurrent requests into one GPU forward) to hit throughput without blowing latency. A feature store gives the online path sub-10ms reads and the offline path consistent training features (kills train/serve skew). Ship models with versioning + canary/shadow, autoscale on Kubernetes, and watch p99, not the mean.
6.1 Online vs batch inference
| Batch (offline) | Online (request-time) | |
|---|---|---|
| What runs | Item/user embeddings, ANN index build, candidate precompute, propensity scores | The ranker over ~500 candidates; light re-rank |
| Cadence | Hourly / daily | Per request, p99 budget (~20–40ms for ranking) |
| Why | Amortize heavy compute; item embeddings change slowly | Needs live context (session, freshness) |
The standard pattern: two-tower / embedding models run in batch (item tower precomputed, indexed in ANN; user tower may run online or be precomputed) and the heavy ranker runs online on the small candidate set.
6.2 Model servers & dynamic batching
The servers
- Triton — NVIDIA, multi-framework, great GPU utilization, built-in dynamic batching + model ensembles.
- TorchServe — PyTorch-native, simpler.
- Ray Serve — Python-flexible, composes pipelines (retrieval + rank + rerank as one graph), scales with Ray.
Dynamic batching
GPUs are throughput machines: one request wastes them. The server waits a tiny window (e.g. 2–5ms) to coalesce concurrent requests into one batch, runs a single forward, splits results. You trade a few ms of latency for multiples of throughput. Tune max_batch_size and max_queue_delay against your p99 budget.
6.3 Feature store: the train/serve skew killer
A feature store has two faces backed by one definition:
- Online store (Redis/KV/Cassandra): sub-10ms point reads of the latest feature values for the ranker at request time.
- Offline store (warehouse/lake): historical feature values for point-in-time-correct training joins (no label leakage).
The whole reason it exists: compute a feature once from the same definition so the value at training time equals the value at serving time. The #1 silent killer of RecSys models is train/serve skew — a feature computed differently online vs offline. Naming this is a strong senior signal (more in ch13).
6.4 Embedding serving, versioning, canary, autoscaling
- Embedding serving: huge embedding tables (millions of IDs) sharded across an embedding-parameter-server / KV; looked up and fed into the ranker. Often the memory bottleneck.
- Model versioning: every model is a versioned, immutable artifact (registry). Roll forward/back by pointer; pin training data + features + code for reproducibility.
- Canary / shadow: route a small % of live traffic to the new model (canary) and/or run it in shadow (score live traffic, don't serve it) to compare before promotion. Always have a kill switch to instantly revert.
- GPU vs CPU: deep ranker → GPU (batched). Small models / embedding lookups / business logic → CPU. Mixed fleets.
- Autoscaling on K8s: scale replicas on QPS / GPU utilization / queue depth (HPA / KEDA). Provision for p99 under peak, not average. Reddit runs Kubernetes on AWS.
6.5 Serving capacity math (size the GPU fleet out loud)
How many ranker GPUs?
Peak ~50–70k feed QPS, each request ranks ~500 candidates → ~25–35M item-scorings/sec at peak. Suppose a ranker GPU does ~1M scorings/sec at the batch size that fits the latency budget → you need ~25–35 GPUs of headroom just for ranking, then ×~2 for redundancy/zone spread/burst → ~50–70 GPUs. The exact number is unimportant; showing you can derive it from QPS × candidates ÷ per-GPU throughput is the signal.
Dynamic batching math
At 50k QPS into a fleet, each GPU sees ~700–1000 req/sec. A max_queue_delay of 3ms naturally accumulates a batch of ~3 requests' worth of candidates — enough to fill the GPU without adding more than 3ms of queueing. Push the delay too high and you blow p99; too low and GPU utilization (and cost) tanks. State the dial: "I tune max_queue_delay against the p99 budget to trade a few ms of latency for multiples of throughput."
Embedding table memory
100M users + 1M posts/day retained × a 128-dim float32 embedding ≈ 100M × 512 bytes = ~50 GB just for user embeddings → doesn't fit one GPU's RAM, so embeddings live in a sharded parameter server / KV (CPU RAM), looked up and fed to the GPU ranker. This is why embedding lookup is often the memory bottleneck, not the dense net.
From the online feature store (Redis/KV) with sub-10ms point reads — a single multi-get for all of a request's features. Real-time features (engagement velocity) were written there by the Flink job (ch2); batch features (30-day affinity) were materialized there by a nightly job. The ranker never computes features online — it reads precomputed ones, which is exactly what kills train/serve skew (ch6.3, ch19).
~5ms edge + ~10ms auth/context + ~15ms candidate gen (Redis sorted sets + ANN) + ~12ms feature hydration (one multi-get) + ~30ms GPU ranker (dynamic-batched) + ~5ms re-rank/integrity ≈ ~77ms typical, with headroom under a 150ms p99. If the ranker over-runs, degrade to hot-score ordering of cached candidates (never blank). Stating per-stage budgets that sum under p99 is the whole game.
Default to frequent batch retraining (daily/continuous) — simpler, reproducible, easy to validate and roll back. Add near-real-time signals as features (velocity from Flink) rather than truly online-updating the model weights, which risks instability and feedback-loop runaway. If freshness genuinely demands it (trending, breaking events), a lightweight online-updated layer (e.g. a calibration or popularity head) on top of a stable base model is the controlled compromise.
Deep dense ranker over a batch → GPU (throughput machine). Embedding lookups, business-rule re-rank, and small models → CPU. Many candidate-gen steps (ANN, sorted-set reads) are not models at all — they're index/cache reads. Mixed fleet, each component on the right hardware; don't put a 2-layer re-rank MLP on a GPU just because it's a "model."
"I split inference: heavy embeddings and candidate lists run in batch and land in an ANN index + feature store; the multi-task ranker runs online on ~500 candidates with dynamic batching on GPU, reading precomputed features in one sub-10ms multi-get. I size the GPU fleet from QPS × candidates ÷ per-GPU throughput with 2× redundancy, budget p99 per stage, and ship every model as a versioned artifact behind shadow → canary → kill switch."
6.6 The Reddit stack cheat-sheet (what each box is for + the real numbers)
One glance table to anchor every design decision — drop the right component and a number and you sound like you've operated at scale. (Confirmed vs hedged vs industry-standard tags carry through from ch5/ch13.)
| Component | What it is | What Reddit uses it for | The number / the catch |
|---|---|---|---|
| Kafka confirmed | Distributed, replayable commit log | Engagement firehose, ML event transport, CDC sink, stream source | 500+ brokers, tens of millions msgs/sec; RF=3; ~4 GB/s → ~130–256 partitions; days of retention |
| Flink Stateful Functions confirmed | Stateful stream processor (actor-style) | Real-time features, velocity/trending, content-safety rule eval | Runs Lua for hot-swappable safety rules; event-time + watermarks + RocksDB checkpoints |
| RabbitMQ confirmed | Work-queue broker (per-msg ack) | Votes, link submission, notification dispatch | Process-and-forget tasks with retries/DLQ; not log-scale, not replayable |
| Postgres + memcached confirmed | Relational truth + read cache | Accounts, posts, votes-of-record; cache-aside hot rows/fragments | Single primary ≈ few-k writes/sec → the 70k-peak vote firehose must go async; watch stampede |
| Cassandra confirmed | Wide-column, leaderless | Write-heavy / newer features, denormalized read-models | R+W>N for strong consistency per query; watch wide partitions + tombstones |
| Redis confirmed | In-memory KV + data structures | Online feature cache, sorted-set rankings, counters, rate-limit state | Feed-ID cache ≈ 20M×1KB ≈ 20 GB; feature reads ≈ 10M/sec via multi-get; TTL'd |
| GraphQL Federation confirmed | Composed API over per-domain subgraphs | One supergraph the client queries; ML service = a subgraph | Per-subgraph timeouts + nullable fields → partial-response degradation; DataLoader kills N+1 |
| Go Domain Graph Services confirmed | The subgraph services themselves | Posts / Users / Communities / Comments / Ads ownership | Add ML-derived fields (pCTR, safety score) without forking the API |
| Fastly confirmed | CDN / edge | Static assets, cacheable + logged-out responses, media bytes | Cache the shell, personalize the payload; persisted-query hash = cache key |
| K8s on AWS confirmed | Orchestration (Spinnaker/Drone/Terraform) | Stateless services + inference pods, autoscale on lag/QPS | Dedicated node pools for GPU inference; HPA/KEDA; provision for peak p99 |
| Debezium CDC reportedly | Change-data-capture off the WAL | Streams committed DB changes into Kafka → search/cache/read-models | Say "CDC, reportedly Debezium"; fixes the dual-write problem; consumers still idempotent |
| Hot formula confirmed | Time-decayed log score | Front-page / Hot sort; also a ranking feature | $\operatorname{sign}(u{-}d)\cdot\log_{10}(\max(|u{-}d|,1)) + t/45000$; 12.5h half-life |
| Wilson LB confirmed | Lower bound of a proportion CI | Comment "best" sort | Confidence-aware: a 5/0 can beat 100/40 on thin evidence |
| 4-stage notif confirmed | Multi-stage recommender | Notifications | Budget(causal) → two-tower retrieve → multi-task DNN → business rerank |
| Vector DB / ANN eval | Embedding index (HNSW / IVF-PQ) | Retrieval across surfaces (feed, search, Answers) | Evaluated Milvus/FAISS/Vertex/Solr-ANN; quote recall@K vs p99 operating point |
6.7 More capacity math & failure modes (the infra grilling)
Embedding-table memory, derived twice
User table: 100M users × 128-dim float32 = 100M × 512 B ≈ ~50 GB. Item table (say 90 days of posts ≈ 90M items) × 256-dim = 90M × 1 KB ≈ ~90 GB. Neither fits one GPU (24–80 GB), so embeddings live in a sharded parameter server / KV in CPU RAM, looked up per request and fed to the dense GPU net. Consequence: the embedding lookup + network transfer, not the dense FLOPs, is usually the serving bottleneck — so quantize embeddings (fp16/int8), cache hot rows, and co-locate the PS with the ranker.
ANN index sizing
90M item vectors × 256-dim fp32 = ~90 GB raw. HNSW adds a graph (~1.5–2× memory) → ~150–180 GB → shard across nodes or compress with IVF-PQ (e.g. PQ to 64 bytes/vector → ~6 GB, recall trade). Say the dial: "I'd start HNSW for recall, move to IVF-PQ if memory cost dominates, and quote a recall@100 vs p99 operating point rather than a single latency."
Feature freshness vs cost
Streaming a feature (always-on Flink) costs ~10–50× a nightly batch recompute of the same feature. So tier by need: "comments in last 5 min" must be streamed (seconds-fresh); "30-day topic affinity" is batch (hours-stale is fine). Only stream what genuinely needs second-level freshness — that single decision is a real cost lever a Staff engineer owns.
| Layer | Failure mode | Symptom | Mitigation |
|---|---|---|---|
| Kafka | Hot partition (celebrity AMA keyed to one post_id) | Consumer lag spikes on one partition, others idle | Salt the key for hot entities; route to dedicated consumer; only for commutative aggregates |
| Kafka | Rebalance storm | Group repeatedly pauses, lag grows | Cooperative rebalancing, static membership, shrink max.poll.records |
| Flink | State bloat / checkpoint stall | Recovery lag, backpressure | TTL state, bound key cardinality, RocksDB + incremental checkpoints |
| Redis | Eviction of live ML features under memory pressure | Silent quality drop (no error) | Separate ML cache from rate-limit cache; size for working set; monitor hit rate |
| Cassandra | Wide partition (megathread under one key) | Multi-MB partition, slow reads, GC | Time-bucket the partition key; cap partition size |
| GraphQL | Subgraph timeout cascade | Slow ads subgraph blocks whole feed query | Per-subgraph timeouts + partial response (render feed without ads) |
| Serving | Tail latency (GC, cold cache, oversized batch) | p99 ≫ mean → visible jank for 1% of scrolls | Budget p99 per stage; tune max_queue_delay; warm caches; degrade to Hot ordering |
| Feature pipeline | Feature silently goes 30% null | Model quality decays, no alert | Per-feature null/range/PSI monitors + per-feature kill switch + version pinning |
This is the question to over-prepare. Walk it like a top-scoring candidate: narrate tradeoffs, quote numbers, and lean into the infra layer.
Step 1 — Clarify & objective
"The home feed is the personalized, ranked list a logged-in user sees — a blend of their subscribed communities plus recommended communities/posts. We optimize for healthy engagement: a multi-task blend of upvote / comment / dwell / share, with explicit negatives (hide / downvote / report / 'not interested'), and we defend long-term retention & community health as the guardrail. We surface ~25 posts per fetch and must feel instant on scroll."
Step 2 — Requirements & scale math
Functional: return ranked top-K personalized posts; paginate infinitely; update with new content and on user actions; gate unsafe content. Non-functional: p99 ≈ 150ms, high availability (feed must never be empty — degrade gracefully), freshness within seconds–minutes, cost-bounded.
Scale (from ch1): ~100M DAU → ~1.5B feed requests/day → ~17k QPS avg, ~50–70k peak. ~500 candidates ranked/request → millions of scorings/sec → ranking must be GPU-batched, retrieval cheap ANN. Vote firehose ~70k peak → async. Engagement firehose tens of millions/sec → Kafka.
Step 3 — ML framing (the funnel)
Label: implicit feedback — positives (upvote, comment, long dwell, share), negatives (hide, downvote, report, fast skip). Training: batch-train the ranker daily/continuously on logged interactions joined point-in-time with features. Hot-score baseline as a strong non-ML floor and a feature.
# Reddit "Hot" ranking (CONFIRMED) — a baseline AND a feature
score(ups, downs, t_post) =
sign(ups - downs) * log10( max( |ups - downs|, 1 ) )
+ (seconds_since_epoch(t_post)) / 45000
# 45000s ≈ 12.5h half-life: a 24h-old post needs ~10x the net votes
# to match a fresh one. Top = score with NO time decay.
# Rising = upvote velocity. Controversial = high vol, near-equal up/down.
Step 4 — Data & feature pipeline
"Clients emit impressions/clicks/dwell/votes/hides to a Kafka topic partitioned by user_id. A Flink job keeps sliding-window aggregates (engagement velocity, recent topic affinity, session signals) and writes them to the online feature store (sub-10ms). The same topic sinks to the lake for batch features and training joins. Item-side features (post age, sub, content embeddings, early-velocity) and counters come from the same store." This is ch2 + ch6 applied.
Reddit-confirmed signals to name explicitly: account-age trust, the user's tendency to like new communities, their upvote/comment history, and engagement velocity on the post. Reddit publicly emphasizes velocity + account-age trust for the home feed.
Step 5 — Model
Candidate generation (multi-source union)
- Subscribed: hot/top posts from the user's subscribed subreddits (sorted sets in Redis by hot-score).
- Recommended communities: posts from subs the user is likely to join (graph + affinity).
- Embedding ANN: nearest posts to the user's interest embedding (HNSW/IVF-PQ). two-tower is standard industry retrieval; at Reddit it's confirmed for notifications, not asserted for the home feed — present it as "standard two-tower-style embedding retrieval as one source."
- Freshness / exploration: a slice of new posts to avoid feedback-loop ossification and to gather labels.
Ranker (multi-task DNN)
A shared-bottom or MMoE network over user × item × context features with multiple heads: P(upvote), P(comment), P(dwell>t), P(share), P(hide). Combine into a single score with tuned weights: score = w1·P(up) + w2·P(comment) + w3·P(dwell) − w4·P(hide) + …. Multi-task sharing improves data efficiency and lets you trade objectives in re-rank. Feed the hot-score and velocity as features.
Re-ranker
Diversity (MMR / determinantal — don't show 8 posts from one sub), freshness boost, integrity gate (drop/demote unsafe — ch12), frequency caps (don't re-show seen posts), business rules.
Step 6 — Serving architecture & APIs
client ──▶ Fastly CDN / edge ──▶ GraphQL router ──▶ homeFeed resolver
│
▼
Feed service
1) check per-user feed cache (Redis, short TTL) ── hit ─▶ return
2) miss: candidate gen (Redis sorted sets + ANN)
3) hydrate features from online feature store (sub-10ms)
4) ranker on model server (dynamic batching, GPU)
5) re-rank + integrity gate
6) write feed cache, return post IDs
│
hydrate post/author/sub/counts via DataLoaders across Go subgraphs
(counts/fragments from memcached; truth in Postgres)
"The feed is exposed as a GraphQL homeFeed field. Its resolver calls the ranking service for ordered IDs, then hydrates posts through DataLoaders across our federated Go subgraphs — batched to avoid N+1. Counts and rendered fragments come from memcached; source of truth is Postgres."
Step 7 — Scale / latency / caching / reliability (over-invest here)
- Feed cache: precompute & cache the ranked feed per active user with short TTL (tens of seconds) → most reads are O(1); compute-on-read on miss. (ch3: hybrid, not pure fanout.)
- Stampede protection: request coalescing on cache miss for hot users; TTL jitter; stale-while-revalidate so the feed is never empty.
- Hot keys: r/popular & viral posts get replicated cache + local L1; counters split + summed.
- Graceful degradation: if the ranker times out, fall back to the hot-score ordering of cached candidates — degraded but never blank. Kill switch to disable the ML ranker entirely.
- Backpressure: Kafka absorbs engagement spikes; if the feature job lags, serve last-known features (bounded staleness), don't block the read path.
- Latency budget: 5ms edge + 10ms context + 15ms candidate gen/ANN + 12ms feature hydration + 30ms ranker + 5ms re-rank ≈ within 150ms p99.
Step 8 — Eval, A/B, monitoring, feedback
- Offline: per-head AUC/PR, calibration, NDCG/recall@K for retrieval.
- Online A/B: primary = session length / DAU / dwell; guardrails = downvote rate, hide/report rate, complaints, long-term retention. Never ship if guardrails regress even with engagement up.
- Monitoring: p99 per stage, cache hit rate, candidate-gen recall, feature freshness lag, train/serve skew checks, prediction-distribution drift.
- Feedback loop: today's impressions + outcomes become tomorrow's labels; watch for feedback-loop bias (we only learn about what we showed) → keep an exploration slice + log propensities for unbiased eval.
Cold start & integrity
New user: no history → rely on onboarding topic picks, popular-by-locale/age, account-age-trust priors, and exploration; quickly personalize as signals arrive. New post: no engagement → lean on author/sub priors, content embeddings, and a freshness/exploration slice to gather early signal (Rising surfaces velocity). Integrity: every candidate passes a safety gate before ranking output (ch12) — unsafe content is demoted/removed, not just down-weighted.
Reddit's "best" comment sort is the Wilson score lower confidence bound on the upvote fraction — it rewards confidence, not raw counts, so a 5-up/0-down comment can beat 100-up/40-down. Layer a learned re-ranker on top for quality/relevance. Store the nested tree (adjacency list / materialized path) and paginate deep threads with "continue this thread."
8.1 Wilson score lower bound (CONFIRMED)
Naive sorts fail: "ups − downs" lets a high-volume mediocre comment beat a high-quality low-volume one; "fraction up" lets 1-up/0-down beat 99-up/1-down. The Wilson lower bound asks: given the votes seen, what's the lower end of the 95%-confidence interval for the true positive fraction? Few votes → wide interval → lower bound is pulled down; many votes → tight interval → bound near the observed fraction.
$$ \widehat{p}_{\text{LB}} = \frac{\hat p + \dfrac{z^2}{2n} - z\sqrt{\dfrac{\hat p(1-\hat p)}{n} + \dfrac{z^2}{4n^2}}}{1 + \dfrac{z^2}{n}} $$
where $\hat p$ = observed upvote fraction, $n$ = total votes, $z$ = the z-score for the confidence level (≈1.96 for 95%). Sort comments by $\widehat{p}_{\text{LB}}$ descending. Intuition: it's "what fraction up could we defend even if we got unlucky with this sample." This is why a 5/0 comment (high $\hat p$, modest $n$) can outrank 100/40 (lower $\hat p$, high $n$).
| Sort | Formula / idea | Behavior |
|---|---|---|
| Best | Wilson LB on up-fraction | Quality with confidence; default |
| Top | net score, no decay | All-time popularity |
| New | recency | Latest first |
| Controversial | high volume, near-equal up/down | Surfaces fights |
| Q&A | OP/answer-aware | For AMAs |
8.2 Learned re-ranker on top
Wilson is a strong unsupervised prior. A learned re-ranker (gradient-boosted trees or a small DNN) can lift it using features beyond votes: author trust, length/quality signals, toxicity score, relevance to the post, reply engagement, time-to-first-reply. Train on engagement targets (does this comment get further replies/upvotes/dwell) with integrity penalties. Keep Wilson as a feature and a safe fallback.
8.3 Nested tree storage & pagination
Storage options
- Adjacency list (parent_id): trivial writes; subtree read needs recursion / multiple round-trips.
- Materialized path (store "root/c1/c7"): one range-scan returns a subtree, ordered; reorders are costly.
- Closure table: a row per ancestor-descendant pair; flexible queries, more storage.
"Continue this thread"
Deep trees can't be fully loaded. Render to a depth/cost budget (e.g. top N children, M levels), then emit a cursor ("continue this thread") that pages the remaining subtree on demand — GraphQL cursor pagination per node. Collapse low-Wilson subtrees by default.
Reddit's notification system is a CONFIRMED 4-stage pipeline: (1) Budgeting via causal modeling — how many sends per user without harming long-term engagement; (2) Retrieval via two-tower embeddings — candidate notifications; (3) Ranking via a multi-task deep NN — predict click / upvote / comment; (4) Reranking via business logic — boost subscribed content, enforce caps. Causal budgeting exists because notification volume has a causal effect on long-term engagement (fatigue/churn), which correlational click models miss.
9.1 The pipeline
9.2 Why causal budgeting (stage 1)
A correlational model says "this user clicks notifications, so send more." But sending more has a causal negative effect: notification fatigue → unsubscribes, app-uninstall, long-term disengagement. Stage 1 estimates the causal effect of send-volume on long-term engagement per user (uplift modeling / treatment-effect estimation from randomized send-rate experiments) and sets a daily budget — the number of sends that maximizes long-term value, not next-click. This is the most senior idea in the whole system: optimize the long-term objective, not the myopic proxy.
Two-tower is the standard retrieval architecture: precompute item (notification-candidate) embeddings offline, index in ANN, encode the user at request, dot-product retrieve. It's confirmed for Reddit's notification retrieval. For the home feed, Reddit publicly emphasizes engagement velocity + account-age trust and a multi-source candidate gen; treat two-tower there as one possible retrieval source, not "Reddit's home-feed standard."
9.3 Ranking, rerank, serving
- Stage 3 ranking: multi-task DNN predicts P(click), P(upvote), P(comment); blend into a send-worthiness score; only send above threshold and within budget.
- Stage 4 rerank: boost content from subscribed communities, enforce per-type caps, dedupe, respect quiet hours / channel (push vs in-app vs email).
- Serving: candidate triggers (new reply, hot post in your sub) flow through; dispatch via the async queue (RabbitMQ confirmed for notifications). Budget state in Redis. A/B on long-term retention + send-to-click + unsubscribe rate, not just CTR.
"Which communities should this user join?" is cold-start-heavy and graph-rich. Use graph signals (co-subscription, co-engagement: "users who joined X also joined Y") + content embeddings of communities + a two-tower user↔community retrieval, ranked by predicted join & sustained-participation, re-ranked for diversity and topic coverage.
10.1 Signals & model
- Graph / collaborative: community co-occurrence (item-item from subscription & engagement co-occurrence), node embeddings over the user-community bipartite graph, optionally GNN. The "joined X → also joined Y" signal is strong and interpretable.
- Content: community embeddings from their posts/description/rules → match to the user's interest embedding (handles new communities with no subscribers yet).
- Two-tower retrieval: user tower (history, onboarding topics, demographics) × community tower (content + graph features) → ANN over the (modest) community corpus; cheap because there are far fewer communities than posts.
- Ranker: predict P(join) and, crucially, P(sustained participation) — joining is cheap; staying is the real objective. Penalize churned-from communities.
10.2 Cold start (the heart of it)
New user
Onboarding interest picker → seed embedding; popular-by-locale/age; account-age-trust priors; aggressive exploration early, exploit as signal accrues.
New community
No subscribers/graph yet → content embedding + creator/topic similarity to established communities; seed via exploration slots; graduate to graph signals as engagement grows.
Objective separation matters: optimizing raw joins inflates vanity numbers; optimize healthy long-term participation and community fit (don't shove a brand-new user into a toxic niche).
Search is retrieval + learning-to-rank (LTR). Retrieve with a hybrid of sparse (BM25 / inverted index — exact terms, names, rare tokens) and dense (embedding ANN — semantic match), fuse the candidate sets, then re-rank with an LTR model over relevance + engagement + freshness + authority features.
11.1 Retrieval: sparse + dense hybrid
| Sparse (BM25 / inverted index) | Dense (embedding ANN) | |
|---|---|---|
| Matches | Exact terms, proper nouns, rare tokens, operators | Semantics, synonyms, paraphrase |
| Fails on | Vocabulary mismatch ("car" vs "automobile") | Exact rare strings, typos of names |
| Infra | Lucene/Solr/Elasticsearch | Vector index (HNSW/IVF-PQ) |
Fuse with reciprocal rank fusion or a weighted blend; the union covers each method's blind spots. (Reddit's stack includes Solr, which also offers ANN — so a Solr-based hybrid is plausible.)
11.2 Learning-to-rank
- Approaches: pointwise (regress relevance), pairwise (RankNet — order pairs correctly), listwise (LambdaMART / LambdaRank — directly optimize NDCG). LambdaMART (GBDT) is the battle-tested baseline; a DNN/cross-encoder re-ranks the top few for quality.
- Features: BM25 & dense similarity, click-through & dwell on past queries, post score/Wilson, freshness, subreddit authority, author trust, query-doc match features.
- Labels: graded relevance from clicks/dwell with position-bias correction (you only get feedback on what you showed and where).
- Eval: NDCG@K, MRR, recall@K for retrieval; online: success rate (click + dwell, no immediate re-query / abandonment), zero-result rate.
Safety is a real-time streaming + ML + human-in-the-loop system. CONFIRMED: Reddit runs content safety on Kafka + Flink Stateful Functions executing Lua rules — fast, hot-swappable rules over the event stream. Layer an ML classifier (toxicity/spam) on top, with a human-in-the-loop for the uncertain band. The core tension is precision vs recall, made harder by adversaries who actively evade.
12.1 Architecture
content event ──▶ Kafka ──▶ Flink Stateful Functions ──▶ decision
(post/comment/ │ - Lua rules (fast, hot-swap) │
vote/DM/report) │ - velocity/burst detection ├─▶ auto-allow (high-conf safe)
│ - reputation/account-age state ├─▶ auto-remove (high-conf bad)
│ │ └─▶ review queue (uncertain)
│ ▼ │
│ ML classifier (toxicity/spam/ ▼
│ NSFW/CSAM-detectors) human moderators
│ + admins → labels
└─────────────────────────────────────────────────────▶ feedback into training
"Layered defense": cheap deterministic Lua rules and velocity checks run inline on the stream (catch obvious spam/bursts with near-zero latency); an ML classifier scores the rest; a confidence band routes to humans. Human labels feed back into both the rules and the model.
12.2 Modeling & the precision/recall tradeoff
- Classifier: text (transformer) + behavioral features (account age, posting velocity, network/graph signals, report counts). Spam is often best caught by behavior, not content.
- Thresholds by stakes: auto-remove only at very high precision (false removals = censorship complaints, "Remember the Human"); cast a wider, higher-recall net into the human review queue. For severe categories (CSAM), recall dominates and you accept more human review.
- Operating point: quote a precision/recall pair per action tier; use PR-AUC, not accuracy (extreme class imbalance).
12.3 Adversarial dynamics
Spammers/trolls adapt: obfuscation (l33t-speak, unicode homoglyphs), coordinated brigading, fresh throwaway accounts, image-text to dodge text models. Defenses: account-age & reputation gating, rate limiting (token bucket — a confirmed Reddit interview topic), velocity/burst detection in Flink, graph-based coordination detection, frequent retraining, and hot-swappable Lua rules so humans can respond to a new attack in minutes without a model redeploy. Treat it as a moving target, never "solved."
13.1 Reddit confirmed stack (one-glance)
| Layer | Tech | Note |
|---|---|---|
| Relational truth | Postgres confirmed | Users, posts, votes-of-record |
| Cache | memcached + Redis confirmed | memcached in front of Postgres; Redis for structures/counters |
| Wide-column | Cassandra confirmed | Newer / write-heavy features |
| Streaming | Kafka confirmed | 500+ brokers, tens of millions msgs/sec |
| Stream compute / safety | Flink Stateful Functions + Lua rules confirmed | Content safety/moderation |
| Async jobs | RabbitMQ confirmed | Voting, link submission, notifications |
| API | GraphQL Federation, Go subgraphs (DGS) confirmed | Domain Graph Services |
| Edge | Fastly CDN confirmed | Static + cacheable responses |
| Platform | Kubernetes on AWS; Spinnaker / Drone CI / Terraform confirmed | Deploy / CI / IaC |
| CDC | Debezium reportedly | Hedge — one source |
| Vector / ANN | Evaluated Milvus / FAISS / Vertex Vector Search / Solr ANN eval | Retrieval embeddings |
13.2 Confirmed Reddit ranking formulas
- Hot =
sign(ups−downs)·log10(max(|ups−downs|,1)) + seconds/45000(12.5h half-life; 24h-old post needs ~10× score). confirmed - Comments (best) = Wilson score lower confidence bound (ch8). confirmed
- Rising = upvote velocity · Top = score, no decay · Controversial = high engagement, near-equal up/down. confirmed
13.3 Capacity-math quick-reference (memorize the chain)
Numbers to anchor estimates (round, defensible — never claim they're exact). Each row's consequence is the point.
| Quantity | Estimate | Architectural consequence |
|---|---|---|
| DAU | ~100M | Everything downstream multiplies from here |
| Feed requests | ~1.5B/day → ~17k QPS avg, ~50–70k peak | Design for peak; cache aggressively |
| Candidates ranked/req | ~500 → ~25–35M scorings/sec peak | GPU-batch the ranker; ~50–70 GPUs w/ redundancy (ch6.5) |
| Vote firehose | ~2B/day → ~23k avg, ~70k peak | 10–20× a Postgres primary → async via RabbitMQ (ch5.9) |
| Engagement firehose | tens of M/sec → ~4 GB/s, ~350 TB/day raw | Kafka, ~130–256 partitions, 500+ brokers, days retention (ch2.10) |
| Feed cache (IDs only) | ~20M active × ~1 KB ≈ ~20 GB | Cache IDs not payloads → a few Redis nodes (ch3.7) |
| Online feature reads | 50k QPS × ~200 feats ≈ ~10M reads/sec | Batched multi-get, sharded online store (ch18.2) |
| Feed latency budget | p99 ≈ 150ms (edge 5 + ctx 10 + cand 15 + feat 12 + rank 30 + rerank 5) | Per-stage budgets that sum under p99; degrade if over |
13.4 Confirmed Reddit signals (home feed)
Account-age trust; tendency to like new communities; upvote/comment history; engagement velocity. Reddit publicly emphasizes velocity + account-age trust for the home feed. Two-tower is industry-standard retrieval and CONFIRMED only for the NOTIFICATION retrieval stage — do not claim it as the home-feed standard.
13.5 Phrases that make you look senior
Idempotency
"Consumers are idempotent — dedupe by event_id / upsert — so at-least-once reprocessing converges." Required whenever you mention Kafka or queues.
Backpressure
"Kafka absorbs spikes; if the stream job lags we serve last-known features within a bounded-staleness SLA rather than block the read path."
Graceful degradation
"If the ML ranker times out, fall back to hot-score ordering of cached candidates — degraded, never blank."
Kill switches
"Every model and risky feature has a kill switch and a one-click rollback to the previous version."
Shadow traffic
"Score the new model in shadow on live traffic before any user sees it; compare distributions, then canary."
Train/serve skew & online-offline consistency
"One feature definition feeds both online and offline stores so training-time values equal serving-time values — the #1 silent model killer."
Point-in-time correctness
"Training joins use as-of-event feature values to avoid label leakage from the future."
Feedback-loop bias & exploration
"We only observe outcomes for what we showed → keep an exploration slice and log propensities for unbiased offline eval."
p99, not the mean
"I budget and monitor tail latency per stage — the mean hides the jank 1% of users feel."
13.6 Senior checklist (run before you say "done")
- Did I state the objective and a guardrail (not just engagement)?
- Did I do scale math and turn each number into a decision?
- Is there a retrieval → ranking → re-ranking funnel with reasons?
- Did I show the data path: events → Kafka → stream → feature store, plus a batch path?
- Did I name the caching strategy and a stampede / hot-key defense?
- Did I describe the API layer (GraphQL resolver + DataLoader + subgraphs)?
- Did I pick datastores by access pattern, not by habit?
- Did I cover serving: dynamic batching, p99 budget, autoscale?
- Did I handle cold start, integrity/safety, and graceful degradation?
- Did I close with A/B + guardrails + monitoring + the feedback loop?
13.7 Likely prompts (drill each with the 8-step script)
- Design Reddit's home feed (ch7 — the marquee).
- Design comment ranking / "best" sort + nested threads (ch8).
- Design the notification / push system (ch9 — confirmed 4-stage).
- Design subreddit / community recommendations (ch10, cold-start).
- Design Reddit search (ch11 primer; ch16 full 8-step — hybrid retrieval + LTR + cross-encoder).
- Design spam / toxicity detection at scale (ch12 primer; ch14 full 8-step — Kafka+Flink+Lua + human-in-loop + adversarial).
- Design ads ranking / targeting (ch15 — auction + pCTR/pCVR + pacing + calibration).
- #18 Video recommendation (ch17 — lead with data flow / logging / observability; cross-link q18).
- #19 Feature store (ch18 — point-in-time joins, versioning/tests, CI/CD, rollout/rollback, sub-10ms serving; cross-link q19).
- "Design Instagram Reels" / "a Reddit clone" (Exponent-reported) — same funnel, video twist (reuse ch17).
- Design trending / r/popular (streaming velocity, hot keys, fan-out).
- "What tooling to prototype a feature, and how do you scale it?" (Exponent-reported — narrate prototype → Kafka/feature-store/serving scale-up).
- Reddit Answers / RAG over Reddit (ch20 — the Staff-MLE-offer team: hybrid retrieval + rerank + grounded/cited generation + faithfulness eval).
- ML case study (modeling-only) — ad-click / dwell-time modeling (ch21 — run the modeling checklist, NOT the 8-step system-design script).
- Design a subreddit chatroom (ch22 — coding/design hybrid: WebSockets, pub-sub fan-out, Cassandra history, hot rooms).
See ch23 for a 60-second opening for each of the 10 most-likely prompts, ch24 for rapid-fire follow-up drills, and ch25 for two reusable architecture diagrams.
ch12 is the primer; this is the full 8-step treatment, because "design a safety / spam / toxicity system at scale" came up repeatedly in the multi-source sweep. The differentiator is treating it as a real-time streaming + adversarial + human-in-the-loop system, not a single classifier. CONFIRMED: Reddit runs this on Kafka + Flink Stateful Functions executing hot-swappable Lua rules.
Step 1 — Clarify & objective
"Detect and act on policy-violating content — spam, toxicity/harassment, NSFW-in-wrong-place, ban evasion, and severe categories (CSAM, violent threats) — across posts, comments, votes, DMs, and reports, in near real time. The true objective isn't 'max recall': it's protect the community while minimizing wrongful removals ('Remember the Human'). So I optimize per-category for an explicit precision/recall operating point and treat wrongful-removal rate and time-to-action on severe content as guardrails."
Step 2 — Requirements & scale math
Functional: score every content event; take graded action (allow / demote / quarantine / remove / route-to-human); support fast rule updates for emerging attacks; feed human decisions back into training. Non-functional: inline decision latency low enough not to delay posting (target ~tens of ms for the rule layer), high recall on severe categories, auditable decisions.
Scale: posts ~1M/day, comments ~50M/day, votes/DMs/reports add more — call it order 10^8 content events/day ≈ ~1–2k/sec avg, ~5k+ peak for content needing classification (far below the engagement firehose, but each is higher-stakes). Severe-category detectors run on 100% of media uploads.
Step 3 — ML framing
A cascade, cheapest-first: (1) deterministic Lua rules + velocity/burst heuristics (catch obvious spam at near-zero cost); (2) a fast ML classifier (toxicity/spam/NSFW) on what survives; (3) heavyweight detectors (hash-match + classifier for CSAM/known-bad) where recall must dominate; (4) a confidence band routes the uncertain middle to humans. Labels: human-moderator/admin decisions, user reports (noisy), and rule hits — heavily class-imbalanced, so PR-AUC not accuracy. Frame each tier as its own precision/recall problem.
Step 4 — Data & feature pipeline
"Content events publish to Kafka. Flink Stateful Functions consume them, keeping per-account/per-IP/per-fingerprint state (posting velocity, recent-removal count, account-age, karma) so behavioral features are computed in-stream. The Lua rules and the ML classifier both read this state." Behavioral features (velocity, account age, network/graph co-activity) catch spam that content models miss — spam is often a behavior, not a string. Human decisions sink back to the training lake.
Step 5 — Model
- Text classifier: a transformer (distilled for latency) over content + a behavioral feature head (account age, velocity, report counts, graph signals). Multi-label across policy categories.
- Image/media: NSFW classifier + perceptual-hash match against known-bad sets (severe categories); OCR to catch text-in-image evasion.
- Coordination detection: graph clustering over accounts/IPs/devices to catch brigading and bot rings (a model over the interaction graph, run in batch + streaming).
- Lua rules layer: human-authored, hot-swappable rules for known patterns — the fast-response weapon between model retrains.
Step 6 — Serving architecture & APIs
content event ─▶ Kafka ─▶ Flink Stateful Functions ─────────────────▶ decision tier
(post/comment/ │ Lua rules (hot-swap) + velocity/state │
vote/DM/media) │ │ survives rules ├─▶ allow (high-conf safe)
│ ▼ ├─▶ demote/quarantine
│ ML classifier (toxicity/spam/NSFW) ├─▶ remove (high-conf bad, high precision)
│ + media hash-match └─▶ human review queue (uncertain band)
│ │
└──────────────────────── feedback (labels) ◀────────── moderators/admins
The classifier is served behind the stream as a low-latency model service; the review queue is prioritized by severity × confidence so humans see the highest-impact uncertain items first. Every action is logged with the deciding rule/model version for appeals and audit.
Step 7 — Scale / reliability / adversarial
- Adversarial robustness: attackers obfuscate (l33t-speak, homoglyphs, text-in-image), spin up throwaways, and brigade. Defenses: account-age/reputation gating, rate limiting (token bucket — a confirmed Reddit interview topic), OCR + normalization, graph coordination detection, frequent retrains, and hot-swappable Lua rules to respond in minutes without a model redeploy.
- Fail-safe direction: if the classifier is down, the rules layer + conservative defaults still run; for severe categories, fail toward review (queue it) rather than fail-open. Kill switch per detector.
- Cost: the cascade keeps the expensive transformer off the 99% obvious cases; severe-category detectors are the costly always-on tier.
Step 8 — Eval, A/B, monitoring, feedback
- Offline: precision/recall & PR-AUC per category & per action tier; wrongful-removal rate on a human-audited sample.
- Online: shadow new models on live traffic before they take action; A/B on prevalence-of-bad-content-seen, appeal-overturn rate, moderator workload, time-to-action on severe content.
- Monitoring: drift (attackers shift the distribution constantly), queue depth/latency, rule-hit rates, false-positive complaints.
- Feedback loop: human decisions are the gold labels → continuous retrain; but watch label feedback bias (you only get labels on what you surfaced/removed) and audit a random hold-out to estimate true prevalence.
"It's a cascade on a Kafka+Flink stream: hot-swappable Lua rules and velocity checks catch the obvious cases inline, an ML classifier scores the rest, severe categories get hash-match + high-recall detectors, and a confidence band routes the uncertain middle to humans whose decisions become training labels. I set precision/recall per action tier — wrongful removal is a guardrail, not just recall — and I treat adversaries as a moving target, which is exactly why rules are hot-swappable independent of model deploys."
Different time-constants. The model is more accurate on fuzzy cases but takes a retrain+validate+deploy cycle to adapt. Rules are instant and auditable — when a new spam campaign appears at 2am, a human writes a rule and it's live in minutes, no redeploy. Rules also encode hard policy (exact-match banned content) where you want determinism, not a probability. The model handles generalization; rules handle speed and certainty. Reddit confirms Flink Stateful Functions running Lua for exactly this.
Per category by stakes. Auto-remove only above a very high precision point (false removals are censorship complaints + appeals load), and route the wider, higher-recall band to humans. For CSAM/imminent-harm, recall dominates: lower the bar and accept more human review, because a miss is catastrophic. Quote a precision/recall pair per tier rather than one global threshold.
Treat it as adversarial ML: continuous retraining on fresh human labels, monitor input-distribution drift, normalize obfuscation (unicode/homoglyph/OCR) before scoring, gate by account-age/reputation so new attackers start with less trust, and keep the human-authored rule layer as the rapid-response patch between retrains. Never claim "solved" — claim "instrumented to detect and respond to drift."
Prioritize the queue by expected harm × uncertainty: severe categories and high-reach content (large subs, going viral) first, low-stakes uncertain items later or sampled. Use the classifier's confidence to not send the obvious cases to humans at all. Track reviewer agreement for label quality, and feed their decisions back as the highest-value training signal.
Reddit is ad-funded, so an ads-ranking overlay on the feed is a natural prompt. The distinctive ideas vs organic ranking: a second-price-style auction, pacing/budgets, calibration (predicted probabilities must be numerically right, not just well-ordered), and blending ads with organic content.
Step 1 — Clarify & objective
"Pick which ads to show a user, where, and at what price, to maximize long-term platform value = advertiser ROI (so they keep spending) + user experience (so ads don't drive users away) + revenue. The proxy I optimize is expected value of an impression = bid × P(action), but I defend user-experience guardrails (ad hide/negative-feedback rate, session length) so we don't over-monetize into churn."
Step 2 — Requirements & scale math
Functional: for each ad slot, retrieve eligible ads (targeting + budget + policy), predict pCTR/pCVR, run an auction, apply pacing, blend with organic. Non-functional: same feed p99 (~150ms, ads add a small slice), budgets never overspend, predicted probabilities calibrated. Scale: feed QPS ~50–70k peak; each request has a few ad slots; candidate ads per request after targeting filters is modest (hundreds–thousands), so the ranker load is smaller than organic but the auction + pacing logic is the new complexity.
Step 3 — ML framing
Two (or more) predicted probabilities per (user, ad): pCTR = P(click) and, for performance ads, pCVR = P(conversion | click). eCPM-style rank: for a CPC ad, rank score = bid × pCTR; for CPA/oCPM, ≈ bid × pCTR × pCVR — the expected revenue per impression. Labels: clicks (dense, fast) and conversions (sparse, delayed — needs delayed-feedback handling). These models must be calibrated, because the predicted probability directly sets price and billing, unlike organic ranking where only the order matters.
Step 4 — Data & feature pipeline
Impression/click/conversion events → Kafka → features. Conversion attribution is the hard part: conversions arrive hours–days after the click (delayed feedback), so the training pipeline must wait/window and correct for not-yet-arrived positives (e.g. delayed-feedback loss). Features: user interest + context (organic), ad/creative features, advertiser/campaign features, and crucially cross features (user × ad category). Budget/pacing state lives in a fast store (Redis) updated in near-real-time off the spend stream.
Step 5 — Model
- pCTR/pCVR model: a DNN with feature crosses (DCN / DeepFM-style) or wide-and-deep; embeddings for user/ad/advertiser IDs. Often multi-task (CTR + CVR with a shared bottom; ESMM-style to handle the click→conversion funnel and CVR sample-selection bias).
- Calibration layer: isotonic regression / Platt scaling on top so predicted probabilities match observed rates — load-bearing because price = f(predicted prob).
- Auction: rank by expected value; charge generalized second price (winner pays just enough to beat the runner-up) — truthful-ish bidding, advertisers don't overpay.
Step 6 — Serving architecture & APIs
ad request ─▶ targeting/eligibility ─▶ retrieve candidate ads ─▶ pCTR/pCVR model ─▶ auction ─▶ pacing/budget ─▶ blend w/ organic
(audience, geo, (Redis/ANN over (calibrated) (GSP: (don't over- (interleave; cap
budget>0, policy) ad embeddings) 2nd price) spend; smooth) ads density)
The blended response goes through the same feed/GraphQL path; ads are interleaved at fixed/learned positions with a frequency cap. Spend is decremented from the budget store on each charged event.
Step 7 — Scale / pacing / reliability
- Budget pacing: spend a campaign's daily budget evenly over the day (probabilistic throttling / PID controller on spend rate), not all in the first hour — protects advertiser ROI and inventory. Pacing state must be near-real-time consistent enough to avoid overspend; small overspend tolerated, large overspend is a financial bug.
- Calibration drift: monitor predicted-vs-actual CTR continuously; recalibrate on drift, because miscalibration directly mis-prices.
- Cold start: new ad/campaign has no history → exploration budget + prior from similar creatives/advertisers; explore-exploit (e.g. Thompson sampling) to learn pCTR without burning advertiser money.
- Reliability: never serve a policy-violating or over-budget ad; fail to organic-only if the ads stack is down (the feed still renders).
Step 8 — Eval, A/B, monitoring
- Offline: CTR/CVR AUC and calibration (log-loss, reliability curve, calibration error) — calibration is non-negotiable for ads.
- Online A/B: revenue / RPM and advertiser ROI and user guardrails (ad-hide rate, session length, long-term retention). Optimizing revenue alone burns the user base.
- Monitoring: calibration drift, budget pacing accuracy, auction price distributions, conversion-delay distribution.
"Ads ranking is organic ranking plus three things: a calibrated pCTR/pCVR model — calibration matters because the probability sets the price — a generalized second-price auction ranking by bid × predicted action, and pacing that spreads each budget over the day so we don't overspend or front-load. Conversions are delayed feedback, so the training pipeline windows and corrects for not-yet-arrived positives. I blend ads with organic under a density cap and defend user-experience guardrails against pure revenue maximization."
Organic ranking only needs the right order — a monotonic transform of scores gives the same feed. Ads use the predicted probability numerically: the auction rank and the price are bid × pCTR, and billing/pacing depend on it. A pCTR that's 2× too high mis-prices and overspends real money. So ads need calibrated probabilities (isotonic/Platt + continuous predicted-vs-actual monitoring), not just good AUC.
In a second-price auction the winner pays just enough to beat the next-highest effective bid, not their own bid. This makes truthful bidding near-optimal — advertisers can bid their true value without fear of overpaying, which stabilizes the marketplace and reduces bid-shading games. First-price forces everyone to constantly shade bids and creates volatility. (Modern systems use variants, but GSP is the canonical answer to give.)
Delayed feedback. A click labeled "no conversion" now might convert tomorrow, so naive labeling under-counts positives. Handle it with a delayed-feedback model (model the conversion-delay distribution and reweight), or wait a fixed attribution window before finalizing labels (trades freshness for correctness), or use importance weighting for not-yet-matured samples. Also handle CVR sample-selection bias (you only observe conversions among clicks) with an ESMM-style model that trains CVR over the full impression space via the CTR×CVR factorization.
Treat user experience as a guardrail, not an afterthought: cap ad density and frequency, interleave rather than dominate, include ad negative-feedback (hide/"not relevant") as a ranking signal, and in A/B tests block ship if session length / retention / ad-hide rate regress even when revenue rises. The objective is long-term platform value (advertiser ROI + user retention + revenue), not this-session revenue.
An ads system is an organic recommender plus an economic mechanism. You still retrieve candidates and predict engagement — but then advertisers bid, an auction decides who wins and what they pay, and a pacing controller spends their budgets smoothly. The whole thing optimizes a three-way marketplace: users (relevance, not too many ads), advertisers (ROI/conversions), and the platform (revenue). If you only remember one line: rank ads by eCPM = bid × pCTR × 1000, charge a second-price, and pace the budget. These five chapters teach every piece from scratch, then walk the full design.
Ads is a funnel that ends in an auction. Keep this whole picture in your head; every sub-topic hangs off one box.
The vocabulary — learn these 25 terms and you can hold the conversation
| Term | Means |
|---|---|
| Impression | one ad shown to one user once. The thing advertisers ultimately pay for slots of. |
| Click (CTR) | user taps the ad. CTR = clicks / impressions. pCTR = the model's predicted click probability. |
| Conversion (CVR) | user does what the advertiser wants after the click (purchase, install, signup). pCVR = predicted conversion prob. |
| Bid | the max an advertiser will pay for the event they're buying (a click, an impression, a conversion). |
| CPC / CPM / CPA / oCPM | pricing models: cost-per-click / cost-per-mille (1000 impressions) / cost-per-action / optimized CPM (you bid on impressions but the system optimizes toward your action). |
| eCPM | effective cost per mille — the common currency that lets the platform compare a CPC ad, a CPM ad, and a CPA ad on one axis: expected revenue per 1000 impressions. This is the ranking score. |
| Ad Rank / ranking score | what the auction sorts by. Core form: bid × pCTR (+ quality/relevance terms). Highest wins. |
| Auction | the mechanism that picks winners and sets prices from everyone's bids. Runs per ad request, in <~10ms. |
| First-price / Second-price | winner pays their own bid (first) vs. just enough to beat the runner-up (second / GSP). |
| GSP / VCG | Generalized Second Price (the classic, simple) vs. Vickrey-Clarke-Groves (truthful, charges your "externality" — what Meta/Google moved toward). |
| Reserve / floor price | the minimum eCPM to win at all — protects the platform from selling impressions too cheap and protects users from low-quality ads. |
| Quality / relevance score | a multiplier that lets a relevant ad win over a higher bid — keeps the user experience and the marketplace healthy. |
| Budget & pacing | an advertiser's daily/lifetime cap, and the controller that spends it smoothly over time instead of all at 9am. |
| Targeting / audience | who the advertiser wants to reach (demographics, interests, custom/lookalike audiences, retargeting). |
| Bid shading | in a first-price auction, automatically lowering your bid to "just enough to win" so you don't overpay. |
| Attribution | deciding which ad/touchpoint gets credit for a conversion (last-click, multi-touch, data-driven). |
| Incrementality / lift | the conversions that happened because of the ad (vs. would-have-happened-anyway). The honest measure of value; measured with holdouts/ghost ads. |
| Calibration | pCTR=0.1 should mean 10% really click. Essential here because pCTR is multiplied by money. |
| ROAS / ROI | return on ad spend — the advertiser's north star. Keeping it high keeps advertisers bidding. |
- An auction sits at the end. You don't just sort by predicted relevance; you sort by expected revenue (bid × pCTR) and charge a price.
- Calibration is load-bearing. Organic only needs the order right; ads multiplies pCTR by dollars, so a mis-calibrated model literally mis-charges and overbids. (Figure in chA4.)
- Budgets & pacing. Each advertiser has a finite budget that must be spent smoothly — a control-systems problem organic never has.
- Conversions are delayed & sparse. A click's conversion may land days later (delayed feedback), and you only see conversions among clicks (selection bias) — special modeling (ESMM, delayed-feedback models).
The auction is the heart of ads and the thing interviewers probe hardest because most candidates hand-wave it. Master three ideas: (1) rank by eCPM (= bid × pCTR, not raw bid), (2) second-price / GSP makes truthful bidding optimal, and (3) reserve + quality protect the marketplace. Be able to compute a winner and a price on paper.
Why eCPM, not the raw bid
Different advertisers buy different events (clicks, impressions, conversions) and bid different amounts — you can't compare $5-per-click to $8-per-1000-impressions directly. eCPM converts every bid into one currency: expected revenue per 1000 impressions.
- CPC ad: $\text{eCPM} = \text{bid}_{\text{cpc}} \times p\text{CTR} \times 1000$ — you only earn the bid when a click happens, so multiply by click probability.
- CPM ad: $\text{eCPM} = \text{bid}_{\text{cpm}}$ — they pay per impression regardless, so it's already in eCPM units.
- oCPM / CPA (conversion): $\text{eCPM} = \text{bid}_{\text{cpa}} \times p\text{CTR} \times p\text{CVR} \times 1000$ — earn the bid only if click and conversion happen.
Second-price (GSP): the price the winner pays
If the winner paid their own bid (first-price), everyone would constantly shade bids down and re-bid — unstable, and you have to guess the right shade. The Vickrey / second-price insight: charge the winner just enough to beat the runner-up. Then bidding your true value is optimal (a dominant strategy) — you never pay more than you had to, so there's no reason to game it.
For one slot, the winner is the highest-eCPM ad; the price per click is set so the winner's charged eCPM equals the runner-up's eCPM:
Multi-slot GSP: with $K$ slots, sort by eCPM, slot $i$ goes to bidder $i$, and bidder $i$ pays the price that would just keep them above bidder $i{+}1$. (GSP is not truthful with multiple slots — that's the theoretical wart VCG fixes.)
VCG (Vickrey-Clarke-Groves) charges each winner their externality — the total value their presence takes away from everyone else. It's truthful even with multiple slots and complex allocations, which is why Meta (and Google for some surfaces) moved to VCG-style pricing. The downsides are that it's harder to explain to advertisers and can yield lower revenue in some configurations, so platforms add reserves. You don't need to derive it — just know GSP = simple but not truthful multi-slot; VCG = truthful, charges your externality, what big platforms use.
Reserve prices, quality, and the final ad rank
Two more terms turn the toy formula into a real one:
- Reserve / floor: an ad must clear a minimum eCPM to show at all. Protects revenue (don't sell cheap) and users (don't show junk). Reserves can be dynamic/personalized.
- Quality / relevance & user-value terms: real ad rank is not just bid × pCTR. It blends in ad quality, predicted user satisfaction, and negative signals (hide-rate):
Three CPC ads compete for one slot. Compute eCPM, pick the winner, set the price.
| Ad | bid (CPC) | pCTR | eCPM = bid×pCTR×1000 |
|---|---|---|---|
| A | $3.00 | 4% | $120 |
| B | $1.50 | 10% | $150 ← winner |
| C | $5.00 | 2% | $100 |
Winner = B (highest eCPM, despite the lowest bid — relevance wins). Runner-up eCPM = $120 (Ad A). B's charged price per click = $120 / (0.10 × 1000) = $1.20 (+$0.01). So B bid $1.50 but pays only $1.20 per click — and only when a click actually happens. That gap is the "second-price discount," and it's why B has no incentive to lie about its bid.
The auction needs two numbers per ad: pCTR (will they click?) and, for conversion advertisers, pCVR (will they convert?). These are the same retrieval→ranking ML you know, with four ads-specific complications: calibration (pCTR × dollars), delayed & biased conversion labels, extreme sparsity/scale, and position/selection bias. Nail those four and you've shown depth.
Candidate generation — narrow millions of ads to ~hundreds
Same two-stage funnel as organic, but eligibility is richer. For each request, retrieve ads that are (a) targeted to this user (audience match), (b) eligible (budget not exhausted, frequency cap not hit, brand-safe for this context), and (c) likely relevant. Methods: inverted-index match on targeting criteria, plus a two-tower ad-retrieval model (user tower × ad tower → ANN index) for relevance-based recall. Output: a few hundred candidate ads.
The ranking models
pCTR model: a large-scale binary classifier. Features: user (history, demographics, interests), ad (creative, advertiser, category, embeddings), context (placement, device, time), and crucially cross features (user×ad affinity). Architectures, in order of how interviewers expect them: logistic regression (the historical baseline, still a great story for calibration/scale), GBDT, Wide&Deep (memorization + generalization), DeepFM / DCN / DLRM (explicit feature crosses + embeddings — the modern default for ads), and increasingly transformer-style sequence models over user history.
Multi-task: predict pCTR, pCVR, and other engagement heads jointly (MMoE/PLE) — they share a backbone and a lot of signal. For oCPM you need pCTR and pCVR to compute eCPM.
Calibration — the ads-defining requirement
This is the single most important ads-ML point. Because eCPM = bid × pCTR, the pCTR is multiplied by real money. If your model says 8% but the truth is 4%, you compute double the eCPM, win auctions you shouldn't, and overcharge the advertiser. So ads models must be calibrated, not just well-ranked.
- Measure it: reliability diagram, expected calibration error (ECE), and the simplest production check — predicted-clicks vs actual-clicks ratio over a window should be ≈ 1.0.
- Fix it: Platt scaling, isotonic regression, or temperature scaling on a held-out set; recalibrate continuously because the marketplace drifts.
- Why training breaks it: negative downsampling (you keep all clicks but sample the huge negative class for tractable training) shifts the base rate, so you must correct the predictions back with the known sampling rate: $p_{\text{corrected}} = \frac{p}{p + (1-p)/w}$ where $w$ is the negative downsample factor.
Delayed & biased conversion feedback (pCVR)
- Delayed feedback: a click labeled "no conversion" today might convert next week. Naive labeling under-counts positives. Fixes: a delayed-feedback model (jointly model conversion probability and the delay distribution, reweight), or wait a fixed attribution window before finalizing labels (freshness vs correctness tradeoff).
- Selection bias: you only observe conversions among clicks, but pCVR is needed over all impressions. ESMM solves this by modeling pCTR and pCTCVR over the full impression space and getting pCVR via the factorization $p\text{CVR} = p\text{CTCVR} / p\text{CTR}$.
Scale, sparsity & position bias
- Scale & sparsity: billions of examples, billions of sparse feature values (ad IDs, user IDs), CTRs of a few percent. Hashing / embedding tables for high-cardinality IDs, negative downsampling + calibration correction, online/continual learning so the model tracks fresh creatives and trends.
- Position bias: ads shown higher get clicked more regardless of relevance. Same fix as organic — model position as a feature you zero-out at serving, or inverse-propensity weight the training clicks — so you learn true relevance, not "was it on top."
- Cold-start: a brand-new ad/creative has no click history → lean on content features (creative embedding, advertiser priors), explore (give it some impressions with an exploration bonus / Thompson sampling) to gather data without tanking revenue.
The pieces organic never has: pacing (spend a budget smoothly, a control loop), auto-bidding & bid shading (the system bids on the advertiser's behalf), attribution & incrementality (who gets credit, and was the ad even causal), and marketplace health (keep all three sides happy long-term). These are where staff candidates separate themselves.
Budget pacing — spend smoothly, not all at 9am
An advertiser's $1000/day must last all day and reach the right users, not get burned by 11am on cheap morning traffic. A pacing controller throttles the ad's participation to track a target spend curve.
- Throttling / probabilistic pacing: enter only a fraction $p$ of eligible auctions; raise $p$ if under-spending, lower it if over-spending.
- Bid modulation: scale the effective bid by a pacing multiplier $\mu \in (0,1]$ — a PID/feedback controller adjusts $\mu$ so cumulative spend tracks the target. This is the more modern approach (smoother than hard skipping).
- Why it's a control problem: spend is a delayed, noisy signal; over-correct and you oscillate. Frame it as a feedback loop with a setpoint (the spend curve) — that framing alone reads as senior.
Auto-bidding & bid shading
- Auto-bidding (value-based / target-CPA / target-ROAS): advertisers say "get me conversions at $20 each" and the system sets the per-auction bid for them — bid = target_CPA × pCVR (bid more when conversion is likely). Most spend on mature platforms is auto-bid; the platform is bidding into its own auction on the advertiser's behalf.
- Bid shading: the industry moved from second-price to first-price auctions (especially in programmatic/RTB) for transparency. But in first-price, bidding your true value means overpaying. Bid shading predicts the minimum winning price and shades your bid down to "just enough to win" — restoring some second-price-like efficiency. It's itself an ML model over auction-landscape data.
Attribution & incrementality — did the ad cause the conversion?
- Attribution assigns credit for a conversion across the touchpoints that preceded it: last-click (simple, over-credits the final ad), multi-touch (linear/time-decay/position-based), and data-driven attribution (a model — often Shapley-value-based — distributes credit by marginal contribution).
- Incrementality / lift is the honest question: of the conversions we attributed, how many wouldn't have happened anyway? Measured with holdouts / ghost ads (a control group that's eligible but not shown the ad) or geo experiments. This is the metric that protects advertiser trust — attribution can inflate, lift can't.
- Privacy shift: with ATT/cookie deprecation, deterministic attribution is fading → aggregated/modeled measurement (Aggregated Measurement, conversion modeling) and on-device signals. Worth a sentence to show currency.
Marketplace health, brand safety & user experience
- The three-sided balance: advertiser ROI (keeps bids/demand up), user relevance & ad load (keeps attention/supply up), platform revenue (the extract). Optimizing one alone breaks the others — over-monetize and users leave (supply collapses); under-monetize and you leave money on the table.
- Ad load & frequency: cap ads per session, frequency-cap per user/ad, interleave with organic. Treat user retention/session length as a hard guardrail in every ads experiment — block ship if they regress even when revenue rises.
- Brand safety & integrity: don't place ads next to harmful content (advertiser side), and screen ad creatives for policy violations (the content-safety stack from ch14 applies to ads too).
- Auction-pressure / reserve tuning: reserves and ad load are the platform's levers on the revenue/experience tradeoff; they're tuned with long-term holdbacks, not single-session revenue.
Now assemble it into a 45-minute answer. The structure is the standard ML-design framework, but with the auction, calibration, pacing, and marketplace woven in at the right beats. Drive it in this order and you'll hit every signal: requirements → metrics → the funnel → retrieval → pCTR/pCVR → auction & pricing → pacing & serving → measurement & iteration.
Clarify requirements & scope
- Surface & format: promoted posts in the home/subreddit feed (Reddit's main format), or search ads, or video? Assume feed-native promoted posts unless told otherwise.
- Objective: who's buying what — clicks (traffic), conversions (oCPM), or reach (CPM)? Assume a mix dominated by oCPM/conversion, since that's where the money and the ML depth are.
- Scale: state rough numbers — hundreds of millions of DAU-impressions/day, millions of active ads, <~100ms end-to-end including the auction.
- Constraints: latency budget, advertiser budgets must be respected, user experience guardrails, brand safety, privacy.
Define metrics — three sides, explicitly
| Side | Metric |
|---|---|
| Platform | revenue, eCPM, fill rate, auction depth/competition |
| Advertiser | ROAS, CPA vs target, conversion volume, incremental conversions (lift) |
| User | ad relevance, hide/negative-feedback rate, session length & retention (guardrails), ad load |
| Model | pCTR/pCVR AUC and calibration (predicted/actual ratio, ECE) |
Lead with: "I'll optimize Total Value = revenue + advertiser value + user value, with retention as a hard guardrail." That frames the whole design as a marketplace, not a revenue grab.
Architecture: the funnel + retrieval
Walk the funnel diagram (chA1). Retrieval: for each ad request, fetch eligible ads by (a) targeting/audience match (inverted index over targeting predicates), (b) eligibility filter (budget remaining, frequency cap, brand-safe for this context), and (c) a two-tower relevance retrieval (user tower × ad tower → ANN) — narrowing millions of ads to a few hundred. Cache item (ad) embeddings; the user tower runs online.
Ranking: pCTR / pCVR (calibrated)
Score each candidate with a multi-task model (DCN/DLRM-style with embeddings + explicit crosses, MMoE heads for pCTR and pCVR). Emphasize calibration (predicted/actual ≈ 1.0; recalibrate continuously; correct for negative downsampling) and the delayed/biased conversion handling (ESMM + delayed-feedback). Features: user × ad × context + sequence over user history. Mention online/continual learning for fresh creatives.
The auction & pricing — the part most candidates skip
This is where you stand out. For each candidate compute eCPM = bid × pCTR (× pCVR for oCPM) × 1000, add quality/user-value terms to get AdRank = expected revenue + α·quality − β·p(negative), apply the reserve, run a second-price/VCG auction over the slots, and compute each winner's price. Then blend the winning ads into the organic feed (compare ad eCPM against an organic value floor so ads only appear when they beat the opportunity cost of the slot).
Pacing & serving infrastructure
- Pacing: a controller per ad/campaign modulates the effective bid (pacing multiplier μ) to track the spend curve; feedback loop on near-real-time spend.
- Budget bookkeeping: spend must be tracked fast and consistently across the fleet — over-delivery is real money lost. Use a fast counter (Redis-style) with eventual reconciliation; account for the in-flight auctions.
- Serving: feature store (online + offline parity), candidate/embedding caches, the whole request in a tight latency budget; the auction itself is cheap once eCPMs are computed.
- Event pipeline: impressions/clicks/conversions → Kafka → stream features (Flink) + warehouse; log what you ranked with (candidate set, bids, pCTR, position) so training data is reconstructable and you can measure bias.
Measurement, experimentation & iteration
- A/B with guardrails: revenue up is not a ship criterion if retention/ad-hide regress. Use long-term holdbacks for the revenue/experience tradeoff.
- Incrementality: ghost-ad / holdout measurement so you report causal advertiser value, not just attributed conversions.
- Calibration monitoring: predicted/actual click & conversion ratios on dashboards; alert on drift.
- Marketplace monitoring: auction depth, fill rate, reserve effectiveness, advertiser ROI distribution — the marketplace can degrade silently even when revenue looks fine.
"Reddit ads is a three-sided marketplace — users, advertisers, platform — so I'll optimize Total Value (revenue + advertiser value + user value) with retention as a guardrail, not raw revenue. The pipeline is a funnel that ends in an auction: per ad request I retrieve eligible, targeted ads, predict a calibrated pCTR and pCVR, convert each bid to eCPM = bid × pCTR × 1000, add quality/user-value terms, run a second-price (or VCG) auction over a reserve, and blend the winners into the feed against an organic value floor. Two things make ads harder than organic ranking: calibration is load-bearing because pCTR is multiplied by dollars, and I have to pace each advertiser's budget smoothly with a feedback controller. I'd measure success on all three sides and use ghost-ad holdouts for true incrementality."
That paragraph hits every concept an ads-design interviewer wants: marketplace framing, the funnel, calibration, eCPM, the auction, pacing, and incrementality. Everything else is depth on demand.
Raw bid ignores relevance. eCPM = bid × pCTR is expected revenue, so a cheap relevant ad beats an expensive ignored one — which both makes the platform more money in expectation and protects users from irrelevant high-bidders. It's the alignment mechanism of the whole system.
Winner pays the minimum that would still have won — the runner-up's eCPM converted back to the winner's units. Because your price depends on the runner-up's bid, not yours, raising your bid only changes whether you win, never what you pay when you win — so bidding your true value is dominant. (Caveat: GSP isn't truthful with multiple slots; VCG is, by charging your externality.)
Organic only needs the order; a monotone distortion of scores doesn't change the ranking. Ads multiplies pCTR by real dollars in the eCPM, so a 2×-too-high pCTR doubles the eCPM → wins auctions it shouldn't and mis-charges advertisers. Check predicted/actual ≈ 1.0; fix with isotonic/Platt/temperature; correct for negative downsampling.
Delayed feedback: a "no-conversion" label now may flip later, under-counting positives. Use a delayed-feedback model (jointly model conversion prob + delay distribution and reweight) or a fixed attribution window (freshness vs correctness). And handle selection bias — you only see conversions among clicks — with ESMM, modeling pCTCVR over all impressions and dividing by pCTR.
Pacing: a feedback controller modulates the effective bid (pacing multiplier μ) or the fraction of auctions entered, tracking a target spend curve over the day. It's a control loop — spend too fast, throttle harder. The multiplier is essentially the Lagrange dual on the budget constraint.
Not on revenue alone. Check the guardrails: session length, retention, ad-hide rate, advertiser ROI. Over-monetizing raises revenue this quarter but erodes the user supply and advertiser demand that the marketplace runs on. I'd want long-term holdback data and confirm the user side didn't regress before shipping.
Attribution assigns credit (last-click over-credits; multi-touch/data-driven is better), but the honest measure is incrementality: a ghost-ad/holdout control group that's eligible but not shown the ad. The lift over control is the causal value. Attribution can inflate; a holdout can't.
Cold-start: lean on content features (creative embedding, advertiser category priors, similar-ad performance) so the model has a reasonable prior, then explore — give it some impressions with an exploration bonus / Thompson sampling — to gather click data without large revenue risk. Tighten as data accumulates.
Second-price/VCG is cleaner for advertisers (truthful, no need to shade) and is what social platforms (Meta) use for owned-and-operated auctions. First-price dominates programmatic/RTB display for transparency, but then you need bid shading (predict the min winning price) to avoid overpaying. I'd default to VCG-style for an owned surface like Reddit's feed and explain the externality pricing.
Your sequence-model + capacity-constrained-serving project maps cleanly onto ads ranking: pCTR/pCVR are exactly the large-scale, latency-bound ranking models you built, and "make an expensive model affordable to serve" is the ads serving problem. Bridge it: "I haven't owned the auction layer, but the ranking model under it — multi-task, calibrated, sequence-over-history, served in a tight latency budget — is precisely what I built; the auction and pacing are the economic wrapper I'd layer on top." That turns "I don't know ads" into "I know the hard ML half cold and here's how the marketplace sits on it."
ch11 is the primer; this is the full 8-step. Search surfaced in the multi-source sweep as a Reddit ML-SD prompt. The spine: hybrid sparse+dense retrieval → learning-to-rank → cross-encoder re-rank, with the infra (inverted index + vector index + LTR serving) carrying as much weight as the model.
Step 1 — Clarify & objective
"Search over posts, comments, communities, and people. Objective: return results that satisfy the query — measured by click + dwell with no immediate re-query or abandonment — fast. I optimize a relevance proxy but guard against pure engagement-bait results and against high zero-result/abandonment rates. Clarify: is it primarily navigational (find a known sub) or informational (find content about X)? That changes the sparse/dense balance."
Step 2 — Requirements & scale math
Functional: parse query → retrieve candidates → rank → return top-K with pagination + facets (subreddit, time, type). Non-functional: search p99 ~200–300ms (users tolerate a bit more than feed scroll), fresh content searchable within minutes, typo/synonym tolerance. Scale: search QPS is far below feed QPS (a fraction of sessions search) — call it low thousands/sec peak; the cost is the index size (billions of posts/comments) and keeping it fresh.
Step 3 — ML framing
Two-stage: retrieval (cheap, high-recall, get ~hundreds–thousands of candidates) then ranking (expensive, high-precision LTR over the candidates), then optional cross-encoder re-rank on the top ~50–100. Retrieval is hybrid: sparse (BM25 inverted index — exact terms, names, rare tokens, operators) ∪ dense (embedding ANN — semantics, synonyms, paraphrase), fused. Labels: graded relevance from clicks/dwell with position-bias correction (you only see feedback for what you ranked and where).
Step 4 — Data & feature pipeline
Indexing pipeline: new/edited content → CDC/Kafka → tokenize + embed → upsert into the inverted index (Solr/Lucene) and the vector index (HNSW/IVF-PQ). Query-side: query understanding (spell-correct, entity/intent, expansion). Click logs → Kafka → training data with position/propensity logging for unbiased LTR. Per-doc engagement features (score, Wilson, freshness, sub authority) materialized in a feature store.
Step 5 — Model
- Sparse retrieval: BM25 over an inverted index; cheap, exact, great for rare strings and proper nouns.
- Dense retrieval: a bi-encoder (two-tower) embedding of query and doc; ANN search; catches vocabulary mismatch. (Bi-encoder because it's precomputable and scales to retrieval.)
- Fusion: reciprocal-rank fusion or a weighted blend of the two candidate sets.
- LTR ranker: LambdaMART (GBDT, listwise, directly optimizes NDCG) as the battle-tested baseline over relevance + engagement + freshness + authority features.
- Cross-encoder re-rank: query+doc jointly through a transformer on the top ~50–100 — far more accurate, far slower, so only on the short list.
Step 6 — Serving architecture & APIs
query ─▶ query understanding ─▶ ┌─ BM25 (inverted index) ─┐ ─▶ fuse (RRF) ─▶ LTR ─▶ cross-encoder ─▶ top-K
(spell, intent, └─ dense ANN (vector idx) ┘ (~hundreds) (LambdaMART) (top ~50)
expansion) + feature store reads
Exposed as a search service / GraphQL field; results hydrated through the same DataLoader + subgraph path as the feed. Facets and pagination via cursors.
Step 7 — Scale / freshness / reliability
- Index sharding: shard the inverted + vector indexes by doc; scatter-gather across shards, merge top-K. Replicate shards for read QPS and availability.
- Freshness: near-real-time indexing off CDC/Kafka so new posts are searchable within minutes; segment merges in the background.
- Cross-encoder cost: only on the top ~50–100, batched on GPU — this is the latency-critical stage; degrade by skipping the cross-encoder under load (LTR results only).
- ANN operating point: tune efSearch/nprobe to a recall@K vs p99 target; cache hot/head queries entirely.
Step 8 — Eval, A/B, monitoring
- Offline: NDCG@K, MRR, recall@K for retrieval; on position-bias-corrected labels.
- Online: success rate (click + dwell, no re-query/abandonment), zero-result rate, time-to-first-click; A/B with these as primary.
- Monitoring: retrieval recall, query-latency p99 per stage, index freshness lag, head-query cache hit rate, ANN recall.
"Search is retrieval + learning-to-rank. I retrieve with a hybrid of BM25 over an inverted index (exact terms, rare names) and a dense bi-encoder ANN (semantics, synonyms), fuse with reciprocal-rank fusion, rank the union with LambdaMART over relevance+engagement+freshness+authority, and cross-encode the top ~50 for precision. Labels come from clicks/dwell with position-bias correction, the index stays fresh off CDC, and I degrade by dropping the cross-encoder under load."
No. Dense models fail on exact rare strings — a specific username, error code, or product SKU that wasn't in training. Sparse BM25 nails those but fails on vocabulary mismatch ("car" vs "automobile"). They have complementary blind spots, so the union (fused) covers both. Reddit's stack includes Solr, which offers both inverted-index and ANN, so a single-engine hybrid is plausible.
Bi-encoder (two towers, query and doc embedded separately) for retrieval: doc embeddings precompute and index in ANN, so you score millions cheaply. Cross-encoder (query+doc concatenated through one transformer) for re-ranking: it attends across query and doc jointly so it's far more accurate, but it can't be precomputed and is slow — so you only run it on the top ~50–100 candidates. Retrieve cheap, re-rank precise.
Users click higher-ranked results more because they're higher, not only because they're better — so click labels are confounded by position. If you train naively, you just reinforce the current ranker. Fixes: log the propensity (estimated examination probability by position, often from randomized/inverse-propensity experiments) and train with inverse-propensity weighting, or use a position feature at train time and zero it at serving. Naming IPW here is a strong signal.
Near-real-time incremental indexing: content changes flow via CDC/Kafka into the index as new segments (Lucene-style), searchable within minutes; background merges compact segments. Deletes are tombstoned and purged on merge. You never rebuild the whole index for a new post — that's the difference between a toy and a production search system.
"Watch-next is: given the video the user is on (or just finished), pick the next ~10–25 videos to autoplay/queue, optimizing satisfying watch-time — completion and meaningful watch, not clicks. I'll spend most of my time on the data path — event collection, logging, the feature/training pipeline, and the feedback loop — because that's where this system's correctness lives, then treat the candidate-gen → ranker → re-rank funnel as one component on top. I'll flag online/offline parity and observability throughout, since those are the things that silently break recommenders."
Step 1 — Clarify & objective
"Recommend short/long videos to maximize satisfying watch — completion + meaningful watch-time, not just clicks (clickbait inflates clicks, kills satisfaction). Guardrails: not-interested/skip rate, reported content, long-term retention. Clarify surface (feed vs dedicated video tab), cold-start severity, and whether autoplay is on (changes the impression/label definition)."
Step 2 — Requirements & scale math
Functional: rank a personalized video feed; log rich playback telemetry; update on feedback. Non-functional: feed p99 ~150ms, video start fast (separate CDN concern), labels available within minutes. Scale: same DAU order as feed; the new firehose is playback telemetry — play, pause, seek, watch-time-quartiles, buffering — which is denser than clicks (many events per video) → easily the heaviest event stream and the reason logging dominates the discussion.
17.1 The data flow & logging story (lead with THIS)
1 EVENT COLLECTION 2 TRANSPORT 3 STREAM + BATCH COMPUTE 4 STORES 5 SERVING
client SDK logs: Kafka topics Flink (real-time): online feature ranker reads
- impression (shown) ─▶ - video.impression ─▶ - watch-time aggs ─▶ store (Redis, features at
- play / pause / seek - video.playback - velocity / trending sub-10ms) request time
- watch-time quartiles - video.feedback - session state │
- complete / skip (key = user_id) │ offline lake ▼
- not-interested / report RF=3, retention 3-7d ▼ also sink (S3/parquet) ◀── join ── training
+ schema-registry, event_id watermarks for late point-in-time (labels =
+ client+server-side dedup mobile events satisfying watch)
│
OBSERVABILITY (cross-cutting): per-stage p99, consumer lag, event-loss rate, schema-violation rate, ◀──────────┘
feature-freshness lag, training/serving skew checks, model prediction-distribution drift, end-to-end trace IDs
Walk it left to right out loud. The five stations — collect → transport → compute → store → serve — plus the cross-cutting observability band are what this interviewer wants to hear, in this order, with the model living at "serve."
17.2 Logging done right (the details that score)
- Client-side vs server-side logging: client logs rich playback (watch-time, seeks) the server can't see; server logs authoritative impressions/serving decisions. Reconcile both — client logs lie (clock skew, dropped events on app-kill, ad-blockers), so server-side serving logs are the spine and client telemetry enriches.
- Log the candidate set & scores, not just the click — you need impressions and what was shown but not watched for unbiased training; log propensities for the exploration slice.
- Every event carries a stable event_id (dedup/idempotency), a request_id/trace ID (stitch impression→playback→outcome), session ID, and a schema version (registry-validated).
- Watermarks for late events: mobile devices buffer events offline and flush late — the Flink job uses event-time + bounded-lateness watermarks so a watch-time event that arrives 2 minutes late still lands in the right window.
- Exactly-once vs at-least-once: playback logging is at-least-once + idempotent (dupes are fine, dedupe by event_id); billing-relevant events (ads on video) need stronger guarantees.
17.3 The model (one component, kept crisp)
Standard funnel: candidate gen (subscribed + embedding ANN + trending + fresh/exploration) → multi-task ranker predicting P(complete), E[watch-time], P(like/share), P(skip/not-interested) → re-rank for diversity/freshness/integrity. Key video-specific points: (1) optimize satisfying watch-time, not clicks — weight completion and meaningful watch, penalize fast-skip; (2) video content embeddings from frames/audio/title/transcript for cold-start; (3) calibrate watch-time predictions if used for pacing/blending. State it in ~90 seconds, then pivot back to data flow and serving.
17.4 Serving, feedback loop, observability
- Serving: candidate gen from Redis/ANN, features in one sub-10ms multi-get, GPU-batched ranker, re-rank; video bytes served from CDN (Fastly) independent of ranking. Degrade to trending/subscribed ordering if the ranker times out.
- Feedback loop: playback outcomes → Kafka → features + training labels; today's watch becomes tomorrow's label. Keep an exploration slice and log propensities to fight feedback-loop bias (you only learn about videos you showed).
- Observability metrics to name: event-loss rate, consumer lag, feature-freshness lag, train/serve skew, prediction-distribution drift, per-stage p99, end-to-end trace coverage. This is the band the interviewer keeps pulling on — have ~6 concrete metrics ready.
"Let me start with the data path, because that's where this system lives. Clients and servers log impressions and rich playback telemetry — each event carries an event_id, trace ID, and schema version — into Kafka topics keyed by user. A Flink job with event-time watermarks computes real-time watch-time and trending aggregates into the online feature store and sinks raw events to the lake for point-in-time training joins. The recommendation ranker is one component that reads those features at request time; I optimize satisfying watch-time, not clicks. Across all of it I instrument event-loss, consumer lag, feature-freshness, train/serve skew, and prediction drift."
Client emits quartile/watch-time events → Kafka (keyed by user, event_id for dedup). Flink, using event-time watermarks for late mobile events, aggregates per-user and per-video watch stats and upserts them into the online store (idempotent under at-least-once). The same raw events sink to the lake; the training job joins them point-in-time to the impression that caused them — the watch-time outcome becomes the label, the as-of-impression feature values become the inputs. Same feature definition online and offline kills train/serve skew.
Instrument the pipeline: event-loss rate (compare client-emitted counts to server-received via sampled audit), schema-violation rate (registry rejects), consumer lag and feature-freshness lag, duplicate rate, and reconciliation between client and server logs. Add end-to-end trace IDs so you can follow one impression → playback → outcome. If you can't measure log loss, you can't trust your labels — that's the senior point.
Clicks reward clickbait thumbnails/titles; watch-time rewards content people actually consume. But raw watch-time has its own failure: it favors long videos and can reward "couldn't find the exit" passive watching. So optimize satisfying watch — completion rate, meaningful-watch thresholds, explicit positive feedback — and penalize fast-skip and not-interested. Always pair the watch-time objective with negative-feedback guardrails.
Event-time processing with watermarks: events carry the time they happened, and the Flink window tolerates bounded lateness so a late-flushed watch event lands in its correct window rather than being misattributed. Idempotent upserts (dedupe by event_id) absorb the duplicates that come from client retries. For events later than the watermark allows, route to a late-data side-output and reconcile in batch rather than silently dropping.
17.5 The full end-to-end watch-next architecture (draw this)
When the interviewer says "now draw the whole thing," this is the board. It deliberately puts the data path on the left and the model funnel as one box on the right — matching how this interviewer weights the round.
17.6 Online/offline parity & the feedback loop (where it silently breaks)
The three places parity breaks
- Feature-computation skew: the online "watch-time in last 10 min" is a Flink sliding window; if the offline training feature is recomputed by a different Spark query with a different window edge or a different late-data policy, the model trains on values it never sees in prod. Fix: one feature definition, materialized to both stores (ch18), with periodic sampled skew checks comparing served-vs-recomputed values.
- Label-definition skew: "satisfying watch" must mean the same thing in the label pipeline and any online proxy. If offline labels use a 30s completion threshold but the online reward uses raw watch-time, the model optimizes a target you never measured. Pin the label SQL/logic in code and version it.
- Data-availability skew: a feature available at training time (because the lake has the full day) may not be available at serving time (the Flink job is 8s behind). Train on what's actually servable — use point-in-time joins that respect the real serving freshness, not the idealized batch value.
The feedback loop and its bias
Today's served videos generate tomorrow's labels, so the model learns only about what it already showed — a self-reinforcing loop that ossifies the catalog and starves new/long-tail videos. Counter it explicitly: keep an exploration slice (e.g. an ε-greedy or Thompson-sampled fraction of slots), log the propensity (probability the slot was shown) so you can do inverse-propensity-weighted offline eval, and reserve a fresh/cold-start lane in candidate gen. Naming "we only observe outcomes for what we showed, so I log propensities and keep an exploration slice for unbiased eval" is a top-decile signal here.
(1) At serve time the ranking service emits a serving log: request_id, user_id, the full candidate set shown with their scores and positions, the model version, and the propensity for any explored slots — this is the authoritative spine. (2) The client emits playback telemetry (impression-confirmed, play, quartiles, complete, skip, not-interested) tagged with the same request_id. (3) Both land in Kafka; a join keyed on request_id stitches "what we showed" to "what happened." (4) The joined rows sink to the lake; the training job applies the label definition (satisfying watch) and point-in-time feature values as of the impression. The key senior detail: the serving log, not the click stream, is the source of truth for the candidate set and positions — without it you can't do unbiased training or position-bias correction.
Monitor catalog-coverage and diversity metrics over time: fraction of the catalog that gets any impression, Gini of impressions across videos, share of impressions going to top-1% videos, and new-video time-to-first-1000-impressions. If coverage shrinks and top-heaviness grows while engagement looks flat, the loop is eating itself. The intervention is the exploration slice + a fresh/cold-start lane, and evaluating candidate models with IPW on logged propensities rather than naive replay (which just rewards the incumbent).
With autoplay, an "impression" isn't a user choice — the next video plays automatically, so a "view" no longer signals intent and a short watch may just mean "hadn't reached for the skip yet." So I redefine positives around active signals (watch past a meaningful threshold, replays, likes/shares, adding to a queue) and treat fast auto-advance/skip as a strong negative. I'd also log the trigger (autoplay vs tap) on every impression so the model and the eval can condition on it — conflating tapped and auto-played impressions is a classic silent label bug.
You're predicting a continuous E[watch-time] (often log-transformed because watch-time is heavy-tailed). It must be calibrated if you blend it into a single score with classification heads or use it for pacing — a 2× overprediction skews the blend. Monitor predicted-vs-actual watch-time by segment (short vs long videos), because regression heads quietly mispredict on the tails. This is the bridge to ch21's dwell-time case study, where watch/dwell-time modeling is worked end-to-end as a modeling-only problem.
Step 1 — Clarify & objective
"A feature store is the system that lets many teams define a feature once and consume it consistently in training (historical, point-in-time-correct) and serving (online, sub-10ms), with versioning, tests, lineage, and rollback. The objective is training-serving consistency and developer velocity, with reliability and low serving latency as hard constraints. The #1 thing it exists to kill is train/serve skew."
Step 2 — Requirements & scale math
Functional: register feature definitions; materialize to online + offline stores; serve point-in-time training sets; serve online features at request time; version + test + roll out + roll back + backfill. Non-functional: online read p99 <10ms, offline join correctness (no leakage), high availability of the online path (it's on the critical serving path of every model). Scale: online store handles ranker QPS × features/request — e.g. 50k QPS × 200 features = 10M feature reads/sec, so reads are batched multi-gets and the store is sharded Redis/Cassandra.
18.1 The two-store architecture
ONE feature definition (declared once, in code/DSL, versioned in git)
│
┌─────────────────────────┴──────────────────────────┐
materialization materialization
│ (Flink stream + Spark batch jobs) │
▼ ▼
OFFLINE store (warehouse / lake, S3/parquet) ONLINE store (Redis / Cassandra)
- full history, append-only - latest value per entity
- point-in-time training joins (as-of) - sub-10ms point reads (multi-get)
- no label leakage - TTL / freshness SLA
│ │
▼ ▼
training (consistent features) ───── same definition ──── serving (ranker reads)
The single load-bearing idea: one definition, materialized two ways, so the value at training time equals the value at serving time. Everything else (versioning, tests, rollout) protects that invariant.
18.2 Point-in-time joins & train/serve consistency (lead with this)
Train/serve skew is when a feature is computed differently (or with different data availability) in training vs serving — the silent #1 RecSys model killer. The two mechanisms that prevent it:
- Shared definition + shared materialization code: the same logic computes the feature for the online store and the offline store. No re-implementing "user 7-day click count" twice.
- Point-in-time (as-of) joins for training: when building a training row for an event at time T, join feature values as they were at T, never their current value — otherwise you leak the future into the label. The offline store keeps a full history with valid-from timestamps so an as-of join is exact.
This is the difference between a model that looks great offline and dies in production (leakage + skew) and one that ships.
18.3 Versioning, tests, CI/CD for feature pipelines
Feature versioning
Each feature definition is versioned and immutable (in git, registered in a catalog). Changing a feature's logic creates feature_v2 rather than silently mutating v1, so already-trained models keep reading the values they trained on. Models pin the exact feature versions they consume.
Tests
- Unit tests on the transformation logic.
- Data-quality / expectation tests (Great-Expectations-style): null rate, range, cardinality, distribution.
- Skew tests: sample online-served values vs offline-recomputed values, alert on divergence.
CI/CD for pipelines
Feature pipelines are code: PR → unit + data tests in CI → deploy the materialization job → backfill the offline history → validate → promote. Treat a feature change like a code change, with review and a rollback path — not a notebook edit in prod.
Staged rollout & rollback
New/changed features roll out staged: shadow-compute and compare, then enable for a small % of models/traffic, then full. Rollback = repin to the previous feature version (immutability makes this clean). Kill switch per feature so a bad feature can be dropped from serving without redeploying every model.
18.4 Online serving, backfills, reliability/observability
- Online caching for sub-10ms: the online store is sharded Redis/Cassandra; the ranker fetches all of a request's features in one batched multi-get. Hot entities get an L1 cache. Freshness SLA per feature (real-time features from Flink within seconds; batch features refreshed nightly).
- Backfills: when you add a feature or fix a bug, recompute history into the offline store by replaying Kafka / re-running the batch job over historical data — then retrain. Backfill jobs must be idempotent and not corrupt online state.
- Reliability: the online store is on every model's critical path → replicate, multi-region read-local, and define a degradation policy (serve last-known/default feature values on miss rather than failing the request).
- Observability metrics to name: feature-freshness lag, online/offline skew rate, materialization job lag, null/anomaly rate per feature, online read p99, cache hit rate, feature-usage lineage (which models use which feature version).
"A feature store is one definition materialized two ways — an offline historical store for point-in-time training joins and an online store for sub-10ms serving — so the value at training time equals the value at serving time and train/serve skew dies. Features are versioned and immutable, tested in CI like code with data-quality and skew checks, rolled out staged with per-feature kill switches and version-repin rollback, and backfilled by replaying Kafka. I monitor freshness lag, online/offline skew, and read p99 because this store sits on every model's critical path."
To build a training example for an impression at 10:00:00, I need each feature's value as of 10:00:00 — e.g. the user's 7-day click count computed from data available up to that instant. If I instead joined the feature's current value, I'd leak post-impression behavior (including the outcome) into the inputs → the model looks brilliant offline and fails live. The offline store keeps each feature value with a valid-from timestamp, and the join picks the latest value with valid_from ≤ event_time. That's an as-of / point-in-time join.
Layers: (1) data-quality tests in the pipeline catch null/range/cardinality anomalies before bad values land; (2) distribution monitoring alerts on drift; (3) online/offline skew tests sample served vs recomputed values; (4) per-feature kill switch drops a bad feature from serving (models fall back to defaults) without a full redeploy; (5) version pinning means a model only ever sees the feature version it was trained on. The store is the control point that makes all five possible.
Then training and serving compute features in different codepaths → skew, and you re-implement every feature per model → no reuse, no lineage, no tests. The feature store centralizes the definition (compute once, consume everywhere), guarantees online/offline consistency, and gives versioning/rollout/observability. Inline computation is how skew bugs are born — naming that is the senior answer.
Register it as a new immutable version, deploy the materialization job, backfill its history into the offline store (replay Kafka / re-run batch) so training sets can use it, validate data-quality + skew, then opt models in by pinning the new version — existing models are untouched because they pin their own versions. Staged rollout + per-feature kill switch + version-repin rollback make it safe.
ch13.4 lists the phrases; this chapter is the why behind each, so you can deploy them with conviction and survive the follow-up. At Sr/Staff the bar isn't "more components" — it's reasoning about failure, cost, and the long-term objective, and turning numbers into decisions. Weave 4–6 of these into every design unprompted.
19.1 Idempotency
What: processing the same event twice produces the same state. Why it's senior: distributed systems retry; at-least-once is the default; so correctness requires idempotent consumers (dedupe by event_id, upsert by natural key, or compare-and-set). Survive the follow-up: "I'd make the consumer idempotent by upserting the per-user/per-window aggregate keyed by event_id, so a replay is a no-op, rather than claiming end-to-end exactly-once which is usually a myth across an external store." (ch2.9)
19.2 Backpressure
What: when a downstream can't keep up, the system slows or buffers gracefully instead of dropping data or cascading failure. Why senior: shows you think about overload, not just the happy path. Say: "Kafka is the backpressure — a slow consumer accrues lag on a durable log; we alert and autoscale on lag, and the read path degrades to bounded-staleness features rather than blocking." (ch2.8)
19.3 Graceful degradation
What: the system gives a worse-but-working answer when a dependency fails, never a blank/error. Why senior: availability is a product feature; "the feed must never be empty." Say: "If the ML ranker times out, fall back to hot-score ordering of cached candidates; if a GraphQL subgraph is down, return a partial response with that field null — degraded, never broken."
19.4 Shadow traffic
What: run a new model/system on live traffic, score it, but don't serve its output — compare against production. Why senior: de-risks launches with real distributions before any user is affected. Say: "Shadow the new ranker on live traffic, compare prediction distributions and latency, then canary 1%, then ramp — with a kill switch the whole way."
19.5 Training-serving skew
What: a feature computed differently online vs offline → the model sees different inputs in prod than in training. Why senior: it's the #1 silent killer of RecSys models and only experienced people name it unprompted. Say: "One feature definition materialized to both the online and offline stores, plus skew tests sampling served-vs-recomputed values, so training-time values equal serving-time values." (ch6.3, ch18)
19.6 Kill switches & rollback
What: every model/risky feature can be instantly disabled and reverted to a known-good version without a full redeploy. Why senior: shows operational maturity — you plan for being wrong. Say: "Every model is an immutable versioned artifact behind a pointer; rollback is a repoint, and there's a per-feature kill switch so a bad feature drops out of serving without redeploying every model."
19.7 Online/offline metric parity
What: the offline metric you optimize should move the online metric you care about; verify the link, distrust offline-only wins. Why senior: guards against shipping models that win on NDCG but lose on retention. Say: "I check that offline AUC/NDCG gains correlate with the online guardrails in past A/Bs; if they decouple, I trust the online result and re-examine the offline proxy. And I never ship on an engagement lift if a health guardrail regresses."
19.8 Cost
What: name the dominant cost (GPU fleet, embedding memory, cross-region egress, always-on stream compute) and the lever to control it. Why senior: Staff engineers own budgets, not just latency. Say: "The cost driver here is the GPU ranker fleet sized for peak p99; I cap it with dynamic batching, a cascade that keeps the expensive model off the obvious cases, and caching the feed so most reads never hit the ranker. Stream compute is always-on, so I'd only stream the features that genuinely need second-level freshness and batch the rest."
"Reddit Answers takes a natural-language question and returns a synthesized answer grounded in and cited to real Reddit threads. I'll frame it as a RAG pipeline: query understanding → hybrid retrieval (sparse + dense ANN) over posts and comments → reranking → a grounded generator that must cite its sources → safety and freshness gates. The two things I'll keep returning to are faithfulness (the answer must be supported by the retrieved threads, with citations, and not hallucinate) and retrieval quality (garbage-in-garbage-out — the generator is only as good as what we retrieve). I'll budget latency across stages because generation dominates it, and I'll treat eval — citation accuracy and faithfulness — as a first-class part of the design, not an afterthought."
Step 1 — Clarify & objective
"Given a user question, return a helpful, grounded answer that synthesizes across Reddit threads and cites the specific posts/comments it used. The true objective is helpful + faithful + safe, not 'fluent.' I optimize answer helpfulness (did it resolve the question, thumbs-up, no immediate re-ask) under hard constraints: faithfulness (every claim supported by a cited source), attribution (Reddit's value is community knowledge — cite the humans), and safety (no harmful/medical/legal mis-advice, no surfacing of toxic or removed content). Clarify scope: is it answering from the whole corpus or in-context within a subreddit/thread? Is it conversational (follow-ups) or single-shot? Both change retrieval and memory."
Step 2 — Requirements & scale math
Functional: parse question → retrieve relevant passages → rerank → generate a cited answer → safety-gate → return with source links; support follow-ups. Non-functional: end-to-end latency is dominated by generation — target first-token < ~1s, full answer ~2–5s (users tolerate far more than a 150ms feed scroll, but it must feel responsive, so stream tokens). Freshness: new threads answerable within minutes–hours. Cost: LLM generation is the dominant cost — every design lever (retrieve-less, cache, smaller model, distill) trades quality vs $/query.
Scale: question QPS is far below feed QPS — call it low hundreds to low thousands/sec. The cost isn't QPS, it's (a) the index over billions of posts + comments and keeping it fresh, and (b) the per-query LLM tokens. Back-of-envelope: 1k QPS × (say) 2k input tokens (retrieved context) + 400 output tokens → ~2.4M tokens/sec of generation → this is why context budget and caching are load-bearing cost decisions, and why you don't stuff 50 passages into the prompt.
Step 3 — ML framing (the RAG pipeline)
question ─▶ query understanding ─▶ hybrid retrieval ─▶ rerank ─▶ context build ─▶ generator (LLM) ─▶ grounding/cite check ─▶ safety gate ─▶ answer
(intent, decompose, sparse(BM25) cross- (dedup, pack, (grounded, (verify each claim (toxicity, policy, + citations
rewrite, expand, ∪ dense(ANN) encoder budget tokens, cite-constrained maps to a source; PII, no removed (links to
subreddit hints) → top ~100 top ~10-20) per-source IDs) decoding) drop unsupported) content) threads)
It's the search funnel (ch16) with a generation head bolted on, plus two RAG-specific stages: context construction (what passages, in what order, within a token budget, tagged with stable source IDs so citations are machinable) and grounding/citation verification (post-hoc check that every claim is supported by a retrieved source; drop or flag unsupported claims). The generator is one stage of eight — say that explicitly so you're not seen as "just call an LLM."
Step 4 — Data & indexing pipeline
- Chunking: Reddit content is threaded — a question's answer often spans a post + several comments. Chunk at a passage granularity (a comment, or a post + its top-Wilson comments) with enough context to be self-contained, and keep a stable source ID (permalink, thread id, comment id) on every chunk so citations resolve to a real, linkable thread. Over-chunking loses context; under-chunking blows the token budget.
- Embedding + dual index: embed each chunk → upsert into a vector index (HNSW/IVF-PQ, ch5.6) and an inverted index (BM25, for exact terms/names/error codes). New/edited content flows via CDC/Kafka (ch5.7) so the index is fresh within minutes.
- Metadata to index alongside: subreddit, score/Wilson, recency, author trust, removed/locked/NSFW flags — used both as retrieval filters and rerank features, and to exclude removed/unsafe content from ever being a source.
- Training data: for the reranker — graded relevance from clicks on cited sources, thumbs-up/down on answers, and (offline) human-labeled query↔passage relevance. For eval — a held-out set of questions with reference answers and known supporting sources.
Step 5 — Model (each stage)
Query understanding
Detect intent (factual / opinion / how-to / current-event), rewrite conversational follow-ups into standalone queries (resolve "what about the cheaper one?" against history), optionally decompose a multi-part question into sub-queries, and expand (synonyms, entities). For conversational mode this rewrite step is what makes follow-ups work — name it.
Retrieval (hybrid)
Sparse BM25 (exact subreddit names, product names, error strings) ∪ dense bi-encoder ANN (semantic match, paraphrase), fused with reciprocal-rank fusion → top ~100 passages. Hybrid because dense models miss exact rare strings and sparse misses vocabulary mismatch (ch16.7). Filter out removed/NSFW/low-quality at retrieval.
Reranking
A cross-encoder (query+passage jointly through a transformer) reranks the top ~100 → top ~10–20 (ch16: retrieve cheap, rerank precise). Blend in Reddit-native signals: Wilson score, subreddit authority, recency, author trust — a highly-upvoted answer in a relevant sub is better grounding than an obscure low-score comment.
Generator (the LLM)
- Grounded, citation-constrained generation: the prompt packs the reranked passages each tagged with a source ID; the instruction is to answer only from the provided sources and to attach a citation ID to each claim. This is the core anti-hallucination mechanism — the model is told its evidence and told to cite it.
- Context budget: pack the highest-value ~10–20 passages within the token budget (dedup near-duplicate comments; order by relevance; truncate tails). More context is not free — it costs latency, money, and can dilute attention ("lost in the middle").
- Abstain path: if retrieval returns nothing relevant (low scores), the generator should say "I couldn't find a good answer on Reddit" rather than hallucinate. An explicit no-answer is correct behavior, not failure.
- Model choice: a strong instruction-tuned LLM; consider a smaller distilled model for cost if quality holds. The model is swappable — the grounding/citation contract around it is the durable design.
Grounding / citation verification
Post-generation, verify that each sentence/claim is entailed by at least one cited source (a lightweight NLI/entailment check or a verifier model). Drop or flag claims with no support; ensure every citation ID resolves to a real passage that actually backs the claim. This is what turns "an LLM with sources in the prompt" into a faithful system.
Step 6 — Serving architecture & APIs
question ─▶ GraphQL/answers svc ─▶ query understand ─▶ ┌ BM25 (Solr/Lucene) ┐ ─▶ RRF fuse ─▶ cross-encoder rerank ─▶ context build
│ └ dense ANN (vector idx)┘ (top~100) (GPU, top~10-20) (token budget,
│ source IDs)
▼ │
conversation memory ▼
(rewrite follow-ups) answer ◀─ safety gate ◀─ grounding/cite verify ◀─ LLM generator (streamed tokens)
+ cited (NLI entailment, (grounded, cite- GPU, dynamic-batched,
thread links drop unsupported) constrained decoding) KV-cache, semantic cache
Exposed as an Answers service / GraphQL field; sources hydrate to real thread links through the same DataLoader + subgraph path (ch4). Stream tokens to the client so perceived latency is first-token, not full-answer. Each component is independently scalable: retrieval and rerank are GPU/CPU index reads; generation is the GPU-bound, dynamically-batched, KV-cache-heavy stage that dominates cost.
Step 7 — Scale / latency / cost / freshness / reliability
- Latency budget (generation dominates): ~30ms query understanding + ~40ms hybrid retrieval + ~60ms cross-encoder rerank + ~300ms-to-first-token + streamed generation (~2–4s full). So stream, and parallelize retrieval branches. Cross-encoder and verification are the droppable-under-load stages (degrade to LTR-only ranking, skip the verifier with a confidence flag).
- Caching (huge cost lever): semantic cache on (normalized question → answer) for popular/duplicate questions; prefix/KV cache for the shared system prompt + instructions; cache retrieval results for repeated queries. A high cache hit rate on head questions can cut generation cost by a large fraction.
- Cost levers: retrieve/pack fewer, better passages (rerank precision > raw context size); use a smaller/distilled generator where quality holds; cap output length; semantic-cache head queries. Quote: "generation is the dominant $/query, so I spend on rerank precision to shrink the context rather than stuffing the prompt."
- Freshness: near-real-time indexing off CDC/Kafka so new threads are retrievable within minutes — critical for "what's happening with X right now" questions. For genuinely breaking topics, boost recency in rerank.
- Reliability / graceful degradation: if the generator is down or times out, fall back to showing the top retrieved threads (an extractive/search result) — degraded but never blank, and it's still grounded. If retrieval is empty, abstain honestly. Kill switch on the generator to revert to search-results mode.
Step 8 — Eval, A/B, monitoring, safety
- Faithfulness / groundedness: the headline metric — fraction of answer claims supported by cited sources (measured by an NLI/entailment judge or human raters). A fluent-but-unsupported answer is a failure.
- Citation accuracy: do the cited sources actually back the claims, and do citation IDs resolve to real threads? Precision/recall of citations.
- Retrieval quality: recall@K and NDCG of the retriever/reranker on labeled query↔passage relevance — the upstream ceiling on answer quality.
- Answer helpfulness: offline LLM-as-judge + human eval; online: thumbs-up rate, no-immediate-re-ask, dwell on the answer, click-through to cited threads.
- Safety: harmful-advice rate (medical/legal/financial), toxicity, surfacing of removed/NSFW content as a source, PII leakage — gated and audited; A/B guardrails block ship on any safety regression.
- Online A/B + monitoring: helpfulness/thumbs-up as primary; faithfulness, safety, and latency as guardrails. Monitor retrieval recall, index freshness lag, abstain rate, hallucination/unsupported-claim rate, cache hit rate, p99 first-token, and $/query.
"Reddit Answers is RAG: query understanding → hybrid sparse+dense retrieval over chunked posts/comments → cross-encoder rerank with Reddit-native signals (Wilson, recency, authority) → pack the best ~10–20 passages with stable source IDs into a citation-constrained generator → verify every claim is entailed by a cited source and drop what isn't → safety-gate → stream the cited answer. The generator is one stage; faithfulness and retrieval quality are where the system lives. Generation dominates latency and cost, so I stream tokens, semantic-cache head questions, spend on rerank precision to shrink the context, and degrade to showing top threads if the LLM is down. I evaluate on groundedness and citation accuracy first, helpfulness second, with safety as a hard guardrail."
Layered: (1) retrieval quality — if you retrieve good evidence the model rarely needs to invent; (2) citation-constrained generation — instruct the model to answer only from the provided, ID-tagged sources and to cite each claim; (3) post-hoc grounding verification — an entailment check that each claim is supported by a cited source, dropping/flagging unsupported ones; (4) an abstain path when retrieval is weak, so "I couldn't find a good answer" beats a confident fabrication. No single layer is enough — hallucination is reduced, not eliminated, so the verifier + abstain + faithfulness monitoring are the honest framing.
Reddit answers are conversational and span a post plus comments, so a naive fixed-token chunk shreds the context. I chunk at a semantic unit: a self-contained comment, or a post bundled with its top-Wilson replies, sized to be individually meaningful and to fit the token budget when several are packed. Every chunk carries a stable source ID (permalink + comment id) so a citation resolves to the exact, linkable thread. I'd test chunking empirically against retrieval recall — too coarse blows the budget, too fine loses the context that made the answer make sense.
Same logic as search (ch16): dense ANN misses exact rare strings (a specific subreddit, product, error code) that BM25 nails, and BM25 misses paraphrase that dense catches — so I fuse both for recall. Then the cross-encoder rerank on the top ~100 is where precision comes from: it jointly attends over query and passage, far more accurate than the bi-encoder, but too slow to run on the whole corpus — so retrieve cheap, rerank precise, and the generator only ever sees the ~10–20 best passages. Better rerank means a smaller, cheaper, more faithful context.
CDC off the source-of-truth tables emits delete/edit events; the index consumer tombstones or updates those chunks within minutes, and retrieval filters on a live removed/locked/NSFW flag so a removed comment can't be a source. At answer-render time I also re-check the cited threads' current state before showing the citation, because the window between index and render is non-zero. Citing removed or stale content is a trust/safety bug, so freshness here is a correctness requirement, not a nice-to-have.
Tiered: (1) an automated entailment/NLI judge (or LLM-as-judge with a strict rubric) scores claim-level support against the cited sources on every answer or a large sample — cheap, continuous, the online signal; (2) a human-rated gold set calibrates and audits the automated judge (judges drift and have blind spots); (3) online proxies — thumbs-down, immediate re-ask, "this is wrong" reports — catch what offline misses. I report claim-level faithfulness and citation precision/recall as the headline, and I trust the human gold set to keep the automated judge honest.
Abstain, explicitly: "I couldn't find a reliable answer on Reddit for this." Low retrieval scores trigger the no-answer path rather than forcing the generator to confabulate from weak evidence. If the only matches are toxic/removed/policy-violating, the safety gate excludes them as sources and the system abstains rather than laundering harmful content into a confident answer. A correct abstain protects trust far more than a fluent wrong answer — and the abstain rate is itself a monitored metric.
21.1 How the case study differs from system design
| ML system design (ch7–ch20) | ML case study (this chapter) | |
|---|---|---|
| Center of gravity | Architecture: retrieval/serving/streaming/infra | The model: label, features, loss, eval, experiment |
| You draw | Boxes: Kafka, feature store, ranker, caches | Almost nothing — maybe a feature table or a calibration curve |
| Scale math | QPS, storage, GPU fleet | Data volume only insofar as it affects modeling (sparsity, imbalance) |
| The bar | Does it scale, degrade, stay correct? | Is the modeling sound: right label, no leakage, calibrated, properly evaluated, failure-aware? |
| Trap | Hand-waving the infra | Jumping to "I'd use XGBoost" before defining the label and the metric |
21.2 The modeling-round checklist (run this instead of the 8 steps)
- Problem framing: what decision does this model drive, and what does a good prediction change? Classification vs regression vs ranking. Online or batch.
- Label definition: precisely what is the positive? Over what window? How do delayed/missing/ambiguous labels work? (This is where most candidates are sloppy and most points are won.)
- Data & sampling: class imbalance, negative sampling, time-based splits (never random when there's temporal leakage), train/val/test by time.
- Features: user, item, context, and cross features; how each is computed; leakage check (is any feature unavailable at prediction time / does it peek at the label?).
- Model family + why: name candidates (GBDT vs DNN vs logistic), pick one, and justify with the data shape (sparsity, cardinality, nonlinearity, interpretability, latency).
- Loss & calibration: the training objective, and whether predicted probabilities must be numerically right (calibration) or just well-ordered.
- Evaluation / metrics: offline (AUC/PR-AUC/log-loss/calibration) tied to what matters; the online experiment (primary metric + guardrails) that decides ship/no-ship.
- Failure modes: bias, drift, feedback loops, leakage, cold start, miscalibration — name them and the mitigation.
21.3 Worked case A — ad-click prediction (pCTR), fully
1. Problem framing
Predict P(click | user, ad, context) for a candidate ad in a slot. It drives the auction: rank = bid × pCTR and the price depends on it, so this is a calibrated probability problem, not just a ranking problem (ch15). Online scoring, per impression candidate.
2. Label definition (where points are won)
- Positive = a click on the ad — but define it carefully: filter accidental clicks (immediate bounce-back), bot/invalid traffic, and double-counts (dedupe by impression_id). A "click" that bounces in 1s isn't a real positive.
- Attribution window: click must be tied to a specific impression; log impression_id on the click so the join is exact.
- Negatives: impressions shown but not clicked — but beware position/examination bias: a non-click at the bottom of the page may be "not seen," not "not interesting." Either model position explicitly (and zero it at serve) or correct with examination propensities.
3. Data & sampling
- Extreme imbalance: CTR is often ~1–5%, so positives are rare. Use negative downsampling (keep all positives, sample negatives) for tractable training, then recalibrate the predicted probability back to the true prior (downsampling inflates predicted rates — a known correction). This is a classic ad-modeling detail interviewers probe.
- Time-based split: train on past, validate on future — random splits leak temporal patterns and overstate offline metrics.
4. Features
- User: interest embeddings, historical CTR, recency/frequency of ad engagement, demographics/context.
- Ad/creative: creative embeddings, ad category, historical ad CTR (with smoothing for new ads), advertiser.
- Context: placement/slot, device, time, the surrounding organic content.
- Cross features: user-interest × ad-category, the most predictive and the reason feature crosses / DCN-style models matter for ads.
- Leakage check: "ad's CTR" must be computed as of the impression (point-in-time), never including the current click; new-ad CTR needs a smoothed prior, not a leaked future average.
5. Model family + why
Two strong choices, and I'd name the tradeoff: GBDT (XGBoost/LightGBM) — excellent on tabular, handles nonlinearity and missing values, strong baseline, fast to iterate; or a DNN with embeddings + explicit feature crosses (Wide&Deep / DeepFM / DCN) — better at high-cardinality sparse IDs (user/ad/advertiser) and learned crosses, scales to huge data, and supports multi-task (CTR + CVR). For ads at scale with massive sparse ID spaces, the industry standard is the deep cross-network family; I'd start with GBDT as a baseline and move to a DCN/DeepFM if the sparse-ID lift justifies the serving complexity. Logistic regression with hashed crosses is the classic, still-respectable, ultra-fast fallback.
6. Loss & calibration (load-bearing for ads)
Train with log-loss / binary cross-entropy (proper scoring rule → encourages calibrated probabilities). Then add an explicit calibration layer — isotonic regression or Platt scaling — because (a) negative downsampling biased the probabilities, and (b) the auction prices on the numeric probability. A pCTR that's 2× too high overspends real advertiser money. Calibration is non-negotiable here, unlike organic ranking where only order matters.
7. Evaluation
- Offline: AUC / PR-AUC for ranking quality, log-loss and a reliability/calibration curve + expected calibration error for probability quality. Report both — a high-AUC, badly-calibrated model still mis-prices.
- Online experiment: A/B with revenue/RPM as primary, advertiser ROI and user guardrails (ad-hide rate, session length, retention) as guardrails. Block ship if user guardrails regress even when revenue rises.
8. Failure modes
- Miscalibration drift → overspend; monitor predicted-vs-actual CTR continuously, recalibrate on drift.
- Position bias → model just learns "top = clicked"; correct with position feature / propensities.
- Cold-start ads → no history; smoothed priors + exploration (Thompson sampling) so you learn pCTR without burning budget.
- Feedback loop → you only see clicks on ads you showed; log propensities, keep exploration, evaluate with IPW.
- Leakage → any feature peeking at the label or computed post-impression silently inflates offline metrics and collapses online.
"pCTR drives the auction price, so it's a calibrated probability problem. I define a click carefully — filter accidental/invalid clicks, tie each to an impression_id — and treat unclicked impressions as position-biased negatives. CTR is ~1–5% so I downsample negatives and recalibrate back to the true prior. Features are user/ad/context plus crosses (user-interest × ad-category), all point-in-time to avoid leakage. I'd baseline with GBDT and move to a DCN/DeepFM for the sparse IDs, train with log-loss, and bolt on isotonic calibration because the auction prices on the numeric probability. I evaluate with AUC and a calibration curve offline, then A/B on revenue with user-experience guardrails. The failure modes I watch are calibration drift, position bias, cold-start ads, and leakage."
21.4 Worked case B — dwell-time / watch-time prediction
1. Problem framing
Predict expected dwell-time (or watch-time for video) on a feed item, used as a satisfaction signal blended into the ranker (ch7/ch17). Unlike click (binary), dwell is a continuous, heavy-tailed, censored target — which drives every downstream choice.
2. Label definition (the crux)
- Dwell-time is heavy-tailed (most items get a few seconds, a few get minutes) → model log(dwell) or bucketize, don't regress raw seconds with MSE (the tail dominates the loss).
- Censoring: a user who closes the app mid-item gives a lower bound, not a true dwell — handle with survival/Tobit-style treatment or cap-and-flag, not by treating the truncated value as truth.
- "Satisfying" vs raw dwell: raw dwell rewards long items and "couldn't find the exit" passive watching. Define the target as meaningful dwell (past a threshold, normalized by item length, paired with positive feedback), and treat fast-skip as a strong negative. Same lesson as ch17.6.
- Position/autoplay bias: dwell is confounded by position and by autoplay; condition on or correct for them.
3–5. Data, features, model
- Features: user historical dwell by topic, item length/type, content embeddings, context (time, device, autoplay flag), position. Cross: user-topic × item-topic.
- Model: a regression head on log-dwell, often a head on the shared multi-task ranker (P(click), P(complete), E[dwell], P(skip)) so it shares representations and the heads trade off in the blend. Could be GBDT for a standalone baseline; DNN multi-task in the production ranker.
- Two-stage option: P(dwell > 0) × E[dwell | dwell > 0] to handle the spike at zero (didn't engage) plus the continuous tail — cleaner than one regression over a zero-inflated target.
6. Loss & calibration
Regression loss on the transformed target (e.g. MSE on log-dwell, Huber for tail robustness, or a quantile/pinball loss if you want the distribution). Calibrate if E[dwell] is blended numerically with classification heads into one score — an overpredicting dwell head silently dominates the blend.
7. Evaluation
Offline: regression error on the transformed scale (don't report raw-second RMSE — the tail makes it meaningless), correlation/ranking metrics (does higher predicted dwell rank items the way actual dwell does), and calibration of E[dwell] by segment (short vs long items — regression heads quietly mispredict on the tails). Online: A/B on satisfying-watch / session length with negative-feedback (skip/not-interested) guardrails — never ship a dwell lift that raises the skip rate.
8. Failure modes
- Length bias → predicting "long item = high dwell"; normalize by length, optimize completion not raw seconds.
- Censoring ignored → systematically under-predicting dwell for engaged sessions cut short.
- Tail misprediction → MSE on raw seconds chases outliers; use log/Huber/quantile and monitor by segment.
- Optimizing the wrong thing → raw dwell rewards passive/clickbait watching; pair with explicit positive feedback and skip as a negative.
"Dwell-time is a continuous, heavy-tailed, censored target, so the modeling choices all flow from the label: I model log-dwell (or bucketize) so the tail doesn't dominate the loss, handle app-close censoring as a lower bound rather than truth, and define the target as meaningful dwell — normalized by item length, paired with positive feedback — not raw seconds, because raw seconds rewards long videos and passive watching. I'd use a regression head on the shared multi-task ranker, optionally a two-stage P(dwell>0)×E[dwell|>0] for the zero spike, with Huber/quantile loss for the tail and calibration if it's blended numerically. I evaluate on the transformed scale and by item-length segment, and A/B on satisfying-watch with skip-rate as a guardrail."
Because in ads the predicted probability is used numerically — auction rank and price are bid × pCTR, and billing/pacing depend on it — so a probability that's 2× too high overspends real money. In the organic feed only the order matters; any monotonic transform of the scores gives the same ranking, so a well-ordered but uncalibrated model is fine. That's why I add isotonic/Platt calibration and monitor predicted-vs-actual for ads, and don't bother for organic ranking.
Yes — it inflates the predicted positive rate, because the model learns the resampled prior, not the true ~1–5% CTR. That's fine for ranking (order is preserved) but wrong for the calibrated probability the auction needs. The standard fix is a calibration correction: either an analytic re-scaling using the known sampling rate, or fit isotonic/Platt on a held-out set that reflects the true prior. So downsampling for tractable training is fine if you recalibrate — and saying that unprompted is the senior signal.
Depends on the data shape, and I'd say so. For mostly-dense tabular features with moderate cardinality, GBDT is the right baseline — strong, fast to iterate, handles nonlinearity and missing values, interpretable via feature importance. When the signal lives in high-cardinality sparse IDs (user/ad/advertiser) and learned feature crosses, and you want multi-task and embedding sharing at huge scale, a DNN (Wide&Deep / DeepFM / DCN) wins, at the cost of serving complexity. I'd baseline with GBDT and only pay for the DNN if the sparse-ID/cross lift is real on a holdout. Naming the decision criterion (sparsity, cardinality, crosses, multi-task, latency) matters more than the pick.
I audit every feature for two leaks: (1) temporal — is it computable strictly from data available before the prediction time? "Ad's CTR" must be point-in-time as of the impression, never including the current outcome. (2) target — does the feature encode the label directly (e.g. a "was clicked" flag that sneaks in)? I also use time-based splits, not random, so future patterns can't leak into training. The tell of leakage is offline metrics that are too good and collapse online — so I sanity-check that offline lift is plausible and verify it survives a strict point-in-time backtest.
Reported in a 1Point3Acres VO: a coding/design hybrid — "design a subreddit chatroom." It's lighter than the full ML rounds; the interviewer wants a clean real-time messaging design plus enough code/API shape to show you can build it. Not an ML problem — don't force a ranker into it. The spine: fan-out of messages to connected clients, persistence + history pagination, presence, and moderation.
Clarify & requirements
"A chatroom per subreddit (or a thread): many users send messages, everyone in the room sees them in near-real-time, with scrollback history, presence ('who's online'), and moderation. Clarify: room size (a small private room vs an r/popular live thread with 100k concurrent — wildly different fan-out), ordering guarantees, and retention. I'll assume rooms can get large, so fan-out and hot-room handling are the interesting part."
- Functional: join/leave a room; send a message; receive others' messages live; load history (paginated); presence; moderation (delete/ban/rate-limit).
- Non-functional: low delivery latency (sub-second), ordered-within-room, durable history, scale to large rooms, graceful under hot rooms.
Architecture
clients ──(WebSocket)──▶ gateway / connection servers ──▶ Kafka/Redis pub-sub (per-room topic/channel) ──▶ fan-out to subscribers
│ send msg (hold the WS connections; │ (decouples senders from
│ map room → connected sockets) │ the N connection servers)
│ │ ▼
│ │ chat service ─▶ persist to Cassandra
│ │ (validate, rate-limit, (messages by (room_id, bucket),
│ ▼ moderation hook) clustering by ts → history scan)
│ presence in Redis (room → online set, TTL heartbeat)
▼
history read ──▶ chat service ──▶ Cassandra range-scan (paginate by cursor)
The key idea: clients hold a WebSocket to a stateful connection server; a published message goes through a pub-sub fabric (Redis pub-sub or a Kafka topic per room) so it reaches every connection server that has subscribers in that room, not just the sender's. Persistence is decoupled — write to Cassandra keyed by (room_id, time_bucket) so history is a single-partition range-scan and a hot megaroom doesn't make one unbounded partition (time-bucketing caps partition size — ch6.7 failure mode).
Message + API shape (the "coding" part)
// message
{ id: uuid, room_id, author_id, body, ts, seq } // seq = per-room monotonic for ordering/dedup
// Cassandra table (history = single-partition range scan)
CREATE TABLE messages (
room_id uuid,
bucket int, -- e.g. hour bucket, caps partition size
ts timeuuid,
msg_id uuid,
author_id uuid,
body text,
PRIMARY KEY ((room_id, bucket), ts)
) WITH CLUSTERING ORDER BY (ts DESC);
// WS protocol (client ↔ gateway)
-> JOIN {room_id}
-> SEND {room_id, body, client_msg_id} // client_msg_id for idempotent send/dedupe
<- MSG {id, author, body, ts, seq} // broadcast to room
<- ACK {client_msg_id, id, seq} // confirm the sender's own send
<- PRESENCE {room_id, online_count}
// history (REST/GraphQL)
GET /rooms/{id}/messages?before={cursor}&limit=50 // cursor = last seen ts/seq
Ordering & dedup: assign a per-room monotonic seq at the chat service (or use a timeuuid); clients dedupe on id and reconcile their optimistic local echo via the ACK's client_msg_id. Delivery: at-least-once over the pub-sub + idempotent client render (dedupe by id) — the same "at-least-once + idempotent" pattern from ch2.9.
Scale, hot rooms, moderation, reliability
- Hot room (100k concurrent, e.g. a live r/popular thread): fan-out to 100k sockets per message is the bottleneck. Spread connections across many connection servers, fan-out via the pub-sub fabric so each server pushes to its local subscribers, and batch/coalesce rapid messages. For extreme rooms, sample/aggregate (don't deliver every message to every client; this is the "live thread" degradation, like Twitch chat).
- Presence: Redis set per room with heartbeat TTL; show approximate counts for huge rooms (exact presence at 100k is wasteful).
- Moderation: messages pass a rate-limiter (token bucket — a confirmed Reddit coding topic) and a fast safety check (reuse ch12/ch14's Lua-rules + classifier idea) before broadcast; mods can delete (tombstone) and ban.
- Reliability: on reconnect, the client replays from its last-seen
seq/cursor to backfill missed messages from Cassandra — so a dropped WebSocket doesn't lose history. Connection servers are stateless beyond the live socket map, so one dying just drops connections that reconnect elsewhere.
"Clients hold WebSockets to stateful connection servers; a sent message is validated and rate-limited by the chat service, persisted to Cassandra keyed by (room_id, time-bucket) so history is a single-partition range scan, and published to a per-room pub-sub channel so every connection server with subscribers fans it out to its local sockets. Ordering is a per-room monotonic seq with client-side dedup by id; delivery is at-least-once plus idempotent render; reconnects replay from last-seen seq. The hard part is hot rooms — I spread connections, fan out via pub-sub, batch rapid messages, and for a 100k-concurrent live thread I degrade to sampled/aggregated delivery rather than pushing every message to everyone."
Chat is bidirectional and latency-sensitive: clients both send and receive continuously, so a full-duplex WebSocket avoids the overhead of repeated HTTP polls and the one-way limit of SSE. Polling wastes requests and adds latency; SSE is server→client only (fine for a read-only live feed, not for sending). The tradeoff is that WebSockets are stateful — connection servers hold open sockets, which complicates scaling and load-balancing — so I keep them thin (just the socket map) and push state to Redis/Cassandra.
Through the pub-sub fabric. Connection server A (where the sender is) doesn't know which servers hold the room's other members. So it publishes the message to the room's channel (Redis pub-sub or a Kafka topic), and every connection server subscribed to that room receives it and pushes to its locally-connected sockets. That decouples the N connection servers from each other — they don't need to know the global socket map, only their own. It's the standard fan-out-via-broker pattern for chat.
Memorize one clean opening per prompt. The opening's job: in ~60 seconds, state the objective + guardrail, frame the funnel/pipeline, and signal where you'll over-invest — buying control of the room. Each links to the full chapter. Ranked by reported likelihood given the 1Point3Acres + darkinterview intel.
1. Watch-next / video recommendation verbatim staff ML-SD (ch17)
"Watch-next picks the next ~10–25 videos to autoplay, optimizing satisfying watch-time — completion, not clicks. I'll lead with the data path — event collection, logging, feature/training pipeline, feedback loop — because that's where this system's correctness lives and what tends to get grilled, then treat candidate-gen → ranker → re-rank as one component. I'll flag online/offline parity and observability throughout."
2. Reddit Answers / RAG over Reddit the offer team (ch20)
"Reddit Answers synthesizes a cited answer over real threads. It's RAG: query understanding → hybrid sparse+dense retrieval → cross-encoder rerank → citation-constrained generation → grounding verification → safety gate. I'll keep returning to faithfulness and retrieval quality because the generator is only as good as what we retrieve, and I'll evaluate on groundedness and citation accuracy first. Generation dominates latency and cost, so I stream tokens and spend on rerank precision to shrink the context."
3. Home feed marquee (ch7)
"The home feed is a ranked, personalized blend of subscribed + recommended posts. I optimize a multi-task engagement blend (upvote/comment/dwell/share) with explicit negatives (hide/report), and defend long-term retention + community health as guardrails. Funnel: multi-source candidate gen → multi-task DNN ranker → re-rank for diversity/freshness/integrity. ~100M DAU → ~50–70k peak QPS, so I'll over-invest in caching, the Kafka→Flink→feature-store data path, and graceful degradation."
4. Ad-click modeling ML case study, modeling-only (ch21)
"This is a modeling round, so I won't draw infra — I'll nail the model. pCTR drives the auction price, so it's a calibrated probability problem. I'll define the click label carefully (filter invalid/accidental, tie to impression_id), treat unclicked impressions as position-biased negatives, downsample-and-recalibrate for the ~1–5% rate, build user/ad/context + cross features point-in-time, baseline GBDT then DCN/DeepFM for sparse IDs, train log-loss + isotonic calibration, and evaluate AUC and a calibration curve, then A/B on revenue with user guardrails."
5. Notifications / push confirmed 4-stage (ch9)
"Reddit's notification system is a 4-stage pipeline: causal budgeting (how many sends maximize long-term engagement, not next-click), two-tower retrieval, multi-task DNN ranking (click/upvote/comment), business rerank. The senior idea is stage 1: notification volume causally affects retention via fatigue, so I optimize the long-term objective with uplift modeling, and A/B on retention + unsubscribe rate, not just CTR."
6. Search ranking reported (ch16)
"Search is retrieval + learning-to-rank. Hybrid retrieval — BM25 inverted index (exact terms/names) ∪ dense bi-encoder ANN (semantics) fused with RRF — then LambdaMART LTR over relevance+engagement+freshness+authority, then a cross-encoder on the top ~50 for precision. Labels from clicks/dwell with position-bias correction; index kept fresh off CDC. I degrade by dropping the cross-encoder under load."
7. Content safety / spam / toxicity confirmed Kafka+Flink+Lua (ch14)
"Safety is a real-time streaming + adversarial + human-in-the-loop system, not one classifier. A cascade on a Kafka+Flink stream: hot-swappable Lua rules + velocity checks catch the obvious cases inline, an ML classifier scores the rest, severe categories get hash-match + high-recall detectors, and a confidence band routes the uncertain middle to humans whose decisions become labels. I set precision/recall per action tier — wrongful removal is a guardrail — and treat adversaries as a moving target."
8. Feature store darkinterview #19, production-side (ch18)
"A feature store is one definition materialized two ways — offline historical for point-in-time training joins, online for sub-10ms serving — so training-time values equal serving-time values and train/serve skew dies. Features are versioned/immutable, tested in CI like code, rolled out staged with per-feature kill switches and version-repin rollback, backfilled by replaying Kafka. It's a data-platform design, not a modeling one; I monitor freshness lag, online/offline skew, and read p99 because it's on every model's critical path."
9. Comment ranking / "best" sort + nested threads confirmed Wilson (ch8)
"Reddit's 'best' sort is the Wilson score lower confidence bound on the upvote fraction — it rewards confidence, so a 5/0 comment can beat 100/40, and few votes pull the bound down. I'd layer a learned re-ranker on top (author trust, toxicity, relevance) with Wilson as a feature and safe fallback, store the nested tree as a materialized path for single-scan subtree reads, and paginate megathreads with 'continue this thread' to a depth budget."
10. Subreddit / community recommendations cold-start-heavy (ch10)
"'Which communities should this user join?' is cold-start-heavy and graph-rich. Graph signals (co-subscription 'joined X → also joined Y') + community content embeddings + a two-tower user↔community retrieval over the small community corpus, ranked by predicted join and sustained participation — because joining is cheap, staying is the objective. Cold start via onboarding picks + content embeddings + exploration. I optimize healthy long-term participation, not vanity joins."
Once the design is on the board, the round is won or lost on follow-ups. Drill these until the answer is reflexive. Grouped by theme; each is the kind of curveball that separates Staff from Senior.
24.1 Data-flow / logging / observability (the watch-next grilling)
I instrument event-loss directly: emit a client-side sampled counter and reconcile it against server-received counts for the same sample, so loss shows up as a divergence rather than invisible absence. I also watch schema-violation rate (registry rejects), duplicate rate, and consumer lag. End-to-end trace IDs let me follow one impression → playback → outcome and spot where the chain breaks. The principle: if you can't measure log loss, you can't trust your labels — so loss is a first-class monitored metric, not an assumption.
Log the full candidate set with scores and positions from the serving path (not just the click), plus the propensity for explored slots, and keep an exploration slice. Then offline eval and training can use inverse-propensity weighting and position-bias correction rather than naively rewarding the incumbent ranker. The serving log — not the click stream — is the source of truth for what was shown.
That's train/serve skew. I'd (1) confirm with a skew test — sample served feature values, recompute them offline from the same raw events, diff; (2) check the usual culprits: different window edges, different late-data/watermark policy, different null-handling, or a serving-time data-availability gap (the offline batch had data the online job hadn't received yet); (3) the structural fix is one definition materialized to both stores (ch18). I'd also add a continuous skew monitor so the next occurrence alerts instead of silently degrading the model.
24.2 Failure & reliability
It degrades, it doesn't fall over. Under load I shed the ranker for the marginal traffic and serve hot-score ordering of cached candidates — degraded relevance, never blank. The feed cache absorbs most reads (so the ranker only sees misses), dynamic batching is already maximizing GPU throughput, and a kill switch can disable the ML ranker entirely. The user sees a slightly less personalized feed instead of a spinner; that's the graceful-degradation contract.
The real-time path degrades: no new engagement events flow, so streaming features go stale — but the read path serves last-known features (bounded staleness) and the cached feed, so users barely notice for a few minutes. Producers buffer/backpressure at the edge (and shed lowest-value events under sustained outage). Nothing is lost permanently because, on recovery, consumers resume from their committed offsets and catch up; the lake sink and training backfill from the replayed log. The vote-of-record path uses RabbitMQ, not Kafka, so votes are unaffected. The honest caveat: a long outage means stale trending and a stale feature backfill, so I'd alert on consumer lag and have a max-staleness SLA that triggers degradation.
Layered defenses (ch6.7, ch18): per-feature null-rate and range monitors in the materialization pipeline alert before/at landing; PSI/distribution drift monitoring on the feature; a schema registry should have rejected an incompatible change at the producer; and a per-feature kill switch lets me drop the broken feature from serving (models fall back to a default/imputed value) without redeploying every model. Version pinning means models only see the version they trained on. The model degrading without an alert is the actual failure — so the alert is the design, not the model.
24.3 Modeling curveballs
No — and this is the online/offline parity lesson. I trust the online result: the offline metric is a proxy and it's decoupled from what users actually do, plus the rising hide rate is a guardrail regression. I'd dig into why the proxy decoupled (wrong label, position bias, a metric that rewards something users dislike) and fix the offline metric before iterating. The rule: never ship an offline win that doesn't move the online primary, and never ship anything that regresses a health guardrail even if engagement rises.
Classic feedback-loop ossification (ch17.6): the model learns only from what it showed, so it doubles down on the head and starves the tail. I'd confirm with catalog-coverage and impression-Gini trending the wrong way, then intervene with an exploration slice (ε-greedy / Thompson), a fresh/cold-start lane in candidate gen, and a diversity term in re-rank — and evaluate candidate fixes with IPW on logged propensities rather than naive replay, which just rewards the incumbent.
Recall says the right passage is in the candidate set; the failure is downstream. I'd check: (1) rerank/context — is the right passage actually surviving into the ~10–20 packed into the prompt, or buried/dropped? (2) generation faithfulness — is the model ignoring the evidence and hallucinating, caught by the entailment verifier? (3) chunking — is the passage missing the context that makes it usable? (4) citation accuracy — are claims attached to the wrong source? Recall is necessary, not sufficient; faithfulness + rerank precision are usually where RAG quality actually leaks.
24.4 Scale & cost
Name the dominant cost first, then the levers. For a ranker fleet: (1) cache the feed so most reads never hit the ranker; (2) dynamic batching to maximize GPU utilization; (3) a cascade so the expensive model only scores survivors of cheap filters; (4) distill/quantize the model; (5) only stream the features that need second-level freshness, batch the rest (streaming is ~10–50× the cost). For RAG specifically: semantic-cache head questions, shrink the context via better rerank, smaller distilled generator, cap output tokens. I'd quantify the biggest line item and attack it, not micro-optimize the rest.
~50–70k peak feed QPS × ~200 features/request ≈ ~10M feature reads/sec. You never do 200 round-trips — it's one batched multi-get per request, so it's really ~50–70k multi-gets/sec against a sharded online store (Redis/Cassandra), each shard handling a slice, with an L1 cache for hot entities. The store is on every request's critical path at sub-10ms, so I replicate for availability and read-local in each region. The arithmetic (QPS × features ÷ batching) is the point.
Two boards you can redraw from memory under any related prompt: the RAG serving path (for Reddit Answers and any retrieval-augmented system) and the generic ML-platform data plane (the shape underneath every recommender — feed, video, notifications, ads).