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 and 5 more with Pro.
The interviewer asks, the candidate answers and draws, and you press Next. Pause to answer yourself at the key decisions, and ask the coach anything along the way.
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 token was signed when the ad was served and carries campaign, creative, placement and price. Because everything needed is inside it, the edge needs no lookup: that is what makes a sub-10 ms redirect possible at a million a second.
- The event is appended to an in-process buffer and flushed in batches to the collector. Batched every few hundred milliseconds. A node that dies with a full buffer loses a fraction of a second of clicks; making this durable at the edge would cost the latency budget. It is a deliberate, quantified trade and it should be said out loud.
- The collector produces to the event log with acks from all in-sync replicas. Keyed by campaign id, so every event for a campaign lands on one partition in order. From here on nothing is lost: the log is the durability boundary.
- Dedupe and filtering drop repeats and tag suspicious traffic. Click id against a rolling window; then cheap heuristics: a data-centre IP range, an impossible click rate for one user hash, a click with no matching impression. Tagged, not deleted: a false positive should be recoverable from the archive.
- The aggregator folds the event into its minute window and upserts the bucket. State is keyed by (campaign, creative, geo, minute). The window is emitted on the watermark and re-emitted if late events arrive, and the write is an upsert on the window key so re-emission corrects rather than doubles.
- The dashboard reads the bucket seconds later. End to end is a few seconds: batch flush, log, window emit, upsert. The dashboard states its own freshness so nobody has to guess.
- 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. In-memory counters, no windowing and no waiting for watermarks. It deliberately does not wait for late events: being right two minutes later is worthless here.
- Spend crosses the daily budget and a pause is published immediately. Published to the serving system on a low-latency channel, not through the data warehouse. The loop (click, count, pause, stop serving) is what determines overspend, so every hop in it is minimised.
- Serving stops within a couple of seconds; clicks already in flight still arrive. An ad already rendered on someone's screen can still be clicked. Some overspend is structural, not a bug; the design bounds it rather than eliminating it.
- Overspend beyond a threshold is absorbed rather than billed. The standard commitment is to charge no more than the budget, so anything over it is written off. That write-off is the metric that justifies spending engineering effort on this loop.
- As the budget resets or is raised, the controller unpauses. Also the recovery path after a controller restart: it rebuilds today's spend by reading the serving store for closed minutes plus the stream tail, rather than replaying the whole day.
- 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 edge is stateless and already sized for redirects, which cost almost nothing. It is the first thing that would have failed in a design that wrote to a database on the click path, and it does not even notice here.
- The collector keeps producing; the log takes the write rate. A partitioned log absorbing a burst is the whole reason it is in the middle. Disk is cheap and sequential writes are fast; the buffer is what turns a spike into lag instead of loss.
- Consumer lag grows and dashboards drift from seconds to minutes behind. The correct degradation. Nothing is lost and nothing is wrong: the numbers are simply older, and the dashboard says so instead of pretending.
- Aggregator instances scale out; partition assignment rebalances. Parallelism is capped by partition count, which is why partitions are provisioned generously up front: adding them mid-incident changes key-to-partition mapping and disturbs ordering per campaign.
- The budget path is prioritised over the reporting path while lag persists. If something must be behind, let it be the dashboard. Lag in the budget loop costs real money; lag in reporting costs patience. Separate consumer groups make that choice possible.
- The spike passes and consumers catch up by draining the backlog. Windows emit in order as the backlog drains, and because writes are upserts keyed by window, catching up corrects the dashboard rather than double-counting it.
- 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. Its partitions are reassigned. Anything held in memory since the last checkpoint is gone, which is fine as long as the input can be replayed.
- The replacement restores the last checkpoint and rewinds the log to that offset. Checkpoints bundle operator state with the offsets that produced it, so state and position are always consistent with each other. This is what "exactly once" actually means in a stream processor: at-least-once delivery plus atomic state and offsets.
- Events between the checkpoint and the crash are processed a second time. Unavoidable, and harmless here, because the sink is idempotent. A design whose sink appended deltas would over-count on every single crash.
- Windows are re-emitted and upserted by key, overwriting rather than adding. The window emits an absolute total for its key. Replay writes the same total. Idempotency lives in the shape of the write, not in a distributed transaction.
- Events arriving after their watermark are counted into a correction, not dropped. Allowed lateness of a few minutes re-opens the window and re-emits it. Beyond that, events still land in the raw archive and are picked up by the nightly reconciliation, so nothing is ever silently lost: it is just late.
- The archive sink, on its own consumer group, was unaffected throughout. Independent consumer groups mean an outage in the aggregation path costs freshness, never the record. The audit trail survives every failure in the fast path.
- 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. Complete, ordered, and with no watermark pressure: every late event has long since arrived. The batch job can afford the exactness the stream cannot.
- Dedupe runs over the whole day, not a rolling window. A duplicate separated by two hours is invisible to a 30-minute streaming window and obvious to a full-day pass. This is the main reason the two numbers differ, and being able to explain that difference is the job.
- Final invalid-traffic verdicts are applied, including ones that need hindsight. Some fraud patterns are only visible after the fact: a user hash that clicked four hundred ads across the evening looks ordinary at the time. Those clicks are reclassified as invalid and credited back.
- The serving store is corrected and the day is marked final. Same upsert path as the stream, so corrections flow through one mechanism. The dashboard flips from provisional to final, and the delta between the two is recorded: a drift above about a percent is an alert, not a curiosity.
- Billing reads only finalised days and produces invoice lines. It never reads a provisional number. That one rule is what keeps a bad minute in the stream from ever becoming a bad charge, and it is what makes disputes answerable: every line traces back to archived raw events.
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?
The requirements pull in opposite directions: dashboards want seconds and tolerate being slightly wrong; invoices want exactness and tolerate being a day late. One pipeline cannot be both without being the worse of each.
The cost of two paths is the well-known one: two implementations of the same aggregation logic that drift apart, and two numbers that disagree without an explanation.
- Streaming for serving, batch reconciliation for billing, shared upsert sink chosen
- Streaming only, with long allowed lateness situational: the numbers are not billable: internal analytics, where "close enough" is genuinely enough
- Batch only, every few minutes rejected
- Kappa: one streaming job, replayed from the log to correct situational: retention is short and volume is moderate, where replaying the log is genuinely cheap
The answer: 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.
How do you keep the two implementations from drifting?
Share the aggregation logic as a library used by both runners, and run a continuous differential check: for each closed hour, compare stream and batch totals and alert above a threshold. The check is the real defence: shared code drifts anyway through configuration and window semantics, and only a comparison catches that.
An advertiser screenshots a number and it changes the next morning. What do you say?
That it was labelled provisional, that the difference is deduplication and invalid-traffic filtering applied with a full day of hindsight, and that they were billed the lower final number. That conversation is survivable only if the UI said "provisional" before the change rather than after.
Could you make the streaming number exact?
Not within seconds, no. Exactness needs whole-day dedupe and fraud verdicts that depend on behaviour later in the day. You can narrow the gap (a longer dedupe window, more aggressive early filtering) but the last fraction of a percent requires hindsight, and pretending otherwise is how billing bugs get shipped.
Why does the batch job write to the same store rather than its own?
So there is one place to query and one definition of a number. Two stores means every consumer has to choose, and they will choose inconsistently. The provisional/final flag carries the distinction inside one dataset, where it can be enforced.
Counting each click once
Where does exactly-once actually come from in a pipeline with at-least-once delivery everywhere?
Every hop can duplicate: the edge retries a batch, the producer retries after a timeout, the consumer reprocesses after a crash, the user double-clicks. At a million events a second, one duplicate in ten thousand is a hundred a second billed to someone.
Exactly-once is not a property of the transport. It is either a deduplication key applied at a boundary, or an idempotent write, and usually both.
- Click id dedupe in the stream plus absolute-value idempotent upserts chosen
- Transactional sink (two-phase commit into the store) situational: moderate volumes with a sink that supports it, such as a Kafka-to-Kafka transactional job
- At-least-once with dedupe at query time rejected
- Bloom filter for dedupe instead of exact ids situational: a pre-filter in front of an exact check, never as the only check on billable events
The answer: 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.
Why is the click id minted when the ad is served rather than when it is clicked?
Because a click generated at click time by a retrying client would be a new id on each retry, and dedupe would never fire. Minting it at serve time and signing it into the token means every retry of the same human click carries the same id, and it also gives you the impression-to-click join for free.
A user genuinely clicks the same ad twice, ten minutes apart. One click or two?
A product decision, not a technical one, and the usual answer is one billable click per impression: the second click reuses the same token and therefore the same id, so dedupe collapses it. If it was a fresh impression it is a fresh token, a fresh id, and a fresh billable click. Stating that rule explicitly is what makes the system explainable.
How do you bound the dedupe state at a million events a second?
A keyed, windowed state partitioned by the same key as the log (campaign) so no single instance holds the global set, with entries expiring on the window. Roughly two billion ids over thirty minutes across a thousand partitions is a few million per instance, which fits in RocksDB-backed state comfortably.
What if the same event lands in two partitions?
It cannot, if the partition key is deterministic from the event, and that is why the key is campaign id rather than something random. Keyed partitioning is what makes per-key dedupe state correct; a round-robin producer would scatter duplicates across instances and defeat it.
Windows, watermarks and late events
When do you close a minute, and what do you do with a click that arrives afterwards?
Event time and processing time diverge constantly: a mobile client buffers offline, an edge site is partitioned for thirty seconds, a consumer falls behind. Aggregating by arrival time is simple and produces numbers nobody can reconcile with reality.
A watermark is a promise ("no more events older than this") and it is always wrong sometimes. The design question is what that promise costs when it is broken.
- Event-time tumbling minutes, watermark with bounded lateness, re-emit on late arrival chosen
- Processing-time windows situational: operational monitoring of the pipeline itself, where you care about now rather than about when
- Session windows keyed by user rejected
- Sliding windows for smoother charts situational: a derived view for charting, computed from the tumbling buckets rather than from raw events
The answer: 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.
One partition stops receiving events. What happens to the watermark?
It stalls, because the global watermark is the minimum across partitions, and every window everywhere stops emitting. That is the classic idle-source problem. The fix is an idleness timeout that excludes a silent partition from the watermark calculation after a while, at the cost of possibly treating its events as late when it wakes up.
How much state does the aggregator hold?
Roughly the number of active keys times the number of open windows. With about a million active campaign-creative pairs and six open minutes, that is a few million entries per window generation: hundreds of megabytes to a few gigabytes across the cluster in RocksDB-backed state. Allowed lateness is the multiplier, which is why it is five minutes and not an hour.
An advertiser asks why their 10:00 minute changed at 10:04.
Because clicks that happened at 10:00 arrived late (a buffering mobile client, a briefly partitioned edge) and the window re-emitted with the complete figure. The honest answer is that the first number was a lower bound. The UI should mark the last few minutes as still settling for exactly this reason.
Why minute buckets and not ten-second ones?
Row count. Ten-second buckets are six times the rows for a granularity nobody queries at, and the serving store would grow to around 600 M rows a day. Minutes are fine enough for the dashboards people actually build and coarse enough to keep the store cheap. The budget controller, which does want sub-minute reaction, does not use windows at all.
Where the aggregates live
What kind of store serves "clicks by creative by hour for the last month" in under a second?
The query pattern is narrow and known: filter by advertiser and campaign, group by a few dimensions, sum two measures, over a time range. That is a columnar scan over pre-aggregated rows, not a general-purpose workload.
The complication is that this store also has to take a hundred million upserts a day from the stream and be corrected in place by the batch job.
- Columnar OLAP store with pre-aggregated rollups and upsert support chosen
- Relational database with summary tables situational: early, at a fraction of this volume, and it is the right place to start
- Key-value store keyed by (campaign, dimension, bucket) situational: serving a fixed set of counters (a live "clicks today" badge) rather than a report
- Query the raw archive with a data-lake engine rejected
The answer: 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.
An advertiser wants clicks broken down by a dimension you do not pre-aggregate.
Pre-aggregation trades flexibility for speed, so an unforeseen dimension means either adding it to the cube (multiplying rows by its cardinality) or falling back to a slower query over the raw archive with an explicit "this may take a minute" experience. Which dimensions are in the cube is a product decision with a direct storage cost, and it should be made deliberately rather than by request.
What stops row count exploding as dimensions are added?
Cardinality discipline. The cube holds low-cardinality dimensions only (campaign, creative, country, device type) and never user id or exact URL, which would multiply rows by millions. High-cardinality analysis belongs in the archive, queried on demand.
How do the rollups stay consistent with the minutes when a correction lands?
Rollups are derived, not independently maintained: a corrected minute marks its hour and day dirty, and a job recomputes them from the minute buckets. Incrementally patching a rollup with a delta is the tempting shortcut and it drifts, because you cannot tell a correction from a new event.
Two weeks of minute data is a lot. Why keep it at all?
Because the most common investigation is "what happened during our launch yesterday", which is a minute-level question. Beyond a couple of weeks nobody asks it, so minutes expire and hours carry the long tail. Retention per granularity is the cheapest lever on the storage bill in the whole system.
Logging a click without slowing it down
How do you record a million events a second on a path where a human is waiting?
The click endpoint is on the critical path of a redirect. Every millisecond is a person staring at a white screen, and the endpoint is hit a million times a second from everywhere on earth.
That forces two decisions: the endpoint must do no lookups, and it must not wait for durability. Both have consequences that need to be stated rather than hidden.
- Stateless edge endpoint, signed token, buffered batch to a durable log chosen
- Synchronous write to the log before redirecting situational: low-volume, high-value events: a purchase confirmation rather than a click
- Client-side beacon after the redirect rejected
- Write to a local append-only file, shipped by an agent situational: edge sites with local disk where the loss window must be milliseconds rather than a second
The answer: 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.
How much data does a node dying actually lose?
At most one flush interval of that node's share: a few hundred milliseconds of one node out of many, so tens of thousands of clicks in the worst case across the fleet, and far less typically. The trade is explicit (sub-10 ms redirects against a bounded, measurable loss) and making the flush interval configurable lets the business choose where on that curve to sit.
Why put the price in the token rather than looking it up?
Because a lookup is a network hop on a path with a 10 ms budget, at a million requests a second. The price was already known when the auction ran, so signing it into the token makes the click self-describing. The cost is that a price change only affects ads served after it, which is the correct semantics anyway.
Someone fabricates clicks by replaying tokens. What stops them?
The signature stops forgery, an expiry bounds the replay window, and the click id makes a replay a duplicate that dedupe collapses. Beyond that it becomes an invalid-traffic problem rather than an authentication one: the same id arriving from a hundred addresses is a pattern the filters catch.
How do you rotate the signing key without invalidating live tokens?
Key id in the token and an overlapping validity window: the edge accepts both the old and the new key for as long as a token can live, typically an hour or two. Rotating without an overlap invalidates every ad currently on a screen, which is a self-inflicted outage.