Design problems
Platform & infrastructureHardSDE-2 35 min

Design ad click aggregation

Asked at
AmazonFlipkartGoogleInMobiMetaShareChat

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

Signed click URLs and a fast 302 redirectKafka as durable click logDedup by click_id with keyed stateTumbling event-time windows, watermarks, allowed latenessFlink checkpoints for exactly-once stateIdempotent upserts or transactional sinks; effectively-once end to endTwo-stage aggregation for hot keysInvalid traffic filtering before billingBatch recompute from raw data as the number of recordBudget pacing with bounded overspend

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

Click QPS~11.5k/s average, 50k/s peak1B ÷ 86,400 ≈ 11,574/s; evening and festive peaks ~4-5×
Raw click storage~200 GB/day, ~150 TB over 2 years1B × ~200 bytes (click_id, ad_id, user/device hash, ip, ua hash, ts, placement); compressed Parquet is ~4-5× smaller
Aggregate rows~200M rows/day worst case5M ads × 1,440 minutes = 7.2B possible, but only ads clicked in a given minute produce a row; ~140k distinct ads clicked per minute × 1,440 ≈ 200M rows
Kafka partitions~6450k/s peak, ~1-2k/s per Flink subtask after dedup state lookups, plus headroom for replay at 5× speed
Dedup state~10-20 GBClick ids for a 1-hour dedup window: up to 180M at peak × ~64 bytes in RocksDB with overhead; a Bloom filter variant is smaller but approximate
Invalid traffic~5-15% of clicksTypical range of bots and duplicates on open ad networks; must be removed before billing

3 API

EndpointWhat 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=minuteClick and spend time series for an advertiser's ad
GET/v1/campaigns/{id}/spendCurrent real-time spend vs daily budget
GET/v1/top-ads?window=5m&n=100&country=INTop-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 topicKafka
click_id (UUID from signed URL + nonce)ad_idcampaign_idimpression_idts (event time)ipdevice_hashuser_agent_hashcountryplacement

Keyed 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 verdict

Immutable source of truth for billing recompute and audits.

ad_minute_aggClickHouse / Pinot / Druid
ad_idminutecountrydevicevalid_clicksinvalid_clicksspend_micros

Upserted by (ad_id, minute, dims) with absolute values. Sorted by ad_id, minute for dashboard queries.

campaign_spendRedis + Postgres
campaign_iddayspend_microsbudget_microspacing_state

Redis for fast reads by ad servers; periodic checkpoint to Postgres.

billing_dailyPostgres / warehouse
advertiser_idcampaign_idad_iddaybillable_clicksamountrecon_status

Produced by the batch job from S3; this is what invoices use.

5 Architecture

CLIENTSEDGESERVICESASYNCDATAEXTERNALGET /clog click302raw sinkminute upsertsspend deltaspacing stateread hour/daybillable clickscorrect aggregatesAdvertiser dashboardUser clicks adClick redirect serviceReporting APIAd server (pacing)Flink: dedup, IVT,windowBatch recompute(Spark)Kafka: clicksOLAP (minuteaggregates)Redis: campaign spendBilling DBS3 raw clicksAdvertiser landingpage
ClientEdge / gatewayService / workerQueue / streamCacheDatabase / storageAI / modelThird partyrequestasync (event / queue)

Hover a box to highlight its connections.

6 Request flows

A click is counted

  1. 1Ad server rendered the ad with a click URL containing ad_id, campaign_id, impression_id, timestamp and an HMAC signature
  2. 2User taps; the redirect service verifies the signature and expiry (forged or replayed URLs are logged as invalid)
  3. 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
  4. 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)
  5. 5Valid clicks are added to the (ad_id, minute) tumbling event-time window; early results are emitted every 10 s
  6. 6Window results are upserted into the OLAP store with absolute counts; spend deltas update the campaign's Redis counter
  7. 7Advertiser dashboard sees the minute's clicks within about a minute

Campaign hits its daily budget

  1. 1Flink adds each valid click's price to campaign spend in Redis (INCRBY with idempotent per-window bookkeeping)
  2. 2When spend crosses ~95% of budget, pacing state becomes throttle; at 100% it becomes stop
  3. 3Ad servers refresh pacing state every few seconds from Redis (or receive it by pub/sub) and stop bidding with that campaign
  4. 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

  1. 1After the day closes plus a 2-hour lateness buffer, Spark reads the day's raw clicks from S3
  2. 2It applies the same dedup and invalid-traffic rules (plus slower, smarter ML fraud models) and computes billable clicks per ad
  3. 3Results are compared with the real-time aggregates; differences above 0.1% raise an alert
  4. 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

Q1How would you extend this to impressions at 50× the volume?
Q2An advertiser disputes last Tuesday's bill. How do you prove the count?
Q3How do you deploy a new fraud rule and apply it retroactively?
Q4How would you support arbitrary new dimensions such as app version?
Q5How do you keep pacing fair across regions if ad servers run in three regions?
Q6How would you attribute conversions to clicks?

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.

Start free