Design an Ad Click Aggregator
Count a million ad clicks a second, show advertisers a dashboard that is seconds behind, stop a campaign the moment its budget runs out, and be exactly right when the invoice is cut.
Last updated 2026-09-22. Difficulty: hard. Patterns: stream-processing, windowing, exactly-once, olap, analytics. Reported at Meta, Google, Amazon, Netflix, Datadog, LinkedIn.
Sit this as an AI interview and be asked it one question at a time; Study shows every answer, and Practice hides them until you have produced your own.
Functional requirements
- Record every click and redirect the user instantly. The click is a redirect on the critical path of a real person going to a landing page. It must be logged in single-digit milliseconds or not at all.
- Aggregate by campaign, creative, geography and time. Advertisers slice by whatever they like. The cube is small in dimensions and huge in rows.
- Near-real-time dashboards. Clicks and spend visible within seconds, so a campaign manager can react during a launch rather than the next morning.
- Budget enforcement. Stop serving a campaign once its daily budget is spent. Seconds of lag here is money given away.
- Exact numbers for billing. The dashboard may be approximate; the invoice may not. Both numbers come from the same events and must be reconcilable.
- Deduplicate clicks and filter invalid traffic. A double-submitted redirect, a retry, a crawler and a click farm must not all be billed as demand.
- Out of scope. Ad serving and auction (the decision of which ad to show), attribution of conversions after the click, and the advertiser-facing campaign editor.
Non-functional requirements
- Ingest rate (1 M clicks/s peak). Roughly 50 billion a day. Impressions would be a hundred times this; clicks are the expensive, billable subset.
- Redirect latency (p99 < 10 ms). A human is waiting on this hop. Logging must be fire-and-forget to a local buffer, never a synchronous database write.
- Dashboard freshness (< 10 s). Advertisers accept "a few seconds behind". They do not accept an hour, and they do not need the last millisecond.
- Billing accuracy (exactly once, reconciled daily). Every billable click counted once, with an audit trail from the raw event to the invoice line.
- Durability of raw events (no loss, 13 months). Raw events are the source of truth for disputes and for recomputation when the aggregation logic changes.
- Query latency (p95 < 1 s for a month of one campaign). Dashboards are interactive. Scanning raw events per query would never meet this.
- Budget enforcement lag (< 5 s). Overspend is refunded out of our own pocket, so this lag has a direct, measurable cost.
Back-of-envelope estimates
- Clicks per day: ~50 B. 1 M/s at peak, and peak is roughly twice the average over a day: ~0.5 M/s × 86 400 ≈ 50 B clicks/day.
- Raw event size and daily volume: ~400 B · ~20 TB/day. Click id, timestamp, campaign, creative, placement, user hash, geo, device, referrer, cost: ~400 B raw, ~100 B compressed columnar. 50 B × 400 B = 20 TB/day raw, about 5 TB stored.
- Raw retention: ~2 PB. 13 months × 5 TB/day compressed = ~2 PB in object storage. Cheap, and the thing that makes a recount possible when a billing bug is found.
- Aggregated rows per day: ~100 M. Minute buckets × ~1 M active (campaign, creative) pairs × a handful of dimension combinations, after rollup: ~100 M rows/day at ~100 B = 10 GB/day. Four orders of magnitude smaller than the raw stream, which is the entire point of pre-aggregation.
- Dedupe window state: ~2 B ids · ~30 GB. A 30-minute dedupe window at 1 M/s is ~1.8 B click ids. Stored exactly that would be ~50 GB; a partitioned Bloom filter at 1 % false positives is ~2 GB per window, so the choice is exactness against memory.
- Kafka partitions: ~1 000. 1 M events/s ÷ ~1–2 k events/s per partition that a single consumer thread handles comfortably = ~1 000 partitions, keyed by campaign id so all events for a campaign land in order on one consumer.
- Dashboard query cost: ~45 k rows scanned. One campaign, one month, minute granularity: 43 200 rows in the aggregate table. A columnar store answers that in milliseconds. The same question over raw events would scan tens of billions of rows.
Components
- User click: A real person clicking an ad and expecting to arrive at the advertiser's page. Everything on this path is measured against the delay they perceive, which is the reason the ingest design looks the way it does.
- Click endpoint (edge PoP · stateless): Verifies the signed click token, writes the event to a local buffer, and issues a 302 to the landing page. No database, no lookup, no blocking write — a few hundred microseconds of work.
- Collector: Batches buffered events from the edge and produces them to the log with a durable ack. The edge is fire-and-forget; the collector is where the durability promise actually begins.
- Event log (Kafka · ~1 000 partitions by campaign): The backbone. Partitioned by campaign id so every consumer sees a campaign's events in order, retained for a few days so a broken consumer can be rewound rather than losing data.
- Dedupe & filter (stream job): Drops repeated click ids inside a rolling window and tags obviously invalid traffic — known bots, impossible click rates per user, clicks with no preceding impression. Tags rather than deletes, so a mistake is reversible.
- Stream aggregator (Flink · tumbling minute windows): Keyed by (campaign, creative, geo, minute), counting clicks and summing cost. Checkpointed state, watermarks for late events, and idempotent writes downstream so a replay after a crash does not double-count.
- Serving store (columnar OLAP): Pre-aggregated minute buckets rolled up to hour and day. Answers dashboard queries by scanning tens of thousands of rows instead of tens of billions. Upserts by window key, so a window can be corrected in place.
- Budget controller: Keeps a running spend per campaign in memory from the same stream, compares it to the daily budget, and publishes a pause the moment it is exceeded. Separate from the aggregator because it trades accuracy for latency and nothing else in the system should.
- Ad serving: The auction and serving system. It subscribes to pause and pacing signals; it is not part of this design, but the latency of the loop back to it is what determines overspend.
- Raw archive (object store · Parquet by hour): Every event, partitioned by hour and campaign, written by a sink consumer. The audit trail and the input to any recount. Untouched by the fast path.
- Batch reconciler (nightly, over the archive): Recomputes each hour from the raw archive with the full dedupe window and the final invalid-traffic verdicts, then corrects the serving store. The number the invoice is built from.
- Billing: Reads the reconciled, closed days and produces invoice lines. It never reads the streaming numbers, so a bad minute in the stream can never become a bad charge.
- Advertiser dashboard: Interactive slicing over the serving store, labelled with how fresh the numbers are and whether the day is still provisional. The label is a design decision, not decoration: it is what stops support tickets about two numbers disagreeing.
User flows
- One click, from redirect to dashboard. The path everything else hangs off. Note what is not on it: no database write, no lookup, no synchronous aggregation.
- The user clicks; the edge validates the signed token and redirects immediately.
- The event is appended to an in-process buffer and flushed in batches to the collector.
- The collector produces to the event log with acks from all in-sync replicas.
- Dedupe and filtering drop repeats and tag suspicious traffic.
- The aggregator folds the event into its minute window and upserts the bucket.
- The dashboard reads the bucket seconds later.
- A campaign hits its daily budget. The one place latency costs money directly. A separate, faster, less accurate path exists purely for this.
- The budget controller keeps running spend per campaign from the same clean stream.
- Spend crosses the daily budget and a pause is published immediately.
- Serving stops within a couple of seconds; clicks already in flight still arrive.
- Overspend beyond a threshold is absorbed rather than billed.
- As the budget resets or is raised, the controller unpauses.
- Traffic triples during a live event. The scale-breaking case. The log absorbs the burst and the consumers fall behind, which is exactly what should happen.
- Click rate goes from 1 M/s to 3 M/s in a minute.
- The collector keeps producing; the log takes the write rate.
- Consumer lag grows and dashboards drift from seconds to minutes behind.
- Aggregator instances scale out; partition assignment rebalances.
- The budget path is prioritised over the reporting path while lag persists.
- The spike passes and consumers catch up by draining the backlog.
- A worker crashes mid-window. The failure path that decides whether the numbers can be trusted. Replay must not double-count.
- An aggregator instance dies holding uncommitted window state.
- The replacement restores the last checkpoint and rewinds the log to that offset.
- Events between the checkpoint and the crash are processed a second time.
- Windows are re-emitted and upserted by key, overwriting rather than adding.
- Events arriving after their watermark are counted into a correction, not dropped.
- The archive sink, on its own consumer group, was unaffected throughout.
- The nightly recount that produces the invoice. Two paths from the same events: one fast and approximate, one slow and exact. Billing only ever reads the second.
- After the day closes, the reconciler reads the raw archive for each hour.
- Dedupe runs over the whole day, not a rolling window.
- Final invalid-traffic verdicts are applied, including ones that need hindsight.
- The serving store is corrected and the day is marked final.
- Billing reads only finalised days and produces invoice lines.
Deep dives
- Two paths, or one. Do you run a fast approximate pipeline and a slow exact one, or make a single pipeline serve both? Streaming aggregation into the serving store for dashboards and budget control, plus a nightly batch reconciliation over the raw archive that corrects the same store and marks the day final. The dashboard labels provisional days explicitly, and the stream-versus-batch delta is monitored so drift is caught by us rather than reported by an advertiser. Billing reads finalised days only.
- Counting each click once. Where does exactly-once actually come from in a pipeline with at-least-once delivery everywhere? A click id generated at ad-serve time and carried in the signed token, deduplicated in the stream against a 30-minute rolling window held in the job's keyed state. Aggregation windows emit absolute totals, and the serving store takes them as upserts on the window key, so any replay overwrites rather than accumulates. The nightly batch repeats deduplication across the whole day from the raw archive, which is where any duplicate wider than the streaming window is caught.
- Windows, watermarks and late events. When do you close a minute, and what do you do with a click that arrives afterwards? Event-time tumbling one-minute windows keyed by (campaign, creative, geo), with a watermark derived from the maximum event time seen per partition minus a few seconds of slack, and allowed lateness of five minutes during which the window re-opens and re-emits. Events later than that are dropped from the stream but remain in the raw archive, where the nightly reconciliation counts them. Minute buckets are rolled up to hour and day in the serving store.
- Where the aggregates live. What kind of store serves "clicks by creative by hour for the last month" in under a second? A columnar OLAP store holding minute buckets partitioned by day and sorted by (advertiser, campaign, time), with materialised hour and day rollups that queries are routed to by granularity. Writes are upserts keyed by the window key, which serves both the stream and the batch corrections. Minute data is kept for two weeks, hours for three months, days for thirteen; the raw archive stays behind all of it for recounts and disputes.
- Logging a click without slowing it down. How do you record a million events a second on a path where a human is waiting? A stateless edge endpoint that verifies an HMAC-signed click token carrying campaign, creative, placement, price and click id, appends the event to an in-process buffer, and redirects — no lookups, no blocking I/O. Buffers flush to collectors every few hundred milliseconds, and collectors produce to the log with acks from all in-sync replicas. The loss window is bounded by the flush interval and measured: it is a number on a dashboard, not an assumption.