ML SYSTEMS · THE DESIGN-INTERVIEW PLAYBOOK

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.

25 chapters 10 full worked designs 8-step framework RAG / Reddit Answers full design Video watch-next (the real staff prompt) ML case-study (modeling-only) format Reddit-confirmed stack & signals
TL;DR

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.

1
THE REPEATABLE SCRIPT

The ML-system-design interview framework

The ML-system-design skeleton — say these six out loud, in orderClarifyreqs · scope · metricsData & labelssources · leakageFeaturesoffline + onlineModelretrieval → rankingServingfeature store · infraScale & iteratemonitor · A/BPin the metric first (it decides everything downstream); end every answer at monitoring + the A/B that proves it moved.
A reusable spine for any ML design: nail the metric, then walk data → features → model (almost always retrieval then ranking) → serving → scale. Interviewers grade whether you drive the conversation in this order instead of jumping to the model.

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.

The one-sentence frame to open with
"Before I design anything, let me (1) pin the business objective and what we're optimizing, (2) state requirements and do scale math, then (3) frame it as an ML problem — retrieval, ranking, re-ranking — and work down the stack. I'll flag tradeoffs as I go." Saying this in the first 30 seconds signals seniority and buys you control of the room.

1.1 The 8 steps

#StepWhat you actually say / produceTime (of 45 min)
1Clarify problem + objectiveWhat 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
2Requirements + scale mathFunctional (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
3ML problem framingCast as funnel: retrieval → ranking → re-ranking. Define the label (implicit: upvote/comment/dwell/hide). Online vs batch. What's the loss.4–5 min
4Data + feature pipelineEvent logging → stream (Kafka) → real-time + batch features → feature store. Training data join. Label leakage / point-in-time correctness.5–7 min
5ModelCandidate gen (multi-source), ranker (multi-task DNN), re-ranker (diversity/freshness/integrity). Architectures + why.6–8 min
6Serving architecture + APIsRequest path, services, model server, caches, API layer (GraphQL/REST). Draw the boxes.5–7 min
7Scale / latency / caching / reliabilityWhere the bottleneck is, how you cache, shard, degrade gracefully, kill-switch, backpressure. This is your weak spot — over-invest here.5–7 min
8Eval + A/B + monitoring + feedback loopOffline metrics, online A/B with guardrails, drift/skew monitoring, the loop back into training.4–6 min
1 Clarifyobjective 2 Reqsscale math 3 ML framefunnel/label 4 Datastream/store 5 Modelgen/rank/rerank 6 ServingAPIs/boxes 7 Scalecache/relia 8 EvalA/B feedback loop: logged interactions become tomorrow's training labels
The spine. Step 7 is where strong RecSys candidates underperform — spend disproportionate time there.

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.
The move that scores points
Convert every number into an architectural consequence. "70k peak vote QPS" → "so votes go on an async queue, not synchronous to Postgres." "Tens of millions of engagement msgs/sec" → "so we publish to Kafka partitioned by user_id and aggregate in a stream job, never write per-event to the DB." Numbers that don't change a decision are decoration.

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

PhaseMinutes (45-min round)Goal
Clarify + objective + requirements + scale math (steps 1–2)0–10Lock scope, show numeric reasoning
ML framing + data/feature pipeline (steps 3–4)10–22Funnel, labels, Kafka/stream/feature store
Model (step 5)22–30Cand gen, ranker, reranker — your strength, be crisp
Serving + scale/caching/reliability (steps 6–7)30–40Your weak spot — over-invest, draw the boxes
Eval/A/B/monitoring + deep-dive Q&A (step 8)40–45Close 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").

2
INFRA PRIMER · PART 1

Kafka & stream processing, from zero to fluent

Kafka + stream processingproducerseventstopic (partitioned, replicated log)partition 0 ▸▸▸partition 1 ▸▸▸partition 2 ▸▸▸consumer groupoffsets per partitionstream processorFlink: windowed aggregatessinkonline store / DBAppend-only log decouples producers from consumers; partitions give ordered parallelism; consumergroups replay from offsets. Stream processors keep tumbling/sliding-window features fresh in seconds.
Kafka is a partitioned, replicated, append-only log. Producers write; independent consumer groups read at their own offset and can replay. A stream processor (Flink) maintains windowed aggregates — the mechanism behind near-real-time features like "upvotes in the last 5 min".
TL;DR

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:

2.2 Topics, partitions, offsets, consumer groups

ConceptWhat it isWhy it matters in an interview
TopicA named stream of records (e.g. user.engagement, post.created).Topics are your event taxonomy. One per logical event type.
PartitionAn ordered, append-only shard of a topic. Throughput & ordering unit.More partitions = more parallel consumers = more throughput. But ordering only holds within a partition.
OffsetThe position of a record in a partition.Consumers commit offsets to record progress; replay = move the offset back.
Partition keyA 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 groupA 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 factorCopies of each partition across brokers (leader + followers).RF=3 is standard → survives 2 broker failures. This is your durability/availability story.
BrokerA Kafka server hosting partitions. Reddit runs 500+.Cluster size scales throughput and storage; partitions are spread across brokers.
The partition-key sentence to memorize
"We publish engagement events to a Kafka topic partitioned by user_id so that all events for a user land on one partition in order — that lets a Flink job maintain per-user windowed aggregates with local state, and consumers in the group scale out by owning partition ranges." If you can say this fluently, you've cleared the bar.

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)    │
                                     └──────────────────┘                    └───────────────┘
  
Clientsimpr/click/dwell/vote Kafka topickey = user_id · RF=3 p0..pN Flink jobkeyBy user · 10m windowwatermarks · stateful data-lake sinkS3 / parquet (training) Online storeRedis/KV · sub-10ms Offline storewarehouse (batch feats) produce consume
Clickstream → Kafka → Flink → online feature store (real-time path), plus a lake sink for batch training features. The same topic feeds many consumer groups.

2.6 Streaming vs batch — when to use which

DimensionStreaming (Kafka + Flink)Batch (Spark / warehouse)
FreshnessSeconds — "engagement velocity in last 10 min", "is this trending now"Hours/daily — "user's 30-day topic affinity"
Use forReal-time features, integrity/safety, counters, trending, online learning signalsHeavy historical features, training-set joins, embeddings (re)training
CostAlways-on compute, harder ops, statefulCheap, restartable, simpler
Correctness riskOut-of-order, late data, watermark tuningStale by definition; simpler semantics
Lambda vs Kappa
The classic pattern is a Lambda architecture (parallel batch + speed layers, merged at read) vs Kappa (one streaming path, reprocess by replaying Kafka). Modern systems lean Kappa because Kafka's replayability removes the need for a separate batch path for many features. Mentioning this by name signals depth — but most real shops are pragmatically hybrid.

2.7 Fluency drills

Q. Why partition by user_id and not post_id?

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.

Q. A consumer crashes mid-batch under at-least-once. What happens?

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.

Q. Why not just write engagement events straight to Postgres?

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.
Say this out loud

"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.

GuaranteeMechanismCost / catchUse for
At-most-onceCommit offset before processingLoses data on crashAlmost never (lossy metrics at best)
At-least-onceProcess then commit offsetDuplicates on retry/crash → consumer must be idempotentThe default. Engagement features, logging
Idempotent producerProducer ID + per-partition sequence number; broker drops dupesRemoves 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_committedThroughput hit, latency, complexity; only within Kafka (not to an external DB unless that sink is transactional/idempotent)Counters/money that must not double
The honest senior framing
"True end-to-end exactly-once across Kafka and an external store is usually a myth — the pragmatic recipe is at-least-once delivery + idempotent processing so reprocessing converges to the same state. I make the consumer idempotent by deduping on a stable event_id (or upserting keyed by it) so a replayed event is a no-op. I reserve Kafka transactions (EOS) for the narrow case where a Kafka-to-Kafka counter genuinely cannot double-count." Saying "idempotent + at-least-once" instead of hand-waving "exactly-once" reads as someone who has actually run these systems.
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.

Q. How do you pick the number of partitions?

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).

Q. A celebrity user generates a hugely skewed partition (hot partition). Fix?

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).

Q. Consumer group rebalances constantly and lag spikes. What's happening?

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.

Q. Schema evolution — how do producers and consumers not break each other?

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.

3
INFRA PRIMER · PART 2

Caching, taught thoroughly

Look-aside (cache-aside) read pathapp / servicecacheRedis / memcacheddatabasesource of truth1 GET2 HIT → return3 MISS → read4 fill (set TTL)Hot reads served from RAM; TTL bounds staleness; on write, invalidate or write-through to avoid serving stale data.
Cache-aside: check the cache, serve on hit; on miss read the DB and backfill with a TTL. It absorbs hot-key read load and cuts tail latency. The hard part is invalidation — pick TTL vs write-through by how much staleness the product tolerates.
TL;DR

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

PatternHow it worksPros / consWhen
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-throughApp 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-throughWrite 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.
"There are only two hard things…"
Cache invalidation is genuinely hard. The senior move is to scope staleness: declare which data must be strongly consistent (vote totals the user just cast — show their own optimistically) and which can be eventually consistent (the global feed ranking — fine if a few seconds stale). Don't promise global consistency on a cache; promise bounded staleness.

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) post created write into eachfollower's feed cache read = O(1) lookup fanout explodesfor huge subs post createdjust store once read time:gather from sources read = expensive cheap writes,fresh, no blowup
Push precomputes feeds (fast reads, costly + explosive writes). Pull assembles at read (cheap writes, costly reads). Real systems are hybrid.
Fan-out-on-write (push)Fan-out-on-read (pull)
Write costHigh — write the post into every subscriber's feed listLow — store once
Read costLow — feed is precomputed, O(1) lookupHigh — gather + merge + rank at request time
FreshnessSlight lag (fanout job)Always current
Failure modeCelebrity / huge-subreddit fanout explosion (millions of writes per post)Slow reads, repeated work for active users
The senior answer (hybrid)
"I'd default to fan-out-on-read with heavy caching for a ranked feed like Reddit's, because the feed is ranked and personalized, not a simple chronological merge — precomputing it on every vote is wasteful. I'd precompute and cache the ranked feed per user with a short TTL (e.g. tens of seconds) so active users get O(1) reads, fall back to compute-on-read on miss, and use fan-out-on-write only for cheap building blocks like a small per-subreddit hot-posts list. For massive subreddits I never fan-out per subscriber — I keep a single ranked hot list and merge it at read." This shows you understand why Reddit isn't a pure push timeline.

3.6 Reddit's confirmed caching stack

Q. Redis vs memcached — when each?

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
  
Worked: a viral post on r/popular
"A post hits the front page → its count/score key sees, say, 100k QPS on one Redis shard. I put a tiny per-process L1 cache (a few-second TTL) in front of the shared cache so most of that 100k never leaves the app box; I replicate the hot key across read replicas so no single node is the bottleneck; and I shard the vote counter into K sub-counters incremented round-robin and summed on read, so writes don't serialize on one key. The vote total is allowed to be eventually consistent — I show the user their own vote optimistically and let the global count converge."

3.9 More fluency drills

Q. Why "delete the cache key on write" instead of "update it"?

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.)

Q. How do you keep caches consistent across regions/services without dual-writes?

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.

Q. The feed must "never be empty" — how does caching help reliability, not just speed?

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.

Q. Should a per-user personalized feed live on the CDN?

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.

Say this out loud

"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."

4
INFRA PRIMER · PART 3

GraphQL & the API layer, taught thoroughly

TL;DR

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

RESTGraphQL
EndpointsMany (/posts, /users/:id, /comments)One (/graphql), queried by shape
FetchingServer decides payload → over-fetch (too much) or under-fetch (need more calls)Client requests exact fields → no waste, one round-trip per screen
TypingConvention (OpenAPI optional)Strongly typed schema is mandatory — self-documenting, introspectable
Versioning/v1, /v2Evolve schema; deprecate fields instead of versioning
CachingEasy HTTP caching by URLHarder (POST, one URL) → use persisted queries + field-level caches
RiskChatty clients, N round-tripsN+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)
Say this in the room
"The feed resolver fetches ranked post IDs from the ranking service, then hydrates author/subreddit/counts through DataLoaders so we batch those into one query each and avoid N+1. Counts and rendered fragments come from memcached, not the DB." That one sentence proves you understand GraphQL at the level Reddit operates it.

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:

LayerWhat it cachesMechanism
Edge / CDN (Fastly)Whole responses for shared, public queries (logged-out front page)Persisted-query hash as cache key (GET-able)
Resolver / per-requestDe-dupes & batches within one queryDataLoader (request-scoped)
Field / entity cacheHot post objects, counts, author cardsmemcached/Redis keyed by entity ID
App / domain cacheRanked feed ID list per userRedis, 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

4.8 Fluency drills

Q. Walk me through exactly what happens when the app issues the homeFeed query.

(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.

Q. Why GraphQL Federation instead of one big GraphQL monolith?

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.

Q. DataLoader batches — but doesn't that just move the N+1 into the database?

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.

Q. Client wants real-time vote counts — GraphQL subscriptions?

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.

Say this out loud

"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."

5
INFRA PRIMER · PART 4

Datastores & scale: pick the right box for the right reason

Pick the datastore by access patternpoint reads by keyRedis / KVhuge write-heavy, wide rowsCassandra (wide-column)transactions / joinsPostgres (relational)large blobs / mediaS3 / object storefull-text / rankingElasticsearch / LuceneReddit: Postgres + memcached for the graph; Cassandra for votes/timelines; S3 for media.
There is no "best" database — match the access pattern. Key lookups → KV; massive append/wide rows → wide-column; relations + transactions → SQL; blobs → object store; text/relevance → a search index. Naming the trade-off (consistency vs throughput vs latency) is the senior signal.
TL;DR

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

StoreModelStrengthsUse at Reddit
Postgres confirmedRelational, ACIDJoins, transactions, strong consistency, matureCore data: users, posts, votes-of-record
memcached confirmedKV cacheSimple, fast, multithreaded LRUCache-aside in front of Postgres
Redis confirmedKV + data structuresSorted sets, counters, pub/sub, Lua, rate-limit stateRankings, counters, rate limiting, queues
Cassandra confirmedWide-column (Dynamo-style)Linear write scale, multi-DC, tunable consistency, no single masterNewer / write-heavy features
RabbitMQ confirmedMessage broker (queues)Async work, routing, retries, DLQsAsync jobs: voting, link submission, notifications
Vector DB / ANN industryEmbedding indexSub-linear nearest-neighbor searchEvaluated 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)
ModelSmart broker, dumb consumer; messages routed & removed on ackDumb broker, smart consumer; records retained & replayable by offset
Best forTask queues, RPC-ish jobs, per-message routing, retries/DLQHigh-throughput event streams, replay, many consumers, stream processing
At RedditAsync jobs: votes, link submission, notifications dispatchEngagement firehose, real-time features, safety stream
ThroughputHigh, but not log-scaleTens of millions msgs/sec (Reddit)
One-liner that signals you get it
"Votes go to a RabbitMQ job queue because each vote is a discrete task to process-and-forget with retries; the engagement firehose goes to Kafka because we need replay and many independent consumers. Different jobs, different tools."

5.5 CDC, sharding, replication, CAP

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)
IdeaNavigable small-world graph; greedy descent to neighborsCluster space into cells (IVF); compress vectors (PQ)
Recall/latencyExcellent recall, very low latencyTunable; PQ compresses memory at some recall cost
MemoryHigh (stores full graph + vectors in RAM)Low (quantized) — fits far more vectors per node
Use whenLatency-critical, corpus fits in RAMHuge 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

TopologyHowConsistencyUse
Single-region + read replicasOne primary; followers in-region for read scaleStrong on primary; replica lag on followersDefault for relational truth (Postgres)
Active-passive (DR)One active region; standby promoted on failoverStrong; failover has RPO/RTODisaster recovery for the system of record
Active-active (AP store)All regions writable; async cross-region replicationEventual; conflict resolution (LWW / CRDT)Cassandra-style high-availability data
Geo-partitionedEach region owns a data shardStrong within home regionData-locality / residency needs

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.

Q. When do you reach for Cassandra over sharded Postgres?

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.

Q. Strong vs eventual consistency — give the concrete Reddit call.

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.

Q. R + W > N — explain it like I'm the interviewer.

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.

Say this out loud

"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."

6
INFRA PRIMER · PART 5

Model serving & ML infra

Offline: train & registerdatawarehousetrain+ evalregistryversionedOnline: low-latency servingrequestuser + contextfeature storeonline (ms)model serverGPU/CPU, batchedprediction cachededup hot itemsdeploy ↓Precompute what you can (embeddings, candidate lists); serve the heavy model behind a feature store + cache.Guard training-serving skew: the SAME feature code/values offline and online.
Two worlds joined by a model registry and a feature store: offline you train, evaluate and version; online you fetch features in milliseconds, run the model (request-batched), and cache hot predictions. The #1 bug is training–serving skew — identical feature logic on both sides.
TL;DR

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 runsItem/user embeddings, ANN index build, candidate precompute, propensity scoresThe ranker over ~500 candidates; light re-rank
CadenceHourly / dailyPer request, p99 budget (~20–40ms for ranking)
WhyAmortize heavy compute; item embeddings change slowlyNeeds 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

p99, not the mean
Always reason in tail latency. A 30ms-mean ranker with a 300ms p99 produces visible jank for 1% of scrolls — millions of bad sessions. Tail comes from GC pauses, cold caches, hot keys, oversized batches, slow ANN probes. Budget and monitor p99 (and p99.9) per stage.

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.

Q. Where does the ranker get its features at request time without blowing the budget?

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).

Q. Latency-budget a feed request stage by stage.

~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.

Q. Online learning vs daily batch retraining — which and why?

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.

Q. CPU or GPU for serving — and is it ever neither?

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."

Say this out loud

"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.)

ComponentWhat it isWhat Reddit uses it forThe number / the catch
Kafka confirmedDistributed, replayable commit logEngagement firehose, ML event transport, CDC sink, stream source500+ brokers, tens of millions msgs/sec; RF=3; ~4 GB/s → ~130–256 partitions; days of retention
Flink Stateful Functions confirmedStateful stream processor (actor-style)Real-time features, velocity/trending, content-safety rule evalRuns Lua for hot-swappable safety rules; event-time + watermarks + RocksDB checkpoints
RabbitMQ confirmedWork-queue broker (per-msg ack)Votes, link submission, notification dispatchProcess-and-forget tasks with retries/DLQ; not log-scale, not replayable
Postgres + memcached confirmedRelational truth + read cacheAccounts, posts, votes-of-record; cache-aside hot rows/fragmentsSingle primary ≈ few-k writes/sec → the 70k-peak vote firehose must go async; watch stampede
Cassandra confirmedWide-column, leaderlessWrite-heavy / newer features, denormalized read-modelsR+W>N for strong consistency per query; watch wide partitions + tombstones
Redis confirmedIn-memory KV + data structuresOnline feature cache, sorted-set rankings, counters, rate-limit stateFeed-ID cache ≈ 20M×1KB ≈ 20 GB; feature reads ≈ 10M/sec via multi-get; TTL'd
GraphQL Federation confirmedComposed API over per-domain subgraphsOne supergraph the client queries; ML service = a subgraphPer-subgraph timeouts + nullable fields → partial-response degradation; DataLoader kills N+1
Go Domain Graph Services confirmedThe subgraph services themselvesPosts / Users / Communities / Comments / Ads ownershipAdd ML-derived fields (pCTR, safety score) without forking the API
Fastly confirmedCDN / edgeStatic assets, cacheable + logged-out responses, media bytesCache the shell, personalize the payload; persisted-query hash = cache key
K8s on AWS confirmedOrchestration (Spinnaker/Drone/Terraform)Stateless services + inference pods, autoscale on lag/QPSDedicated node pools for GPU inference; HPA/KEDA; provision for peak p99
Debezium CDC reportedlyChange-data-capture off the WALStreams committed DB changes into Kafka → search/cache/read-modelsSay "CDC, reportedly Debezium"; fixes the dual-write problem; consumers still idempotent
Hot formula confirmedTime-decayed log scoreFront-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 confirmedLower bound of a proportion CIComment "best" sortConfidence-aware: a 5/0 can beat 100/40 on thin evidence
4-stage notif confirmedMulti-stage recommenderNotificationsBudget(causal) → two-tower retrieve → multi-task DNN → business rerank
Vector DB / ANN evalEmbedding 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.

LayerFailure modeSymptomMitigation
KafkaHot partition (celebrity AMA keyed to one post_id)Consumer lag spikes on one partition, others idleSalt the key for hot entities; route to dedicated consumer; only for commutative aggregates
KafkaRebalance stormGroup repeatedly pauses, lag growsCooperative rebalancing, static membership, shrink max.poll.records
FlinkState bloat / checkpoint stallRecovery lag, backpressureTTL state, bound key cardinality, RocksDB + incremental checkpoints
RedisEviction of live ML features under memory pressureSilent quality drop (no error)Separate ML cache from rate-limit cache; size for working set; monitor hit rate
CassandraWide partition (megathread under one key)Multi-MB partition, slow reads, GCTime-bucket the partition key; cap partition size
GraphQLSubgraph timeout cascadeSlow ads subgraph blocks whole feed queryPer-subgraph timeouts + partial response (render feed without ads)
ServingTail latency (GC, cold cache, oversized batch)p99 ≫ mean → visible jank for 1% of scrollsBudget p99 per stage; tune max_queue_delay; warm caches; degrade to Hot ordering
Feature pipelineFeature silently goes 30% nullModel quality decays, no alertPer-feature null/range/PSI monitors + per-feature kill switch + version pinning
7
WORKED DESIGN · 1 · MARQUEE

Design the Reddit Home Feed (end-to-end)

Multi-stage ranking — the funnel behind the feedcandidategenerationtwo-tower ANN +follows + trending~10⁴ items12 msfilteringseen / blocked /integrity~10³ items5 msheavyrankingDLRM / MMoEmany features~500 items40 msre-rankdiversity · dedup ·policy · ads blend~25 items8 mscounts (per request)latency budget →Cheap recall first (narrow billions → thousands), expensive precision last (score hundreds). Total ≈ 65 ms.
Every large feed is a cascade: a cheap, high-recall retrieval stage narrows billions of posts to thousands; progressively more expensive rankers score fewer items; a final policy pass adds diversity, dedup and ads. You spend the compute budget where it changes the top results.

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)

Candidate generation (multi-source) subscribed subs + recommended subs + ANN over embeddings → ~thousands Ranking — multi-task DNN P(upvote), P(comment), P(dwell), P(hide) → ~hundreds scored Re-ranking diversity + freshness + integrity + business rules ~25 shown
Retrieval → ranking → re-ranking. Each layer discards more, spends more per survivor.

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.

8
WORKED DESIGN · 2

Comment ranking & the nested comment tree

TL;DR

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$).

SortFormula / ideaBehavior
BestWilson LB on up-fractionQuality with confidence; default
Topnet score, no decayAll-time popularity
NewrecencyLatest first
Controversialhigh volume, near-equal up/downSurfaces fights
Q&AOP/answer-awareFor 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.

Scale note
Megathreads (100k+ comments) are the stress case: never load the whole tree. Page lazily, cache rendered top-of-thread fragments in memcached, and compute Wilson scores incrementally on vote events (Redis sorted set per post, updated by the vote worker).
9
WORKED DESIGN · 3

Notifications / push recommendation

Notifications / push as a 4-stage funneltriggernew reply, mention,hot post in your subcandidate genwho could getwhich notificationranking +volume controlp(open) · per-userdaily budget · fatiguesend-timewhen is this userlikely activeThe objective is not "max clicks" — it is long-term engagement minus unsubscribes. Volume control andsend-time optimization protect the user from fatigue; a holdout group measures true incremental value.
Push is a ranking problem with a budget: generate candidate notifications, rank by p(open / valuable), then apply volume control (per-user caps, fatigue) and send-time optimization. Optimize long-term retention, not next-click — and keep a holdout to prove lift.
TL;DR

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

1 Budgetingcausal model · daily caps 2 Retrievaltwo-tower embeddings 3 Rankingmulti-task DNN: click/up/comment 4 Rerankbusiness logic · boost subscribed
CONFIRMED Reddit 4-stage notification pipeline (source: ByteByteGo).

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.

Q. Why two-tower here specifically (and not for the home feed)?

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

10
WORKED DESIGN · 4

Subreddit / community recommendations

TL;DR

"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

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).

11
WORKED DESIGN · 5

Search & ranking

TL;DR

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)
MatchesExact terms, proper nouns, rare tokens, operatorsSemantics, synonyms, paraphrase
Fails onVocabulary mismatch ("car" vs "automobile")Exact rare strings, typos of names
InfraLucene/Solr/ElasticsearchVector 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

Cross-encoder vs bi-encoder
Bi-encoder (two-tower) for retrieval (precomputable, scales). Cross-encoder (query+doc jointly through a transformer) for top-K re-ranking (far more accurate, far slower → only on ~50–100 candidates). Naming this split is a strong signal.
12
WORKED DESIGN · 6

Content safety: spam & toxicity at scale

TL;DR

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

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
REFERENCE

Reddit stack cheat-sheet + "things that make you look senior"

13.1 Reddit confirmed stack (one-glance)

LayerTechNote
Relational truthPostgres confirmedUsers, posts, votes-of-record
Cachememcached + Redis confirmedmemcached in front of Postgres; Redis for structures/counters
Wide-columnCassandra confirmedNewer / write-heavy features
StreamingKafka confirmed500+ brokers, tens of millions msgs/sec
Stream compute / safetyFlink Stateful Functions + Lua rules confirmedContent safety/moderation
Async jobsRabbitMQ confirmedVoting, link submission, notifications
APIGraphQL Federation, Go subgraphs (DGS) confirmedDomain Graph Services
EdgeFastly CDN confirmedStatic + cacheable responses
PlatformKubernetes on AWS; Spinnaker / Drone CI / Terraform confirmedDeploy / CI / IaC
CDCDebezium reportedlyHedge — one source
Vector / ANNEvaluated Milvus / FAISS / Vertex Vector Search / Solr ANN evalRetrieval embeddings

13.2 Confirmed Reddit ranking formulas

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.

QuantityEstimateArchitectural consequence
DAU~100MEverything downstream multiplies from here
Feed requests~1.5B/day → ~17k QPS avg, ~50–70k peakDesign for peak; cache aggressively
Candidates ranked/req~500 → ~25–35M scorings/sec peakGPU-batch the ranker; ~50–70 GPUs w/ redundancy (ch6.5)
Vote firehose~2B/day → ~23k avg, ~70k peak10–20× a Postgres primary → async via RabbitMQ (ch5.9)
Engagement firehosetens of M/sec → ~4 GB/s, ~350 TB/day rawKafka, ~130–256 partitions, 500+ brokers, days retention (ch2.10)
Feed cache (IDs only)~20M active × ~1 KB ≈ ~20 GBCache IDs not payloads → a few Redis nodes (ch3.7)
Online feature reads50k QPS × ~200 feats ≈ ~10M reads/secBatched multi-get, sharded online store (ch18.2)
Feed latency budgetp99 ≈ 150ms (edge 5 + ctx 10 + cand 15 + feat 12 + rank 30 + rerank 5)Per-stage budgets that sum under p99; degrade if over
The chain to say in one breath
"100M DAU → ~1.5B feed reqs/day → ~50–70k peak QPS → ×500 candidates → tens of millions of scorings/sec → GPU-batched ranker + cheap ANN retrieval; the vote firehose at ~70k peak is 10–20× a Postgres primary so it goes async; the engagement firehose at tens of millions/sec is ~4 GB/s, which is exactly why Kafka runs hundreds of partitions on 500+ brokers." Each number forces a decision — that's the whole point of the math.

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")

13.7 Likely prompts (drill each with the 8-step script)

  1. Design Reddit's home feed (ch7 — the marquee).
  2. Design comment ranking / "best" sort + nested threads (ch8).
  3. Design the notification / push system (ch9 — confirmed 4-stage).
  4. Design subreddit / community recommendations (ch10, cold-start).
  5. Design Reddit search (ch11 primer; ch16 full 8-step — hybrid retrieval + LTR + cross-encoder).
  6. Design spam / toxicity detection at scale (ch12 primer; ch14 full 8-step — Kafka+Flink+Lua + human-in-loop + adversarial).
  7. Design ads ranking / targeting (ch15 — auction + pCTR/pCVR + pacing + calibration).
  8. #18 Video recommendation (ch17 — lead with data flow / logging / observability; cross-link q18).
  9. #19 Feature store (ch18 — point-in-time joins, versioning/tests, CI/CD, rollout/rollback, sub-10ms serving; cross-link q19).
  10. "Design Instagram Reels" / "a Reddit clone" (Exponent-reported) — same funnel, video twist (reuse ch17).
  11. Design trending / r/popular (streaming velocity, hot keys, fan-out).
  12. "What tooling to prototype a feature, and how do you scale it?" (Exponent-reported — narrate prototype → Kafka/feature-store/serving scale-up).
  13. Reddit Answers / RAG over Reddit (ch20 — the Staff-MLE-offer team: hybrid retrieval + rerank + grounded/cited generation + faithfulness eval).
  14. ML case study (modeling-only) — ad-click / dwell-time modeling (ch21 — run the modeling checklist, NOT the 8-step system-design script).
  15. 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.

Interview-loop reminder
Reddit ML loop ≈ 4 technical (ML fundamentals Q&A, coding, ML system design, project deep-dive) + 2 behavioral; Glassdoor senior-MLE difficulty ~2.7/5; ~21-day loop. Phone screens reward shipping end-to-end working systems over fancy modeling. Tie answers to values: "Make Something People Love," "Remember the Human," "Default Open," "Evolve," mission = "bring community, belonging, and empowerment to everyone in the world."
Accuracy guardrails (don't contradict these)
Course-Schedule / topological-sort is a core CS pattern and worth knowing, but it is not a confirmed Reddit question — never frame it as "Reddit asks this." Two-tower is confirmed only for the notification retrieval stage, not the home feed. Debezium for CDC is reportedly used (single source — hedge). Confirmed Reddit coding asks: Tennis Scoring OOP, LRU Cache, API Rate Limiter (token bucket / sliding window), Comment Threading Model, Hit Counter, Word Ladder, Autocomplete (Trie), Design a CDN, Notification dispatcher.
14
WORKED DESIGN · 7 · FULL 8-STEP

Design Reddit content safety / spam & toxicity at scale

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.
Say this out loud

"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."

Q. Why Lua rules and a model — isn't the model strictly better?

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.

Q. How do you set the auto-remove threshold?

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.

Q. The classifier degrades as attackers adapt — how do you stay ahead?

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."

Q. Human reviewers are the bottleneck. How do you allocate them?

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.

15
WORKED DESIGN · 8 · FULL 8-STEP

Design Reddit ads ranking / targeting

Ads ranking: rank by expected value, then auctionad candidatestargeting matchpCTR modelcalibratedpCVR / qualityeCPM = bid ×pCTR (× quality)auction (GSP)charge 2nd priceCalibrated probabilities are essential — eCPM mixes model output with real dollars, so a mis-calibratedpCTR directly mis-prices bids. Blend ads into organic by comparing eCPM against an organic value floor.
Ads rank by expected value, not raw relevance: $\text{eCPM}=\text{bid}\times p(\text{click})$ (× quality), then a second-price (GSP) auction sets the charge. Because probabilities are multiplied by money, calibration matters more here than almost anywhere else.

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.
Say this out loud

"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."

Q. Why does calibration matter for ads but barely for organic feed?

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.

Q. Explain second-price (GSP) and why not first-price.

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.)

Q. Conversions arrive days later — how do you train pCVR?

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.

Q. How do you stop ads from cannibalizing the user experience?

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.

16
ADS DEEP-DIVE · 1 · FROM ZERO

Ads, from zero — the mental model & glossary

TL;DR

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.

The one diagram that organizes everything

Ads is a funnel that ends in an auction. Keep this whole picture in your head; every sub-topic hangs off one box.

The ad request → impression pipeline (≈ tens of ms)ad requestuser opensfeed~10⁶ eligibletargeting +retrievalmatch audience,budget, eligibility~10³ adspCTR / pCVRpredict click &conversion probscore eachbid × pCTR= eCPMrank by expectedrevenuerankauction2nd-price +reservepick winnersallocation +pacingblend w/ organic,spend control1–3 adsFunnel narrows ~10⁶ → a few ads. Cheap eligibility/targeting first; expensive pCTR/pCVR on the survivors; the auction decides who pays what.
An ad serving pipeline is a cascade like organic ranking, but the last stages are an economic mechanism: predict click and conversion probabilities, convert each advertiser's bid into an expected value to the platform (eCPM), run an auction, and pace spend against budgets.
Glossary

The vocabulary — learn these 25 terms and you can hold the conversation

TermMeans
Impressionone 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.
Bidthe max an advertiser will pay for the event they're buying (a click, an impression, a conversion).
CPC / CPM / CPA / oCPMpricing 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).
eCPMeffective 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 scorewhat the auction sorts by. Core form: bid × pCTR (+ quality/relevance terms). Highest wins.
Auctionthe mechanism that picks winners and sets prices from everyone's bids. Runs per ad request, in <~10ms.
First-price / Second-pricewinner pays their own bid (first) vs. just enough to beat the runner-up (second / GSP).
GSP / VCGGeneralized Second Price (the classic, simple) vs. Vickrey-Clarke-Groves (truthful, charges your "externality" — what Meta/Google moved toward).
Reserve / floor pricethe minimum eCPM to win at all — protects the platform from selling impressions too cheap and protects users from low-quality ads.
Quality / relevance scorea multiplier that lets a relevant ad win over a higher bid — keeps the user experience and the marketplace healthy.
Budget & pacingan advertiser's daily/lifetime cap, and the controller that spends it smoothly over time instead of all at 9am.
Targeting / audiencewho the advertiser wants to reach (demographics, interests, custom/lookalike audiences, retargeting).
Bid shadingin a first-price auction, automatically lowering your bid to "just enough to win" so you don't overpay.
Attributiondeciding which ad/touchpoint gets credit for a conversion (last-click, multi-touch, data-driven).
Incrementality / liftthe conversions that happened because of the ad (vs. would-have-happened-anyway). The honest measure of value; measured with holdouts/ghost ads.
CalibrationpCTR=0.1 should mean 10% really click. Essential here because pCTR is multiplied by money.
ROAS / ROIreturn on ad spend — the advertiser's north star. Keeping it high keeps advertisers bidding.
The senior framing
An ads system is a two-sided (really three-sided) marketplace. Junior answers optimize platform revenue. Senior answers optimize the long-run equilibrium: advertiser ROI keeps demand (bids) high, user relevance keeps supply (attention) high, and revenue is what the platform extracts without killing either side. Say "I'm optimizing the marketplace, not this auction" early.
How ads differs from organic ranking — the four things that are genuinely new
  • 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).
17
ADS DEEP-DIVE · 2 · THE AUCTION

Auctions, bidding & eCPM — how an ad actually gets picked

TL;DR

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.

Generalized second-price (GSP) auction — one slotadvertiserbid $/clickpCTReCPM = bid×pCTR×1000Ad B$68%$480← winnerAd A$85%$400Ad D$46%$240Ad C$102%$200Winner: Ad C (highest eCPM = $200). It does NOT pay its own bid.GSP price = the bid needed to just beat the runner-up: charge per click = eCPM₂ / (pCTR_winner × 1000).Here runner-up eCPM₂ = $480 (Ad B) … winner pays only when the click happens (CPC).
Ads are ranked by eCPM = bid × pCTR × 1000 (expected revenue per thousand impressions), NOT by raw bid — a cheap-but-relevant ad beats an expensive-but-ignored one. In a second-price auction the winner pays just enough to beat the runner-up, which makes bidding your true value the dominant strategy.
Step 1

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.

$$ \text{eCPM} = \text{bid} \times p(\text{billable event}) \times 1000 $$
  • 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.
The intuition to say out loud
"A cheap, highly-relevant ad can beat an expensive, ignored one, because we rank by expected revenue. A $2 bid at 10% CTR (eCPM $200) beats a $10 bid at 1% CTR (eCPM $100). That alignment — relevance is rewarded — is what keeps users from drowning in irrelevant high-bidders."
Step 2

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:

$$ \text{CPC}_{\text{winner}} = \frac{\text{eCPM}_{\text{2nd}}}{p\text{CTR}_{\text{winner}} \times 1000} + \$0.01 $$"pay the smallest CPC that still would have won" — then you're only charged when the click actually happens.

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 in one paragraph (the staff-level extra)

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.

Step 3

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):
$$ \text{AdRank} = \underbrace{\text{bid} \times p\text{CTR}}_{\text{expected revenue}} \;+\; \underbrace{\alpha \cdot \text{Quality}}_{\text{ad relevance}} \;-\; \underbrace{\beta \cdot p(\text{negative})}_{\text{user harm}} $$Meta literally calls a version of this "Total Value" — revenue + advertiser value + user value. The weights are policy levers.
Senior signal
Frame the ranking objective as Total Value = expected revenue + advertiser value + user value, and note the weights are how the platform tunes the long-term/short-term tradeoff. That single sentence separates "I know the eCPM formula" from "I understand the marketplace."
Worked example — do this on the whiteboard

Three CPC ads compete for one slot. Compute eCPM, pick the winner, set the price.

Adbid (CPC)pCTReCPM = bid×pCTR×1000
A$3.004%$120
B$1.5010%$150 ← winner
C$5.002%$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.

18
ADS DEEP-DIVE · 3 · THE ML STACK

The ML models: pCTR, pCVR, and the ads-specific traps

TL;DR

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.

Retrieval

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.

pCTR / pCVR

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.

$$ \text{loss} = \text{BCE}(\text{click}) + \lambda\,\text{BCE}(\text{conversion}) \quad\text{(+ calibration-friendly training)} $$
Trap 1

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.

00.250.50.75100.250.50.751Why pCTR calibration matters for adspredicted CTRactual CTRover-predicting CTR→ eCPM too high → overbid → lose money
In organic ranking only the order matters, so miscalibration is survivable. In ads the probability is multiplied by real dollars (eCPM = bid × pCTR): a pCTR that's 2× too high doubles the eCPM, wins auctions it shouldn't, and mis-charges advertisers. Ads ranking lives or dies on calibration (Platt/isotonic/temperature).
  • 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.
Trap 2

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}$.
Trap 3 & 4

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.
19
ADS DEEP-DIVE · 4 · BUDGETS & MARKETPLACE

Pacing, bid shading, attribution & marketplace health

TL;DR

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.

Pacing

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.

061218240255075100Budget pacing: spend evenly across the dayhour of day% of daily budget spentpaced (target)un-paced (early burnout)budget gone by 11am → miss evening users
A daily budget must be paced or it burns out by mid-morning and misses the rest of the day. A pacing controller throttles participation — lower the ad's effective bid or skip a fraction of auctions — to track an even (or traffic-shaped) spend curve. It's a feedback loop: spending too fast → throttle harder.
  • 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.
The dual / theory bonus
Pacing is the online solution to a budget-constrained allocation problem; the pacing multiplier is essentially the Lagrange dual variable on the budget constraint ("how much to discount this advertiser's bid so they don't blow the budget"). Drop that line if pushed — it shows you see the optimization underneath.
Auto-bid

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

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.
Health

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.
20
ADS DEEP-DIVE · 5 · THE WORKED DESIGN

"Design Reddit's ads recommendation system" — full 8-step walkthrough

TL;DR

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.

Step 1

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.
Step 2

Define metrics — three sides, explicitly

SideMetric
Platformrevenue, eCPM, fill rate, auction depth/competition
AdvertiserROAS, CPA vs target, conversion volume, incremental conversions (lift)
Userad relevance, hide/negative-feedback rate, session length & retention (guardrails), ad load
ModelpCTR/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.

Steps 3–4

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.

Step 5

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.

Step 6

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).

Step 7

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.
Step 8

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.
The 60-second opening to memorize

"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.

Ads design-round Q-bank — the follow-ups they push
Q. Why rank by eCPM and not the highest bid?

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.

Q. Walk me through second-price pricing. Why is truthful bidding optimal?

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.)

Q. Why does calibration matter so much more for ads than for organic ranking?

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.

Q. Conversions land days after the click. How do you train pCVR?

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.

Q. How do you stop a campaign from blowing its whole budget at 9am?

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.

Q. Revenue went up in your A/B test. Ship it?

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.

Q. How do you know the ads were actually responsible for the conversions?

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.

Q. New advertiser, brand-new creative, no click history. What do you serve?

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.

Q. First-price or second-price — which would you build, and why?

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.

Background-match note (for your loop)

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."

21
WORKED DESIGN · 9 · FULL 8-STEP

Design Reddit search ranking (retrieval + LTR)

Search = retrieval then learning-to-rankquery+ user contextRETRIEVAL (high recall)inverted indexBM25 / termsembeddingANN (semantic)candidate docs ~1000LTR rankerfeatures: text match,(GBDT / neural)freshness, authorityresultsNDCG-tunedLexical + semantic recall first; then a learned ranker optimizes NDCG using query–doc + behavioral features.
Search mirrors the feed: a retrieval stage (lexical BM25 ∪ semantic ANN) maximises recall into ~1000 candidates, then a learning-to-rank model optimises a ranking metric (NDCG/MRR) from query–document and behavioral features.

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.
Say this out loud

"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."

Q. Why hybrid retrieval — isn't a good dense model enough?

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.

Q. Bi-encoder vs cross-encoder — when each?

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.

Q. What is position bias and how do you correct it?

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.

Q. How do you keep the index fresh without rebuilding it?

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.

22
REAL QUESTION · STAFF ML-SD (1Point3Acres) + darkinterview #18

Design watch-next for the Reddit mobile video browser — the data-flow / logging / observability prompt

Watch-next: the data-flow / logging / observability promptclientimpressions, plays, dwell, scrubsKafkaevent logFlink (realtime)windowed featureswarehouse (raw)immutable logsonline storeserve features (ms)training joinfeatures as-of impression timeLog the full context you ranked with (candidate set, features, position) — you cannot reconstruct it later.Watch dwell-time distributions and position bias; alert on feature drift and logging gaps, not just model AUC.
This prompt is really about data flow and observability: emit rich client events → Kafka → realtime features (Flink) and immutable raw logs → warehouse. Log what you ranked with at request time (candidates, features, positions) so training data is reconstructable and bias is measurable.
This is the verbatim Staff ML-system-design prompt
Per 1Point3Acres, the 2026 Staff ML system-design round was literally "design a watch-next recommender for Reddit's mobile-app video browser." And the recurring report — across that source and darkinterview's #18 Video Recommendation — is that the interviewers grill logging, observability, and data-flow far harder than the model: event collection, how training data is logged, how features are logged and served, online/offline parity, and the feedback loop. So build the answer around the data path and present the ranker as one component, not the centerpiece. Cross-link: reddit-real-questions ch=q18
60-second opening (say this before drawing anything)

"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)

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

Say this out loud

"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."

Q. How exactly does a watch-time signal become a model feature and a training label?

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.

Q. How do you know your logging itself is correct?

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.

Q. Why optimize watch-time over clicks, and what's the failure mode of watch-time?

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.

Q. Mobile clients drop and delay events. How does that not corrupt your features?

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.

Mobile videobrowser + SDKplay/pause/seek/quartile Fastly CDNvideo bytes (≠ ranking) GraphQL routervideoFeed resolver Kafka topicsvideo.impressionvideo.playbackvideo.feedbackkey=user · RF=3 · 3-7d Flink (RT)watch-time/velocityevent-time watermarks Lake (S3/parquet)point-in-time joins Online feat storeRedis · sub-10ms Traininglabel=satisfying watch Recommendation funnel (ONE component) candidateANN+sub+trend multi-taskranker (GPU) re-rankdiv/fresh/safe log features at request time query feedback loop: served impressions + watch outcomes → tomorrow's labels
Data path is the spine; the funnel is one orange box. Video bytes go via Fastly entirely independent of the ranking path — a point worth making out loud.

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.

Q. The interviewer keeps pushing on "how is training data actually logged?" — give the precise flow.

(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.

Q. How do you detect that the feedback loop has gone bad (filter bubble / ossification)?

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).

Q. Autoplay changes the label. How?

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.

Q. Watch-time prediction is regression, not classification — what changes in serving?

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.

23
REAL QUESTION · darkinterview #19

#19 Feature Store — the production-engineering answer

Feature store: write paths vs read pathsWRITE (materialise)batch (Spark)daily / hourlystreaming (Flink)secondsoffline storewarehouse / parquetonline storeRedis / KV (ms)READtraining: point-in-time joinfeatures as-of event timeserving: online lookuplow-latency get(entity)same feature definitions →no training-serving skewPoint-in-time correctness prevents label leakage; offline/online parity prevents training-serving skew.
A feature store solves two correctness bugs at once. Point-in-time joins build training rows from feature values as they were at event time (no future leakage); offline/online parity (one definition, two materialisations) kills training–serving skew.
This question is about production discipline, not ML
Reports say interviewers push the production side: training-serving consistency, point-in-time joins, feature versioning + tests, CI/CD for feature pipelines, staged rollout + rollback, online caching for sub-10ms serving, backfills, and reliability/observability. Treat it as a data-platform / infra design, not a modeling question. Cross-link: reddit-real-questions ch=q19

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

Say this out loud

"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."

Q. Explain a point-in-time join concretely.

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.

Q. A feature's distribution shifts. What protects you?

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.

Q. Why not just compute features inline in the serving code?

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.

Q. How do you add a new feature without breaking live models?

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.

24
SIGNAL · SR / STAFF

What reads as senior/staff (the cross-cutting moves)

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."

The unifying meta-move
Every one of these is the same instinct: assume something will fail or drift, and design the system to stay correct, available, affordable, and aligned with the long-term objective anyway. Junior answers describe the happy path; senior answers narrate the failure modes and the guardrails. If you only remember one thing: turn every number into a decision, and every component into "...and here's how it degrades when it breaks."
25
WORKED DESIGN · 10 · FULL 8-STEP · THE LIKELY TEAM

Design Reddit Answers (RAG over Reddit)

RAG over Reddit (Reddit Answers)queryuser questionembedquery vectorretrieveANN over post/comment chunksrerankcross-encodertop-kgenerateLLM + promptwith contextanswer+ citationsGrounding the LLM in retrieved Reddit content cuts hallucination and lets you cite sources. Quality livesin retrieval (chunking + embeddings + rerank); evaluate faithfulness/groundedness, not just fluency.
Retrieval-augmented generation: embed the query, retrieve relevant Reddit chunks via ANN, rerank, then have the LLM answer grounded in that context with citations. Most of the quality (and most of the bugs) live in retrieval and chunking, not the model.
Why this chapter exists — and why to over-prepare it
Per 1Point3Acres, the 2026 Staff MLE offer went to "Reddit Answers" — Reddit's RAG product that answers a user's question by retrieving and synthesizing across real Reddit threads, with citations back to the posts/comments. If you're interviewing for that team (or anything adjacent), "design a RAG system over Reddit content" is the single most likely deep-dive. It is a retrieval-system at heart (your strength) plus a generation + grounding + faithfulness layer (the new muscle). Treat the LLM as one stage; the win is in retrieval quality, grounding/citations, freshness, safety, and faithfulness eval.
60-second opening

"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.
Say this out loud

"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."

Q. The single biggest failure mode of RAG is hallucination. How do you actually prevent it here?

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.

Q. How do you chunk threaded Reddit content for retrieval?

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.

Q. Why hybrid retrieval and a cross-encoder rerank instead of just dense ANN?

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.

Q. Content gets removed or edited after you indexed it. How do you not cite stale/removed content?

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.

Q. How do you evaluate faithfulness at scale without humans rating every answer?

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.

Q. A question has no good Reddit answer (or only toxic threads). What should the system do?

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.

26
FORMAT · ML CASE STUDY (MODELING-ONLY) · 1Point3Acres

The ML case-study round: modeling-only (ad-click + dwell-time worked)

A different round with different rules
Per 1Point3Acres, Reddit's staff "ML case study" round is modeling-only — e.g. ad-click modeling — with almost no system building. Do not run the 8-step system-design script here. No Kafka, no QPS, no GraphQL. The interviewer wants to see how you think about a model: problem framing, label definition, features, model family + why, calibration, evaluation, the online experiment, and failure modes. Drawing a Kafka topic in this round is a tell that you don't know which round you're in.

21.1 How the case study differs from system design

ML system design (ch7–ch20)ML case study (this chapter)
Center of gravityArchitecture: retrieval/serving/streaming/infraThe model: label, features, loss, eval, experiment
You drawBoxes: Kafka, feature store, ranker, cachesAlmost nothing — maybe a feature table or a calibration curve
Scale mathQPS, storage, GPU fleetData volume only insofar as it affects modeling (sparsity, imbalance)
The barDoes it scale, degrade, stay correct?Is the modeling sound: right label, no leakage, calibrated, properly evaluated, failure-aware?
TrapHand-waving the infraJumping 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)

  1. Problem framing: what decision does this model drive, and what does a good prediction change? Classification vs regression vs ranking. Online or batch.
  2. 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.)
  3. Data & sampling: class imbalance, negative sampling, time-based splits (never random when there's temporal leakage), train/val/test by time.
  4. 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?).
  5. Model family + why: name candidates (GBDT vs DNN vs logistic), pick one, and justify with the data shape (sparsity, cardinality, nonlinearity, interpretability, latency).
  6. Loss & calibration: the training objective, and whether predicted probabilities must be numerically right (calibration) or just well-ordered.
  7. Evaluation / metrics: offline (AUC/PR-AUC/log-loss/calibration) tied to what matters; the online experiment (primary metric + guardrails) that decides ship/no-ship.
  8. Failure modes: bias, drift, feedback loops, leakage, cold start, miscalibration — name them and the mitigation.
The opening move that scores
"Before I pick a model, let me nail the label and the metric — because the model family follows from those, and most modeling mistakes are a bad label or the wrong metric, not the wrong algorithm." Saying this first signals you've actually shipped models, not just trained them on clean Kaggle data.

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.
Say this out loud (ad-click)

"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.
Say this out loud (dwell-time)

"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."

Q. (Modeling round) Why is calibration the thing you keep mentioning for ads but not for the organic feed?

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.

Q. (Modeling round) You downsampled negatives. Doesn't that bias your model?

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.

Q. (Modeling round) GBDT or deep model — defend your pick.

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.

Q. (Modeling round) How do you make sure there's no label leakage?

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.

27
REAL QUESTION · CODING/DESIGN HYBRID (1Point3Acres VO)

Design a subreddit chatroom (compact)

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.
Say this out loud

"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."

Q. Why WebSockets and not polling or SSE?

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.

Q. How does a message from one user reach a user connected to a different server?

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.

28
RAPID PREP · OPENINGS

The 10 most-likely Reddit ML-SD prompts + a 60-second opening for each

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)

60s opening

"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)

60s opening

"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)

60s opening

"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)

60s opening

"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)

60s opening

"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)

60s opening

"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)

60s opening

"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)

60s opening

"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)

60s opening

"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)

60s opening

"'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."

Honorable mentions to have a one-liner for
Trending / r/popular (streaming velocity, hot keys, fan-out — reuse ch3 hot-key + ch2 streaming); subreddit chatroom (real-time messaging, ch22); "prototype a feature then scale it" (narrate prototype → Kafka/feature-store/serving scale-up, Exponent-reported); Instagram Reels / Reddit clone (same funnel, video twist — reuse ch17). For any prompt not listed: fall back to the 8-step script (ch1) and you'll never be lost.
29
DRILLS · FOLLOW-UPS

Rapid-fire follow-up drills (the questions after the design)

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)

Q. Your logging pipeline drops 5% of events silently. How would you even know?

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.

Q. How do you log training data so it's not biased by what the model already showed?

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.

Q. Online feature value and offline training value disagree. Walk me through debugging it.

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

Q. The GPU ranker fleet is at capacity during a traffic spike. What happens to the feed?

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.

Q. Kafka is down for 10 minutes. What breaks and what survives?

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.

Q. A feature silently goes 30% null after an upstream schema change. What catches it?

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

Q. Offline NDCG is up 3% but the A/B shows flat engagement and a higher hide rate. Ship?

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.

Q. Your recommender keeps showing the same popular content; engagement is flat. Diagnosis?

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.

Q. (RAG) Retrieval recall@20 is 0.9 but answers are still often wrong. Where do you look?

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

Q. Finance says your ML serving bill is too high. What are your top levers?

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.

Q. Estimate the online feature-store read load for the feed and size it.

~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.

30
REFERENCE · DIAGRAMS

Two more reusable architecture diagrams

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).

25.1 The RAG serving path (Reddit Answers)

question+ conv. history queryunderstandingrewrite/expand BM25 sparseinverted idx dense ANNvector idx (HNSW) RRF fusetop ~100 cross-encreranktop ~10-20 contextbuild+ source IDs LLM gencite-constrainedstream tokens groundingverify (NLI)drop unsupported safety gatepolicy/PII/removed answer+ cited threads semantic cache (Q→A) · KV/prefix cache index kept fresh via CDC/Kafka(new threads retrievable in minutes) generator = ONE stage
RAG path: retrieve (hybrid) → rerank → context → generate (cited) → verify grounding → safety → cited answer. Caches and CDC-freshness underneath. The LLM is one orange box of eight stages.

25.2 The generic ML-platform data plane (underneath every recommender)

ONLINE (request path) clientrequest + events GraphQLrouter/resolver candidate genANN + sorted sets ranker (GPU)dynamic batch re-rank+ integrity gate response online feat storeRedis · sub-10ms STREAM (seconds-fresh) Kafkakey=user · RF=3 Flinkwindows + watermarks BATCH (hours-fresh) Lake (S3)point-in-time joins Spark / trainingoffline features offline feat storehistory (as-of) model registryversioned artifacts shadow→canary→kill features @ request events → Kafka deploy model
The shape under every recommender: online request path (top) reads the online feature store; the stream path (Kafka→Flink) writes seconds-fresh features; the batch path (lake→Spark) writes hours-fresh features and trains; one feature definition feeds both stores; models flow registry→shadow→canary. Swap the ranker box for a generator and it's RAG; swap it for a 4-stage pipeline and it's notifications.
Why one data-plane fits them all
Home feed, watch-next, notifications, ads, search, and Reddit Answers are the same data plane with a different middle box. Once you can draw 25.2 from memory, every prompt becomes "what goes in the orange box, what's the label, and where does it degrade?" — which is exactly the altitude the Staff bar rewards.