Design a top-K popular items system
Remix this answer and make it yours, or start blank. Either way you work beside the reference, one chapter at a time, and it stays private until you publish it.
Problem statement
Report the K most viewed or played items over sliding windows (last minute, hour, day) from a firehose of billions of events per day.
Requirements
We are building the top-K service for a food delivery marketplace: from a firehose of menu-item views and orders (billions of events a day), it reports the K most viewed or ordered items per restaurant, per city, and globally over sliding windows of the last minute, hour, and day, and it feeds the "Popular items" shelf on every restaurant's menu page.
Functional requirements
-
topK(scope, metric, window, k): scope is a restaurant, a city, or global; metric isviewsororders; window is1m,1h, or1d; k up to 100. - The restaurant menu page shows a shelf of the top 5–10 items, enriched with name, photo, price, and availability.
- Items that are deleted, sold out, or hidden are never shown, even if they are still in the counts.
- Late events (mobile clients offline, retries) and corrections (fraud, refunds, test orders) are applied, and the ranking changes to match.
- Backfill: when scoring rules change (for example, orders weigh 5× views), recompute history.
Non-functional requirements
- Shelf read p99 under 20 ms; it is on the menu page critical path.
- Freshness:
1mand1hlists lag at most 10–30 s;1dlists and the shelf may lag up to 15 min. - Ingest at 5× average peak with no data loss; at-least-once intake with dedup by event ID.
- Approximate is acceptable for global and city lists (rank errors near the cutoff only); per-restaurant shelves must be exact over the daily batch.
- A failed pipeline must never empty a shelf: serve the last good snapshot with its
as_oftime.
Back-of-the-envelope estimation
- 5B events/day (90% views, 10% orders) → ~58K events/s average, ~250K/s peak at dinner.
- Event ≈ 150 bytes → ~750 GB/day raw, ~22 TB/month in the lake (compressed Parquet ~5×: ~4.5 TB/month).
- 1M restaurants × ~120 items = 120M items. In any minute, maybe 3M distinct items get an event.
- Per-minute bucket for all active items: 3M × (8 B id + 4 B count) ≈ 36 MB. Keeping 60 minute buckets ≈ 2 GB; 24 hourly buckets for the day window ≈ 24 × 20M active items × 12 B ≈ 6 GB. Fits in memory across a few stream workers.
- Shelf reads: 400M menu views/day → ~5K/s average, ~25K/s peak. Snapshot per restaurant ≈ 10 items × 40 B = 400 B → 1M × 400 B = 400 MB total. One KV cluster, or even a CDN-cached file per city.
API design
Public read (menu page, called by the menu service, not clients directly):
GET /v1/restaurants/{id}/popular-items?k=8&metric=orders&window=1d
→ 200 {
"restaurant_id": "r_812",
"as_of": "2026-10-12T18:15:00Z",
"window": "1d", "metric": "orders",
"items": [
{"item_id": "i_33", "rank": 1, "score": 412, "name": "Spicy Tuna Roll",
"price_cents": 1299, "photo_url": "...", "available": true}
],
"source": "batch" | "stream" | "fallback"
}
Analytics read (internal dashboards, trending modules):
GET /v1/topk?scope=city:sf&metric=views&window=1h&k=50
→ 200 {"as_of": "...", "approximate": true, "items": [{"item_id", "score", "error_bound"}]}
Ingest (from the event gateway, not a public API): ItemEvent {event_id, item_id, restaurant_id, city_id, type: view|order, ts_event, ts_received, user_id_hash} on a Kafka topic.
Internal control plane:
-
POST /internal/topk/rebuild {scope: "restaurant:r_812" | "all", from, to, scoring_version}→202 {job_id}. It recomputes snapshots from the lake and swaps them in atomically. -
POST /internal/topk/corrections {event_ids: [...], reason}voids events (fraud, refunds). They drop out at the next incremental run.
Trust and abuse: reads need a service token (mTLS between services); the public menu page never sends a raw k larger than 20. Views are deduplicated per (user_id_hash, item_id, 10 min) and counted only from signed-in or device-attested sessions, so a script reloading a page cannot push an item onto the shelf. source and as_of tell callers how much to trust the list.
Data model
| Store | Key / layout | Why |
|---|---|---|
Kafka item-events
|
partition by restaurant_id, 7-day retention |
Ordering per restaurant, replay for stream recovery |
| Data lake (Parquet on S3) |
events/dt=YYYY-MM-DD/hour=HH/, columns above |
Source of truth for batch, backfills, audits |
item_counts_hourly (lake/warehouse) |
(dt, hour, restaurant_id, item_id, metric) → count |
Rollup that the daily and backfill jobs read instead of raw events |
voided_events |
event_id PK, reason, voided_at
|
Corrections applied as an anti-join |
| Snapshot KV (Redis or DynamoDB) |
shelf:{restaurant_id}:{metric}:{window} → JSON list + as_of + version
|
Point reads at 25K/s, 400 MB total |
| Stream state (Flink RocksDB) | (scope, item_id) → ring of 60 minute counts |
Sliding 1m/1h windows |
| Hot list KV |
topk:{scope}:{metric}:{window} → sorted list of 100 |
Global/city lists, rewritten every 10 s |
| Menu service DB | menu_items(item_id PK, restaurant_id, name, price, photo, status, updated_at) |
Owns item truth; top-K only stores IDs |
Snapshots store item IDs and scores only. Name, price, and availability come from the menu service at read time, so a renamed or sold-out item is right on the next page view.
High-level design
flowchart LR
APP[Apps and web] --> GW[Event gateway]
GW --> K[(Kafka item events)]
K --> FL[Flink stream topK]
K --> SINK[Lake sink]
SINK --> LAKE[(S3 Parquet lake)]
LAKE --> BATCH[Spark rollup and rank]
VOID[(Voided events)] --> BATCH
BATCH --> SNAP[(Snapshot KV)]
FL --> HOT[(Hot list KV)]
FL -. "fresh shelf patches" .-> SNAP
MENU[Menu page service] --> TK[TopK read API]
TK --> SNAP
TK --> HOT
TK --> MS[Menu items service]
-
Ingest. Clients send view and order events to the gateway, which validates them, assigns
event_idif missing, and writes to Kafka keyed byrestaurant_id. -
Authoritative batch (the shelf). A lake sink writes hourly Parquet. Every 15 min, a Spark job folds new hours into
item_counts_hourly, anti-joinsvoided_events, sums the last 24 hours per restaurant, ranks, and writes a new snapshot version per restaurant. It flips a pointer only after all writes for the run succeed. - Streaming (minute and hour windows). Flink keeps per-item minute buckets, emits per-scope top-K every 10 s to the hot list KV, and optionally patches shelves for restaurants with heavy traffic.
- Read. The menu service calls the top-K API, which reads one snapshot key, batch-fetches the item details from the menu service (cached 60 s), drops unavailable items, backfills from ranks 9–20 to fill the shelf, and returns it.
- Corrections and backfills. Voids and scoring changes trigger an incremental rerun of affected hours, or a full rebuild that writes a new snapshot version and swaps it in.
-
Menu item lifecycle. The top-K pipeline never owns names or prices. Deleted items are filtered at read time and pruned at the next run; a re-created item with a new ID starts at zero, so merges of duplicate items are a menu-service concern, mapped through an
item_aliasestable in the rollup. -
Cold start and sparse history. New restaurants or those with fewer than 20 orders in 24 h lack a stable signal; widen to 7 or 28 days, then fall back to the merchant's own "featured" flag. Smooth scores (
orders + α·views, Bayesian prior from the cuisine) so one order does not crown an item. - Time zones. "Last day" is a rolling 24 h in UTC for ranking; daily reports use the restaurant's local day, computed from the same hourly rollups. Start single-region, with the lake and batch in one region and snapshot KV replicated read-only to others; the shelf tolerates minutes of lag.
Deep dives
Windowed rollups and late corrections. Count in tumbling buckets and build sliding windows by summing buckets: 1m is the current minute bucket plus a weighted share of the previous one; 1h is 60 minute buckets; 1d is 24 hour buckets. This makes a slide an O(1) subtract-and-add, not a rescan. Late data: Flink uses event time with a 2-minute watermark; events later than that go to a side output and land in the lake anyway, so the next batch run counts them. The batch keys hourly rollups by event hour, so a late event changes one old hour, and only that hour's partition is recomputed. Voids are applied as an anti-join, never by decrementing in place, so a rerun gives the same answer.
INSERT OVERWRITE item_counts_hourly PARTITION (dt, hour)
SELECT restaurant_id, item_id,
SUM(CASE WHEN type='order' THEN 1 ELSE 0 END) AS orders,
COUNT(DISTINCT CASE WHEN type='view' THEN user_id_hash END) AS viewers,
dt, hour
FROM events e LEFT ANTI JOIN voided_events v ON e.event_id = v.event_id
WHERE (dt, hour) IN (:dirty_hours)
GROUP BY restaurant_id, item_id, dt, hour;
-- then: SUM over last 24 hours, ROW_NUMBER() OVER (PARTITION BY restaurant_id ORDER BY score DESC) <= 20
Snapshot serving contract and freshness. Each run writes shelf:{r}:...:v{run_id}, then sets shelf:{r}:current = v{run_id}. Readers see a whole run or the previous one, never a mix. Rules: if a run fails, the pointer stays put and as_of shows the age; an alert fires at 45 min stale; past 24 h stale, the API returns source: fallback (top items by all-time orders, a small static list per restaurant). Store 20 ranked items, not K, so the read path can drop unavailable items and still fill 8 slots without a recompute. Item details are joined at read time with a 60 s cache; ranking and enrichment fail independently, and either failure yields a degraded shelf, not an empty one.
Hot-restaurant skew and incremental recompute. Partitioning by restaurant_id puts a viral restaurant (100× normal traffic during a promo) on one Kafka partition and one Flink task. Fixes: pre-aggregate in the gateway or a first Flink stage keyed by (restaurant_id, item_id, salt 0–15) over 1 s, then merge per restaurant. That cuts the downstream rate by ~100× for hot keys. For batch, recompute only restaurants with events in dirty hours: on a typical 15-min run that is ~300K of 1M restaurants, and a run takes ~3 min instead of ~20 for all of them. For global and city lists, exact counting of 20M active items is affordable per hour, but the 1m global list uses a Count-Min Sketch (width 2^16, depth 4 ≈ 1 MB, error ≈ 0.004% of the stream) plus a min-heap of size 1,000 per worker, merged into the top 100.
| Approach | Accuracy | Memory per scope | Where used |
|---|---|---|---|
| Exact hash map + sort | Exact | O(distinct items) | Per-restaurant shelf (≤ 120 items) |
| Count-Min Sketch + heap | Overcounts, bounded | ~1 MB fixed | Global/city 1m, 1h
|
| Space-Saving (Misra–Gries) | Bounded error, top items reliable | O(capacity) | Alternative to CMS; easy to merge |
| Batch SQL over rollups | Exact, with corrections | Disk | Daily shelf, backfills, audit |
When batch is not enough. A 15-min batch is fine for "popular this week" shelves, but not for "trending right now" or a dish that sells out at 7 pm. Add streaming where staleness costs money: the 1m/1h lists, and shelf patches for the ~1% of restaurants that get over 1K events/min, which Flink overwrites between batch runs, tagged source: stream. Streaming results are provisional: the next batch snapshot replaces them, so stream bugs heal within 15 min and the stream path can stay simple (no exactly-once sinks, idempotent overwrites only).
Backfills after scoring changes or disputes. Scoring is versioned (scoring_version in the snapshot). A change, such as weighting orders 5× views or excluding group orders, runs the hourly rollup over the last 30 days of lake data (~22 TB raw, ~1 h on a 200-core cluster), writes snapshots under a new version, compares rank overlap against the current version on a sample, and then swaps the pointer. A restaurant disputing a shelf (fake views by a competitor) gets events voided, followed by rebuild for that one restaurant, which takes seconds.
Trade-offs
- Batch as the source of truth gives exact, correctable, replayable shelves, but they lag by 15 min; streaming covers freshness only where it pays, and as a result there are two code paths to keep consistent.
- Count-Min Sketch keeps global windows in fixed memory but overcounts rare items; exact counting is used wherever the key space is small enough to afford it.
- Storing 20 IDs and enriching at read time keeps availability correct, but adds a dependency on the menu service in the read path (mitigated by a cache and by degrading to IDs with cached names).
- Event-time windows with a 2-min watermark trade a short delay for correct minute buckets; anything later is fixed only by batch.
- Snapshot versioning with pointer swaps doubles storage briefly in exchange for atomic, rollback-able publishes.
- Salting hot keys adds a merge stage and a second of latency, but stops one viral restaurant from stalling a partition.
Interviewer expectations
Mid-level (L4)
- Clarifies windows, K, scope, and exact vs approximate before designing.
- Proposes counting per item into time buckets and a heap or sort to get the top K; shows a Kafka-to-counts-to-cache pipeline.
- Gets the throughput estimate right and puts a cache in front of reads.
Senior (L5)
- Separates the cheap read path (precomputed snapshots) from the compute path, and explains sliding windows as sums of buckets.
- Handles late events with watermarks plus batch reconciliation, applies voids idempotently, and filters unavailable items at read time.
- Knows Count-Min Sketch or Space-Saving and when exact counting is affordable; addresses hot-partition skew.
Staff (L6+)
- Argues that batch is the authoritative baseline and adds streaming only where freshness pays, with stream results healing on the next batch.
- Defines the serving contract: atomic snapshot swaps,
as_of, staleness alerts, fallbacks, and never an empty shelf. - Plans backfills under versioned scoring, abuse resistance on views, cold start, and the boundary with the menu service, and estimates the cost of each recompute.