Design ad click aggregation
The interviewer asks
“Design the system that counts ad clicks for an ad network. Advertisers pay per click, so counts drive billing and budget limits. We get about a billion clicks a day; advertisers want click counts per ad per minute within a minute or so, the ad server needs to stop showing ads whose budget is spent, and finance needs numbers that are exactly right.”
What a strong answer looks like
A strong answer starts at the click itself: a redirect service validates a signed click URL, logs the click with a unique click_id to Kafka, and 302s the user to the landing page in a few milliseconds. A stream processor (Flink) deduplicates by click_id, filters invalid traffic, and aggregates clicks per ad per one-minute event-time window with watermarks for late data, writing results to an OLAP store for advertiser dashboards and to a spend counter that feeds budget pacing in the ad server. Raw clicks also land in S3, and an hourly/daily batch job recomputes the same aggregates as the number of record for billing, with any difference reconciled. The candidate should explain how exactly-once state in Flink plus idempotent upserts in the sink give effectively-once counts, how hot ads are handled, and why billing never trusts the real-time path alone.
Concepts to mention
1 Requirements
Functional
- Record every ad click and redirect the user to the advertiser's landing page
- Aggregate clicks per ad (and per campaign, advertiser, country, device) per minute
- Advertiser dashboard: clicks and spend for the last N minutes, hours, days
- Top-N most clicked ads in the last M minutes for internal monitoring
- Real-time spend per campaign so the ad server stops serving when the daily budget is reached
- Filter invalid clicks (bots, duplicates, click farms) before billing
Non-functional
- Redirect latency p99 under 50 ms; clicks must never be lost once logged
- Scale: 1B clicks/day, peak 50k clicks/s; 5M active ads
- Dashboard freshness: aggregates visible within ~1-2 minutes
- Billing accuracy: exact, reproducible from raw data; real-time numbers within 0.1% of final
- Budget overspend bounded to under ~1% of the campaign's daily budget
- Raw clicks retained 2+ years for audits and disputes
Out of scope (say it out loud)
- Ad selection, auctions and ranking
- Impression and conversion tracking (same pipeline shape, larger volume)
- Advertiser invoicing and payments
2 Estimates
3 API
| Endpoint | What it does |
|---|---|
GET/c?d={signed_click_data} | Click redirect: verify signature, log click, 302 to landing page |
GET/v1/ads/{ad_id}/clicks?from=&to=&granularity=minute | Click and spend time series for an advertiser's ad |
GET/v1/campaigns/{id}/spend | Current real-time spend vs daily budget |
GET/v1/top-ads?window=5m&n=100&country=IN | Top-N ads by clicks for monitoring |
gRPCBudgetService.GetPacing(campaign_ids) | Ad server reads pacing state (serve / throttle / stop) per campaign, cached locally |
4 Data model
clicks topicKafkaclick_id (UUID from signed URL + nonce)ad_idcampaign_idimpression_idts (event time)ipdevice_hashuser_agent_hashcountryplacementKeyed by click_id for even spread (ad_id would create hot partitions). Replication factor 3, 7-day retention.
raw clicksS3 (Parquet, partitioned by date/hour)all click fields + validity verdictImmutable source of truth for billing recompute and audits.
ad_minute_aggClickHouse / Pinot / Druidad_idminutecountrydevicevalid_clicksinvalid_clicksspend_microsUpserted by (ad_id, minute, dims) with absolute values. Sorted by ad_id, minute for dashboard queries.
campaign_spendRedis + Postgrescampaign_iddayspend_microsbudget_microspacing_stateRedis for fast reads by ad servers; periodic checkpoint to Postgres.
billing_dailyPostgres / warehouseadvertiser_idcampaign_idad_iddaybillable_clicksamountrecon_statusProduced by the batch job from S3; this is what invoices use.
5 Architecture
Hover a box to highlight its connections.
6 Request flows
A click is counted
- 1Ad server rendered the ad with a click URL containing ad_id, campaign_id, impression_id, timestamp and an HMAC signature
- 2User taps; the redirect service verifies the signature and expiry (forged or replayed URLs are logged as invalid)
- 3It produces the click to Kafka (acks=all, async with a small in-memory buffer and local disk spill if Kafka is slow) and returns 302 to the landing page in ~5-10 ms
- 4Flink dedups on click_id (and on impression_id: one billable click per impression), scores validity with rules (IP/device rate, known bot lists, datacenter IPs)
- 5Valid clicks are added to the (ad_id, minute) tumbling event-time window; early results are emitted every 10 s
- 6Window results are upserted into the OLAP store with absolute counts; spend deltas update the campaign's Redis counter
- 7Advertiser dashboard sees the minute's clicks within about a minute
Campaign hits its daily budget
- 1Flink adds each valid click's price to campaign spend in Redis (INCRBY with idempotent per-window bookkeeping)
- 2When spend crosses ~95% of budget, pacing state becomes throttle; at 100% it becomes stop
- 3Ad servers refresh pacing state every few seconds from Redis (or receive it by pub/sub) and stop bidding with that campaign
- 4Clicks on ads already shown before the stop still arrive; the small overspend is bounded and written off or capped in billing
Daily billing reconciliation
- 1After the day closes plus a 2-hour lateness buffer, Spark reads the day's raw clicks from S3
- 2It applies the same dedup and invalid-traffic rules (plus slower, smarter ML fraud models) and computes billable clicks per ad
- 3Results are compared with the real-time aggregates; differences above 0.1% raise an alert
- 4Batch results overwrite the OLAP daily rows and are written to the billing DB, which feeds invoices
7 Deep dives
Effectively-once counting
Every layer can duplicate: the browser can follow a click twice, the redirect service can retry a Kafka produce, and Flink replays from its last checkpoint after a crash. Exactly-once is built from parts. Each click gets a unique id at the redirect (derived from the signed impression plus a nonce), and Flink deduplicates on it with keyed state that expires after an hour. Flink's checkpoints snapshot both the Kafka offsets and the operator state (window counts, dedup sets) atomically, so after a failure it rewinds both together and never counts a click twice inside the job. The sink is the last risk: if Flink wrote 'ad 42 +17 clicks' as an increment and then replayed, the database would get +34. So the sink writes absolute values keyed by (ad_id, minute, dims), an idempotent upsert, or uses a transactional two-phase-commit sink (Kafka transactions, read with read_committed). Be precise in the interview: Flink plus Kafka transactions give exactly-once inside the pipeline; with an idempotent sink the end result is effectively-once. Raw clicks in S3 make any count reproducible.
Event time, windows and late clicks
Clicks must be billed to the minute they happened, not the minute Flink processed them, or a 10-minute consumer lag shifts revenue between hours and days. Use tumbling one-minute windows on event time (the signed click timestamp, cross-checked with server receive time to stop clients faking it). A watermark of max event time minus ~30 s decides when a window is complete; Flink emits early results every 10 s for freshness and a final result at the watermark. Mobile clients and network delays still produce late clicks. Allowed lateness of a few minutes lets the window update its result (upserting the new absolute count); clicks later than that go to a side output and are counted by the batch job, which uses the full day's data with a 2-hour buffer. This is why there are two numbers: a fast real-time estimate for dashboards and pacing, and a slower exact one for billing, with automated reconciliation between them.
Hot ads and partitioning
A big brand's IPL campaign can get 20k clicks per second on one ad. If the stream is keyed by ad_id end to end, one Flink subtask and one Kafka partition take all of it and fall behind. Two fixes. Key the Kafka topic by click_id so ingestion spreads evenly; deduplication is keyed by click_id too, so it stays spread. For aggregation, use two-stage aggregation: first aggregate by (ad_id, salt) where salt is a random 0-15, producing partial counts per minute spread over 16 subtasks, then a second stage keyed by ad_id sums the 16 partials. The second stage only sees 16 records per ad per minute, regardless of traffic. Flink's local pre-aggregation (mini-batch) achieves similar effects. The OLAP store is less affected because it receives aggregated rows, but the advertiser dashboard query for a giant campaign should hit pre-rolled campaign-level aggregates instead of summing 200 ad rows per minute.
Invalid traffic and budget pacing
Paying for bot clicks loses advertisers, so filtering is part of the core design. Real-time rules run in Flink: signature valid and not expired, one billable click per impression, per-IP and per-device click rate limits, datacenter IP ranges, known bot user agents, and clicks arriving impossibly fast after the impression. Heavier ML models run in the batch job, which is why billing uses batch output; real-time numbers may slightly over-count and get corrected. Budget pacing needs a different trade-off: it must be fast rather than exact. Flink adds spend to Redis per campaign; ad servers read pacing state every few seconds from a local cache. There is always some overspend from ads already shown and clicks in flight, so start throttling near 95% of budget and pace spending through the day (spend target per hour), not only stop at 100%. Overspend beyond the budget is not billed, so it is our cost; the target is to keep it under ~1%.
8 Trade-offs
Real-time plus batch vs streaming only
A streaming-only (Kappa) pipeline is simpler, but billing benefits from a recompute over complete data with better fraud models and late clicks included. Keeping the batch job as the source of record for money, and streaming for dashboards and pacing, is the standard choice.
Aggregate in the stream vs in the OLAP store
Writing raw clicks to ClickHouse or Pinot and aggregating at query time is flexible, but at 50k/s with 5M ads and many dashboards it is expensive. Aggregating minutes in Flink cuts data ~5× and makes queries trivial, at the cost of fixed dimensions. Keep raw data in S3 for new questions.
Exact dedup state vs Bloom filters
Exact keyed state for an hour of click ids costs ~10-20 GB in RocksDB, which is fine. A Bloom filter is smaller but drops a small fraction of real clicks as false duplicates, which means under-billing. For money, exact state is worth the cost.
Redirect vs client-side beacon
A server redirect guarantees we log before the user leaves, but adds a hop of latency. A beacon (navigator.sendBeacon) is faster for the user but can be lost or blocked. For billable clicks, the redirect is safer; many networks use both.
9 Bottlenecks
Redirect service under spikes
Stateless and horizontally scaled behind the CDN/LB; produce to Kafka asynchronously with batching (linger.ms ~5) and a local disk buffer if Kafka is slow, so latency stays flat.
Flink state and checkpoint size
Dedup and window state grows with traffic. Use RocksDB state backend with incremental checkpoints to S3 and state TTL; scale parallelism to the partition count.
Backfill after an outage
Replaying hours of Kafka data at 5× speed needs headroom; size partitions and parallelism for replay, not only live traffic.
10 Failure handling
Kafka unavailable for a few minutes
Redirect keeps serving 302s and buffers clicks to local disk; a sidecar ships them when Kafka returns. Users are unaffected; dashboards lag.
Flink job crash
Restart from the last checkpoint; offsets and state rewind together, sink upserts are idempotent, so counts stay correct. Pacing state is briefly stale, so ad servers fall back to conservative throttling when data is older than 1 minute.
Real-time and batch disagree
An alert fires when the difference exceeds 0.1%. Batch wins for billing; the team investigates (usually a dedup or late-data bug) using raw data in S3.
11 What impresses
- Separating fast approximate numbers (pacing, dashboards) from exact billing numbers and reconciling them
- Explaining why the sink must write absolute values, not increments
- Keying Kafka by click_id but aggregating with salted two-stage keys
- Designing the click redirect with signatures and local buffering
- Bounding overspend by throttling before 100% of budget
- Using exact dedup state instead of Bloom filters because false positives mean lost revenue
12 Mistakes
- Incrementing a counter in a database per click
- Using processing time for billing windows
- Keying everything by ad_id and ignoring hot ads
- Claiming Kafka gives exactly-once end to end without explaining the sink
- Billing straight from the real-time stream with no recompute
- Ignoring bots and duplicate clicks
13 Follow-ups
Lessons this design uses
Practice questions
- You count ad clicks per campaign per minute from a Kafka stream. Mobile clients on poor networks send clicks up to several minutes late. How do you get correct counts?
- A Flink job counts ad clicks per campaign per minute and writes the counts to a database for billing. After a job restart, some campaigns were billed twice for the same minute. How do you make this pipeline correct?
- An ad platform receives 1 million click and impression events per second, each about 500 bytes. Estimate the Kafka cluster you need.
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.