Design an ad click analytics pipeline
Open in the interactive app: practice, quiz, mock interview →In one minute
- Count by event time, not arrival time. A click that happened at 12:00:58 belongs to the 12:00 minute even if a phone on a train delivers it at 12:04. Windows close on a watermark, not on the wall clock.
- Give every click an identity at the source. The ad server signs an impression token, and the click service derives a click ID from it. Without it, "count each click once" is not even well defined.
- Run two paths that agree eventually. A streaming job produces minute counts within about a minute for dashboards and pacing. A daily batch job over the raw log recomputes the same numbers exactly, and the batch result is what advertisers are billed on.
- Pre-aggregate before you shuffle. Sending every click to one counter per ad creates hot keys. Combine locally, then merge.
- Serve from a columnar OLAP store with rollups. "Clicks per minute for my 40 ads this week" is a range scan over a small, sorted, pre-aggregated table.
Requirements
Functional
- A user clicking an ad is redirected to the advertiser's landing page, and the click is recorded.
- Advertisers query click counts per ad (and per campaign) at minute granularity for recent data and hourly or daily granularity for older data.
- The same click recorded twice, from retries or double taps, counts once.
- Clicks that arrive late are still counted, either in the streaming result or in the reconciled result.
Non-functional
- Redirect latency under about 50 ms at p99, because users are waiting on it.
- Dashboard freshness within about 1 to 2 minutes.
- Billing numbers exact, reconciled within 24 hours.
- No click lost once acknowledged; the system survives the loss of a node or an availability zone without manual repair.
Out of scope: impression counting (same pipeline, larger volume), full click-fraud detection (I leave a hook for it), auctions and ad serving, and attribution of conversions.
Back-of-envelope
- Volume: assume 1 billion clicks per day, a plausible order of magnitude for a large ad network. 1e9 / 86,400 ≈ 11,600 clicks/s on average. Ad traffic is peaky (evenings, holiday sales), so plan for 5× at peak, about 60k clicks/s, and test to 100k.
- Raw storage: IDs, timestamps, IP, user agent hash and flags come to about 200 B per click. 1e9 × 200 B = 200 GB/day raw. Five years is 200 GB × 1,825 ≈ 365 TB, perhaps 100 TB after columnar compression, before replication.
- Ingest bandwidth: 60k × 200 B = 12 MB/s at peak, trivial for Kafka. Partition count is chosen for consumer parallelism, not bandwidth.
- Aggregates: of 2 million active ads, assume about 300k get a click in a given minute. 300k × 1,440 ≈ 430M minute rows/day, about 17 GB/day at 40 B per row. Keep minutes for 30 days (about 0.5 TB), hours for 2 years, days forever.
- Query load: 50k advertisers refreshing dashboards each minute is a few hundred queries per second, each a range scan over pre-aggregated rows.
- Dedup state: a 10-minute horizon at peak holds 60k × 600 = 36M IDs. At about 50 B each with overhead, that is 1.8 GB across the stream job.
API
GET /c/{click_token}
click_token = base64(impression_id, ad_id, campaign_id, served_at, landing_url_id) + HMAC
-> 302 Found, Location: https://advertiser.example/landing?...
GET /v1/advertisers/{advertiser_id}/clicks
?ad_ids=a1,a2&from=2026-10-01T00:00Z&to=2026-10-08T00:00Z&granularity=hour
-> 200 {
"granularity": "hour",
"series": [{"ad_id": "a1", "points": [["2026-10-01T00:00Z", 1834], ...]}],
"freshness": {"final_through": "2026-10-07T00:00Z", "provisional_after": "2026-10-07T00:00Z"}
}The freshness block matters. It tells the dashboard which buckets are reconciled and which are provisional streaming numbers that may still move slightly.
Data model
Kafka topic clicks.raw, partitioned by click_id. That spreads load evenly and sends all copies of one click to one partition, so dedup is local. Retention is 7 days for replays.
Raw archive in object storage, Parquet files under dt=YYYY-MM-DD/hour=HH/. This is the source of truth for the batch reconcile and for audits. The partition key is time because every batch job reads a time range.
OLAP table ad_clicks_minute (ClickHouse, Druid or Pinot):
| Column | Type | Note |
|---|---|---|
| advertiser_id | UInt64 | first sort key, so one advertiser's rows are contiguous |
| ad_id | UInt64 | second sort key |
| window_start | DateTime | time partition by day |
| clicks | UInt64 | summed |
| source | Enum(stream, batch) | batch rows replace stream rows |
The sort key (advertiser_id, ad_id, window_start) makes the common query a few contiguous range reads. Partitioning by day makes dropping old minute data a metadata operation. Hourly and daily tables are materialized from this one.
Ad metadata (ad_id → advertiser_id, campaign_id, landing_url) is small and cached in the click service and the stream job.
High-level design
flowchart LR
U[User] -->|"GET /c/token"| CS["Click service"]
CS -->|302| LP["Landing page"]
CS -->|"produce (acks=all)"| K[("Kafka clicks.raw")]
K --> F["Flink: dedup + event-time windows"]
F -->|"upsert minute counts"| O[("OLAP store")]
F -->|"late events"| LQ[("Kafka clicks.late")]
K --> AR["Archiver"] --> S3[("Object store, Parquet")]
S3 --> B["Daily batch reconcile"]
LQ --> B
B -->|"overwrite day partition"| O
Q["Query service"] --> O
A[Advertiser] --> QWrite path. The user taps an ad. The click service verifies the HMAC on the token, which rejects forged or tampered clicks cheaply. It derives click_id = hash(impression_id), so a double tap or a browser retry on the same impression gives the same ID. It produces the event to Kafka with acks=all and only then returns the 302. If Kafka is slow, it redirects anyway after a short timeout and spools the event to local disk for a sidecar to drain. A lost visitor is worse than a delayed count.
Stream path. Flink reads clicks.raw, drops events whose ID it has already seen within the dedup horizon, assigns each click to its one-minute event-time window, pre-aggregates per task, then re-keys by ad_id and sums. When the watermark passes a window's end, the window fires and Flink upserts (ad_id, window_start, count) into the OLAP store; each late click within the allowed lateness fires it again and the row is overwritten with the new total. Clicks that arrive after the lateness has expired go to a side output topic.
Batch path. Every night a Spark job reads the previous day's Parquet, dedups by click ID across the whole day, applies fraud filters with the full day's context, and writes exact counts that replace the day's streaming rows.
Read path. The query service checks ownership, picks the coarsest rollup table that satisfies the granularity, and caches results for 30 seconds.
Deep dives
1. Counting each click exactly once
There are three places a click can be duplicated: the client retries, the producer retries after a timeout, and the stream job replays after a crash.
- Client duplicates are handled by the deterministic click ID. Without a source-assigned ID there is nothing to deduplicate on; two events with the same ad and the same second could be two real users.
- Producer retries are handled by Kafka's idempotent producer, which tags each batch with a producer ID and sequence number so the broker drops a resent batch. Since Kafka 3.0 it is on by default, together with
acks=all(KIP-679), but a conflicting setting such asacks=1quietly turns it off unless idempotence is set explicitly (Kafka producer configs). It protects only against the producer's own retries, not against a user tapping twice or a process resending after a restart, which is why the click ID still matters. - Replays are handled by the stream job's checkpoints (Carbone et al.). Flink snapshots its state (dedup set, open window counts) together with the Kafka offsets. After a crash it restores both and resumes, so state and input position agree.
The output must also be exactly once. A two-phase-commit transactional sink works but ties output visibility to the checkpoint interval: Uber's ads pipeline commits its Kafka output on 2-minute checkpoints and names that interval as the main latency bottleneck (Uber). I chose an idempotent upsert: each fired window writes its full count to the row keyed by (ad_id, window_start), never an increment, so a replayed write is harmless. The dedup set is keyed state with a TTL of about 10 minutes plus the allowed lateness; older duplicates are rare and the batch path catches them.
"Upsert" means different things in different OLAP stores. Pinot's upsert tables return the latest row per primary key, but only if the input stream is partitioned by that key (Pinot docs). ClickHouse's ReplacingMergeTree removes duplicate rows only during background merges at an unknown time, so a query needs FINAL (or an argMax per key) to avoid counting a window twice (ClickHouse docs). Pick the store with this in mind.
2. Event time, watermarks and late clicks
Processing-time windows are easy and wrong: a consumer lag spike shifts clicks into later minutes, drawing a fake dip then a fake spike. So windows use event time, stamped by the click service rather than the device, whose clock can be off by hours.
The watermark is Flink's estimate that "no more events older than T will arrive". I use a bounded-out-of-orderness watermark of about 30 seconds, plus an allowed lateness of 2 minutes during which a fired window can still be updated by re-upserting the row. The trade-off is direct: a larger bound means more complete numbers but staler dashboards and more open window state.
Later events go to clicks.late, which the nightly batch includes, so nothing is lost. One subtlety is idle partitions. A watermark is the minimum across input partitions, so one partition with no traffic stalls every window. Flink's idleness timeout (withIdleness on the watermark strategy) excludes a silent partition from the minimum after a configured period (Flink docs).
3. Hot ads and the shuffle
A Super Bowl ad might get 20% of all clicks for a few minutes. If every click for that ad goes to one Flink subtask, that subtask falls behind, its watermark holds back the whole job and dashboards freeze.
The fix is two-stage aggregation. Stage one, keyed by click ID for dedup, keeps a map of (ad_id, window) → count per subtask and emits partial sums when the window fires. Stage two, keyed by ad_id, only adds partials. With 64 upstream subtasks, a hot ad generates at most 64 records per minute into stage two, whatever its click rate. Salting the key (ad_id#0..7) is the alternative if stage one cannot pre-aggregate, at the cost of a third merge step.
Down the stack
Layer 1The contract
- For any
(ad_id, minute)bucket, the streaming count never counts a click ID twice within the dedup horizon. A first value is visible about one watermark delay after the minute ends (roughly 30 to 40 seconds with the upsert sink), and it may be revised by late clicks until watermark delay + allowed lateness, about 2.5 minutes, after which it changes only when the batch reconcile replaces it. - A click acknowledged with a redirect is in Kafka or in the click service's durable spool. Kafka with
replication.factor=3andmin.insync.replicas=2survives one broker loss without losing it. - The batch count for a day is exact with respect to the archived log and replaces the streaming count. The difference between the two is reported as a metric, and a drift above about 0.5% pages someone.
Layer 2The mechanism
- Checkpoints use asynchronous barrier snapshotting, a variant of Chandy-Lamport. The job manager injects numbered barriers into each source. When an operator has received barrier n on all inputs, it snapshots its state and forwards the barrier. A checkpoint completes when every sink acknowledges it; its Kafka offsets become the restart point.
- Keyed state lives in RocksDB: a
ValueStateper click ID with state TTL for dedup, and aReducingStateper window holding one running count rather than a list of events. - Watermarks flow as special records in the stream. Each operator's watermark is the minimum of its inputs' watermarks, which is why one slow or skewed input holds back everything downstream.
- Window firing is an event-time timer at the window's end. When the watermark passes it the window emits; a late click within the allowed lateness is added and fires the window again with the updated count, and the state is cleared only when the watermark passes
window_end + lateness. The default lateness is zero, which drops late events silently unless they are sent to a side output withsideOutputLateData(Flink docs).
Layer 3The machine
- Kafka append path. A produce request appends a batch to the active segment file of the partition with a
writeinto the page cache. Followers fetch it, and the leader acknowledges once the in-sync replicas have it, typically in 2 to 10 ms in one region. Kafka does notfsyncper message (log.flush.interval.messagesdefaults to the maximum long); it relies on replication for durability, because forcing an fsync on every write would cost two to three orders of magnitude in throughput (Kafka design docs). Consumers are served withsendfile, so bytes go from page cache to the socket without a copy into user space. - Batching.
linger.ms=5(the default since Kafka 4.0, up from 0 (KIP-1030)) andbatch.sizeraised from its 16 KB default to 64 KB pack roughly 300 clicks per request, and LZ4 compresses repetitive click records perhaps 3 to 5×. - RocksDB on local NVMe. A state update is a memtable insert, tens of microseconds at most. A dedup lookup that misses the block cache costs one SSD read of a 4 KB block, about 100 µs; Bloom filters on each SST file avoid most of those reads for IDs never seen. Incremental checkpoints upload only new SST files to object storage, so a checkpoint of a 2 GB state might send tens of megabytes.
Why it's built this way
- Advertisers pay per click, so overcounting is a billing error. That forces a stable click ID and deduplication on it.
- Mobile networks deliver clicks out of order and late, so counting by arrival time gives wrong minutes. That forces event-time windows and watermarks.
- Waiting forever for stragglers makes dashboards useless, so the stream closes windows on a bound and accepts small incompleteness. That forces a second, exact path for billing.
- Network shuffles cost CPU and create hot spots, so partial aggregation happens before the shuffle.
Failure modes
- 10× load during a major event: Kafka absorbs it because ingest is only about 120 MB/s at 10×. Flink lag grows, and watermarks lag with it, so dashboards go stale rather than wrong. Mitigation: autoscale task managers on consumer lag, and keep the redirect path independent of the stream job's health.
- Region outage: each region runs its own click service, Kafka and Flink, and writes rows keyed
(ad_id, window_start, region)that queries sum. A recovering region replays its backlog into its own rows without double counting. - Hot ad: handled by two-stage aggregation; alert when one subtask's record rate exceeds about 3× the median.
- Bad stream deploy: restore a pre-deploy checkpoint and replay from Kafka's 7-day retention; the upsert sink overwrites the wrong rows.
- Clock skew on click servers: event time comes from the click service host. A host with a clock 10 minutes fast would push the watermark forward and make everyone else's clicks late. Mitigation: reject or clamp timestamps more than a few seconds ahead of the Kafka broker's append time, and alert on NTP offset.
In the wild
- Twitter, 2021: dropping the batch path for ad engagement counts. Twitter processed about 400 billion events a day, and its interaction and engagement aggregates were the source of truth for ads revenue. The old Lambda design (Heron streaming plus hourly batch) lost events when backpressure forced container restarts. The replacement relays events at least once to Pub/Sub with a UUID, dedups and aggregates in Dataflow, and writes to Bigtable with latency up to about 10 seconds. Validation against the old batch counts showed more than 95% exact matches, and most of the gap was late events the batch pipeline had discarded (Twitter Engineering).
- Uber, 2021: exactly-once ad events for Uber Eats. Flink dedups events with keyed state, counts clicks and impressions in one-minute tumbling windows, and commits results to Kafka with two-phase commit on 2-minute checkpoints; consumers read only committed records. Each aggregate carries a UUID used as an idempotency key downstream, Pinot upserts on it, and a union job merges two regions' output because Pinot does not replicate between regions (Uber).
- Cloudflare, 2023: the analytics pipeline that lived in one building. A power failure at the PDX-04 data center showed that Kafka and ClickHouse clusters behind analytics ran only in that facility. Most control-plane services moved to the disaster-recovery site in about 6 hours, but analytics and logs took about 41 hours, and some logs were lost permanently (Cloudflare).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Stream only | One codebase, fresh numbers | Late data and bugs permanently affect billing | Internal metrics, not money |
| Stream plus batch reconcile | Fresh and exact | Two code paths that must agree | Billing-grade counts |
| Idempotent upsert sink | Simple, replay-safe | Requires a store with keyed upserts | Aggregates with a natural key |
| Transactional (2PC) sink | True exactly-once to Kafka | Latency tied to checkpoints, more moving parts | Downstream is another Kafka consumer |
| Large allowed lateness | More complete stream numbers | More state, later finality | Mobile-heavy traffic |
| Salting hot keys | Works without pre-aggregation | Extra merge stage, harder queries | Aggregation cannot be combined locally |
Leadership lens
- Build first: the click service with signed tokens and the raw archive. If those are right, every count can be recomputed later. Then the batch job, because billing depends on it. The stream path comes third.
- Ownership: ads serving owns token signing, the data platform team owns Kafka and Flink, reporting owns the OLAP schema and queries. The drift metric is their shared SLO.
- Cost drivers: OLAP storage and query CPU, which grow with the number of active ads times retained minutes. Rolling minutes up to hours after 30 days is the largest single saving.
- Rollout: run the new stream job in shadow for two weeks, writing to a separate table, and compare against batch per ad per day before any advertiser sees it.
- Design-review questions: Who stamps event time? What is the dedup horizon and what happens to a duplicate older than it? What number does the invoice use, and who signs off when stream and batch disagree?
Check yourself
One click-service host's clock runs 10 minutes fast. What goes wrong in the stream job, and how does the design contain it?
Show answer
Its future event times push the watermark forward, so everyone else's clicks start looking late and fall out of their windows. The click service rejects or clamps timestamps more than a few seconds ahead of the Kafka broker's append time, and NTP offset is alerted on.
Why does the stream sink write each fired window as an idempotent upsert instead of using a two-phase-commit transactional sink?
Show answer
Writing the full count to the row keyed by
(ad_id, window_start), never an increment, makes a replayed write harmless, while a 2PC sink ties output visibility to the checkpoint interval and adds moving parts. The cost is that the store must support keyed upserts.Estimate the dedup state for a 10-minute horizon at peak. Why is it acceptable that duplicates older than that horizon slip through the stream?
Show answer
About 60k clicks/s times 600 s is 36M IDs, roughly 1.8 GB at about 50 B each, which fits in keyed RocksDB state across the job. Older duplicates are rare, and the nightly batch dedups by click ID across the whole day before billing.
A Super Bowl ad gets 20% of all clicks. Why can that freeze every dashboard, and how does two-stage aggregation bound the damage?
Show answer
All its clicks land on one subtask, which falls behind, and because watermarks are a minimum over inputs it holds back the whole job. Stage one pre-aggregates
(ad_id, window)counts per subtask, so with 64 upstream subtasks the hot ad sends at most 64 records per minute into stage two.
Sources and further study
Primary
- Photon: Fault-tolerant and Scalable Joining of Continuous Data Streams (Google, SIGMOD 2013): Google's ads system joining queries and clicks across data centers, at-most-once at any moment and exactly-once eventually.
- Lightweight Asynchronous Snapshots for Distributed Dataflows (Carbone et al., 2015): the barrier snapshot algorithm behind Flink checkpoints.
- KIP-1030: new default values: the Kafka 4.0 change of
linger.msfrom 0 to 5.
Systems docs
- Apache Flink: windows: triggers, allowed lateness, late firings and side outputs.
- Apache Pinot: upsert: primary keys, comparison columns and the partitioning requirement.
- ClickHouse: ReplacingMergeTree: merge-time deduplication and why queries need
FINAL.
Books
- System Design Interview, Volume 2, Alex Xu and Sahn Lam, chapter 6 "Ad Click Event Aggregation": the interview-depth version with Lambda versus Kappa and reconciliation.
- Designing Data-Intensive Applications, 2nd edition, Kleppmann and Riccomini, chapter 12 "Stream Processing": event time versus processing time, windows, and exactly-once through idempotence.
Other curricula
- Azure Architecture Center: Big data architectures: frames this design as Lambda (hot and cold paths converging at the client) and names duplicated logic as the cost Kappa removes.
- Azure Architecture Center: Materialized View pattern: frames the minute and hourly rollups as projections whose staleness window must be chosen up front.
Watch
- Design an Ad Click Aggregator w/ a Ex-Meta Staff Engineer (Hello Interview): an interview-paced walkthrough of this exact question, useful for comparing structure.
- Introduction to Stateful Stream Processing with Apache Flink (Confluent Developer): keyed state, event time and checkpoints, which are the mechanisms the stream path relies on.
Free resources
- Streaming 101 (Tyler Akidau, O'Reilly): the clearest explanation of event time versus processing time and why windows need watermarks.
- The Dataflow Model (Google Research): the paper that formalised windows, triggers and late-data handling.
- Timely stream processing (Apache Flink docs): how Flink generates and propagates watermarks, including idle sources.
- Exactly-once semantics in Apache Kafka (Confluent): idempotent producers and transactions, and where the guarantee stops.
- Apache Druid design (Druid docs): how a real-time OLAP store ingests streams and serves pre-aggregated, time-partitioned data.