Design an ad click aggregation 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
Count ad clicks per ad and per time window from billions of events a day, accurately enough to bill advertisers, with results available within minutes.
Requirements
We are building the pipeline that turns raw ad clicks into per-ad, per-minute click counts that advertisers are billed on and that dashboards show within a few minutes. A click only counts if it can be tied back to the ad impression that was actually served.
Functional requirements
- Ingest click events from web and app clients via a click redirect service, and impression (ad served) events from the ad server.
- Join each click to its impression by
impression_id; a click with no matching impression within the attribution window (e.g. 1 hour) is not billable. - Count clicks per ad per 1-minute bucket, rolled up to hour and day, sliced by a small fixed set of dimensions (campaign, advertiser, country, device type).
- Advertiser dashboard: "clicks for ad X (or campaign Y) between T1 and T2, by minute/hour/day".
- Correct counts when events arrive late, are duplicated, or must be replayed after a bug.
- Hook for invalid-traffic filtering: clicks flagged as bots are counted separately, not billed.
Non-functional requirements
- Billing accuracy: each valid click counted exactly once in the final numbers. Dashboards may be a little behind but never double-count.
- Freshness: p95 under 2 minutes from click to dashboard; final (billing-grade) numbers within a few hours.
- Durability: no click lost once acknowledged; raw events retained so any window can be recomputed.
- Scale: billions of events a day, skewed (one viral ad can take 1% of all clicks).
- Reads are cheap and fast: dashboard queries under 200 ms p99.
Back-of-the-envelope estimation
- 10B impressions/day, 1B clicks/day (10% of the impression volume is a generous CTR to size for). Average 12K clicks/s, peak 5× ≈ 60K clicks/s; impressions ≈ 115K/s average, 600K/s peak.
- Event size ≈ 300 B. Clicks: 1B × 300 B = 300 GB/day raw. Impressions: 3 TB/day. Kafka at 3-day retention with replication factor 3 ≈ 30 TB.
- Join state: impressions kept for a 1-hour window at peak ≈ 600K/s × 3,600 × ~100 B (id + ad + dims only) ≈ 216 GB, spread across the join workers' local RocksDB stores.
- Output: 10M active ads, but only ~2M get a click in a given hour. Per-minute rows ≈ 2M ads × 60 × few dimension combos ≈ 300M rows/hour before rollup; minute rows kept 7 days, hourly kept 2 years.
API design
Producers publish to Kafka, not to an HTTP API, so events survive any downstream outage.
topic impressions key=impression_id
{impression_id, ad_id, campaign_id, advertiser_id, user_id_hash, country, device, served_at}
topic clicks key=impression_id
{click_id, impression_id, ad_id, user_id_hash, ip_hash, clicked_at, received_at}
The click redirect service (GET /c?imp=...&ad=...&sig=...) verifies the HMAC signature, writes the click to Kafka (acks=all), then 302-redirects to the landing page. It assigns click_id deterministically as hash(impression_id, clicked_at second) so a client retry produces the same id.
Dashboard read API:
GET /v1/ads/{ad_id}/clicks?from=2026-10-12T00:00Z&to=2026-10-12T06:00Z&granularity=minute|hour|day&group_by=country
200 {
"ad_id": "ad_123",
"series": [{"bucket": "2026-10-12T00:00Z", "country": "US", "clicks": 1840, "invalid": 12}],
"watermark": "2026-10-12T05:58Z", // buckets before this are complete for streaming
"final_through": "2026-10-12T03:00Z" // buckets before this are billing-final
}
GET /v1/campaigns/{campaign_id}/clicks?... same shape, summed across ads
Reads are idempotent GETs; a request outside retention returns 400 range_out_of_retention. The response always carries watermark and final_through, so the dashboard can grey out partial buckets instead of showing a dip.
Operator controls (internal):
POST /v1/admin/backfill {from, to, reason, source: "raw_archive"} -> {job_id}
GET /v1/admin/pipeline/health -> {consumer_lag_s, watermark_lag_s, late_events_last_hour, join_miss_rate}
Why Kafka rather than a plain queue: we need ordered, partitioned, retained logs that several consumers (stream job, raw archiver, fraud scoring) read independently, and that we can rewind by offset to replay. A delete-on-ack queue gives none of that.
Data model
-
Kafka topics
impressions,clicks,attributed_clicks: the durable source of truth for the last 3 days, partitioned byimpression_idso a click and its impression land in the same partition number (co-partitioned join). -
Raw archive (S3 / object store, Parquet) partitioned by
event_date/hour: every event forever (or per legal retention). This is what backfill and reconciliation read. -
Join state (Flink keyed state on RocksDB):
impression_id -> {ad_id, campaign_id, advertiser_id, country, device, served_at}, TTL 1 hour + allowed lateness. Plus a dedupe setclick_id -> seen, TTL 24 hours. - Aggregates (OLAP store: ClickHouse / Druid / Pinot):
| Table | Key / sort order | Columns | Partition | Retention |
|---|---|---|---|---|
ad_clicks_minute |
(ad_id, bucket_start, country, device) | clicks, invalid_clicks, version | day | 7 days |
ad_clicks_hour |
(ad_id, bucket_start, country, device) | clicks, invalid_clicks | month | 2 years |
campaign_clicks_hour |
(campaign_id, bucket_start) | clicks, invalid_clicks | month | 2 years |
billing_clicks_day |
(advertiser_id, ad_id, day) | billable_clicks, run_id | month | 7 years |
Rows are written with a replacing/versioned engine (ClickHouse ReplacingMergeTree(version) keyed on the full key), so re-emitting a bucket overwrites instead of adding. An OLAP store is chosen because every query is "filter on ad or campaign, range on time, sum"; columnar storage sorted by (ad_id, bucket_start) answers that from a few granules.
-
Ad metadata (MySQL/Postgres):
ads(ad_id PK, campaign_id, advertiser_id, created_at), used for dimension lookups and auth on the read API.
High-level design
flowchart LR
AS[Ad server] --> KI[Kafka impressions]
U[User clicks ad] --> CR[Click redirect service]
CR --> KC[Kafka clicks]
KI --> J[Flink join and dedupe]
KC --> J
J --> KA[Kafka attributed clicks]
KA --> AG[Flink windowed aggregator]
AG --> OL[(OLAP store)]
KI --> AR[(Raw archive S3)]
KC --> AR
AR --> B[Batch recompute job]
B --> OL
B --> BL[(Billing tables)]
OL --> Q[Query service]
Q --> D[Advertiser dashboard]
KC --> F[Invalid traffic scorer]
F --> J
-
Serve: the ad server picks an ad, mints
impression_id, and publishes an impression event. The click URL embeds a signedimpression_id. -
Click: the redirect service validates the signature, publishes the click keyed by
impression_id, and redirects. Publishing is on the request path, so Kafka is sized for peak with acks=all and a small local disk buffer if the broker is slow. -
Join before counting: a Flink job reads both topics keyed by
impression_id. It stores impressions in keyed state; when a click arrives it looks up the impression, drops it ifclick_idwas already seen, attaches the ad's dimensions, and emits an attributed click. A click that beats its impression waits in state up to 10 minutes. -
Aggregate: a second Flink job keys attributed clicks by
(ad_id, salt)and counts them in 1-minute event-time tumbling windows. It emits when the watermark passes the window end, then emits corrected rows for late events until allowed lateness expires. - Serve reads: the query service reads minute rows for ranges under 2 days, hourly rows beyond that, and caches closed buckets (they rarely change) for 60 s.
- Reconcile: an hourly batch job recomputes every bucket from the raw archive with the same join and dedupe logic. Its output is the billing-final number and overwrites the streaming rows for that hour.
Deep dives
Stream join correctness and late arrivals. Event time, not processing time, decides the bucket: a click at 10:00:59 that arrives at 10:03 counts in the 10:00 minute. Watermark = max seen clicked_at minus 30 s (covers p99 network delay). Window results fire at the watermark (fresh dashboards) and again for each late event up to 1 hour of allowed lateness, each time re-emitting the whole bucket with a higher version, which the replacing table keeps. Events later than 1 hour go to a late_clicks side topic, are counted in a metric, and are picked up by the hourly batch reconcile, so billing still includes them. Joins: the impression state TTL is the attribution window (1 hour). Clicks that arrive before their impression are buffered 10 minutes; if no impression shows up they go to an unattributed topic, not the count. Watch join_miss_rate: a jump means the impression producer is broken, not that users stopped clicking.
Replay-safe counts and explicit dedupe. Duplicates come from three places: client retries (same click_id, handled by the deterministic id), producer retries (Kafka idempotent producer), and consumer restarts (handled by Flink checkpoints + transactional sinks). The dedupe set in Flink state holds click_id for 24 hours: 1B ids/day × ~40 B ≈ 40 GB across workers, fine in RocksDB. A Bloom filter in front would cut memory by ~10× but gives false positives that silently drop real billable clicks, so we keep an exact set. Sinks must be idempotent: aggregates are written as full bucket values with a version, never as +1 increments, so a replay that rewrites the same bucket is harmless. Billing tables are written by run, each row carrying run_id; a rerun replaces the partition atomically.
| Approach | Exactly once? | Cost |
|---|---|---|
| Increment counters in the store | No, retries double-count | Cheapest |
| Flink exactly-once + Kafka transactions | Yes inside the pipeline | Checkpoint latency (~30 s) |
| Overwrite bucket rows by key + version | Yes at the sink, replay-safe | Store must support upsert/replace |
| Hourly batch recompute from raw | Yes, ground truth | Hours of delay, compute cost |
We use the last three together: streaming for freshness, batch for the billing number.
Hot-ad skew and partitioned rollups. A Super Bowl ad can take 50K clicks/s, more than one Flink subtask (~20K events/s with state access) can handle. Joins are keyed by impression_id, which is spread evenly, so skew only hurts the aggregation stage. There, key by (ad_id, salt) with salt = hash(click_id) % N, where N = 1 for normal ads and up to 16 for ads on a hot list (refreshed every minute from click-rate metrics). Each salted shard pre-aggregates its minute bucket; a small second stage sums the N partials per (ad_id, minute) before writing. Because shards emit full partial counts (not deltas), the merge stays idempotent. Kafka partitioning of attributed_clicks uses the same salted key so one broker partition doesn't become the hot spot.
Backfill, dimension changes and cardinality. Every count must be recomputable from raw: the batch job (Spark over the Parquet archive) uses the same join/dedupe library as the Flink job, so the two paths can't drift. Backfill is the incident response: if a bug miscounted 10:00–14:00, fix the code, run POST /admin/backfill for that range, and it rewrites those partitions with a new run_id; the dashboard shows the new numbers once the swap commits. Dimensions are a closed list reviewed like a schema change. Each new dimension multiplies row count: country (200) × device (5) is 1,000 combos per ad-minute at worst, but adding campaign changes nothing (it is a function of ad_id, so it lives in metadata and in its own rollup table), while adding something like placement_id or raw user agent would explode it. Rule: dimensions that are 1:1 with ad go to lookup tables; new free-form dimensions only get hourly rollups, behind a cap on distinct values (top 100 plus other).
Invalid traffic without building a fraud platform. A scorer reads the clicks topic and emits {click_id, verdict} for simple rules: same ip_hash clicking the same ad more than 5 times in 10 minutes, known data-center IP ranges, click-to-impression gap under 200 ms. The join job holds each click briefly (it is already waiting on the watermark) and tags it invalid if a verdict arrives. Invalid clicks are counted in their own column, so a later, better fraud model can reclassify them in the batch job without touching the pipeline.
Trade-offs
- Lambda (stream + batch) over stream-only: two code paths to run, and the dashboard number can change once the batch result lands. In return billing comes from a recompute over immutable raw data, which survives any streaming bug. A shared library keeps logic identical across both.
- Join before counting: needs ~200 GB of impression state and adds up to 10 minutes of wait for out-of-order clicks; counting raw clicks would be simpler but would bill clicks for ads that were never served or were spoofed.
- Exact dedupe set over a Bloom filter: about 10× more memory, but no billable click is ever dropped by a false positive.
- 1-hour allowed lateness: bounded state and early finality versus a small tail of late clicks that only the batch job counts.
- Fixed dimension list: advertisers can't slice by arbitrary attributes in real time; in return row counts and query cost stay predictable.
-
Overwrite-by-version sink: requires an OLAP engine with replace semantics and background merges (reads may briefly see two versions unless they use
FINAL/argMax); increments would be simpler but are not replay-safe. - Per-region pipelines (when expanding): each region aggregates its own clicks and the global view sums them at query time; cross-region joins are avoided by keeping impression and click in the region that served the ad, at the cost of a little lost attribution when users roam mid-session.
Interviewer expectations
Mid-level (L4)
- Puts a durable log (Kafka) between clients and counting, and counts per ad per minute in a stream processor.
- Stores pre-aggregated rows in a store suited to time-range queries rather than counting raw events at read time.
- Knows duplicates and late events exist and proposes a click id for dedupe.
Senior (L5)
- Uses event time with watermarks and allowed lateness, and explains what happens to an event after the cutoff.
- Makes sinks idempotent (overwrite buckets, not increments) and keeps raw events for batch reconciliation that produces the billing number.
- Joins clicks to impressions before counting, handles click-before-impression ordering, and fixes hot ads with salted keys and a two-stage rollup.
Staff (L6+)
- Frames replay and backfill as the main recovery tool, with one shared join/dedupe library for stream and batch so they can't diverge.
- Exposes freshness (
watermark,final_through) and pipeline health in the API so consumers know which numbers are final. - Governs dimensions and cardinality, leaves a clean hook for invalid-traffic scoring, and has a plan for multi-region expansion.