Social & Feed
Design Ad Click Aggregator
01
What the system must do
What the system must do
- Record every click on a served ad and send the user on to the advertiser's landing page.
- Count valid clicks per ad, campaign and advertiser, broken down by minute, country, device and placement.
- Let advertisers query totals for any time range, from the last ten minutes to the last two years.
- Flag and exclude invalid clicks: repeated clicks from one device, data-centre traffic, known bot signatures.
- Feed real-time spend back to the ad server so campaigns stop serving when their daily budget runs out.
- Produce a monthly billing statement per advertiser that matches the dashboard.
- The click redirect adds under 20 ms at p99; the user should never notice tracking.
- No valid click is lost and none is billed twice, even when parts of the pipeline crash and replay.
- Dashboard totals are at most 30 seconds behind real time under normal load.
- Reports over 90 days for one campaign return in under 2 seconds.
- Raw events are kept so any aggregate can be recomputed from scratch after a bug.
Ask what counts as a billable click and when the number becomes final. Most ad platforms show provisional numbers in real time and settle invoices from a later, more careful pass that also catches slow-burning fraud. If the interviewer agrees to that split, you can make the streaming path fast and tolerant, and put the strictest correctness in a batch job that runs a few hours behind.
02
Sizing the stream
Sizing the stream
Assume 4 billion clicks a day across about 2 million active ads. Traffic has a strong daily curve and spikes during major sporting events and shopping days. Each raw click event is about 300 bytes once enriched with ad, campaign and device fields.
| Quantity | Working | Result |
|---|---|---|
| Clicks per day | assumption | 4B |
| Average rate | 4B / 86,400 s | about 46,000 / s |
| Peak rate | 4x average on a big shopping day | about 185,000 / s |
| Raw ingest volume | 4B x 300 B | about 1.2 TB a day |
| Kafka retention | 7 days x 1.2 TB x 3 replicas | about 25 TB |
| Raw archive in object storage | 1.2 TB a day compressed 6x, 2 years | about 145 TB |
| Distinct keys per minute | ad x country x device x placement with at least one click | about 1.5M |
| Minute rows per day | 1.5M x 1,440 | about 2.2B rows |
| Minute table size | 2.2B x 40 B, compressed about 5x in a column store | about 18 GB a day |
| Hourly rollup | kept 2 years, roughly 1/25 of minute rows | about 0.7 GB a day |
| Dedup state | click ids for a 1 hour window: 185K x 3,600 x 16 B | about 11 GB across the job |
Writes outnumber reads by millions to one. 185,000 events a second must be reduced to a few thousand rows a second before anything touches a database. The aggregation layer, not the storage layer, is the heart of this system.
03
API design
API design
There are two audiences. Browsers hit the click endpoint and must be redirected instantly. Advertisers and internal tools read aggregates.
Store spend as micro-units in 64-bit integers, never as floats. Summing two billion floating point cents will not match an invoice.
04
Architecture overview
Architecture overview
flowchart LR
B["Browser"] --> CR["Click redirect service"]
CR -. "click event" .-> K[["Kafka: raw clicks"]]
K --> FL["Flink job: dedup, fraud, windows"]
FL --> OL[("ClickHouse: minute and hour tables")]
FL --> BP["Budget pacing store"]
BP --> AS["Ad server"]
K --> AR[("Object store: raw archive")]
AR --> BJ["Batch reconciliation, hourly and daily"]
BJ --> OL
BJ --> BL[("Billing ledger")]
OL --> QS["Query service"]
QS --> UI["Advertiser dashboard"]The redirect service is deliberately dumb. It verifies the token signature, appends the event to an in-memory buffer, writes the 302 and moves on. A background thread flushes the buffer to Kafka every few milliseconds with acks from all in-sync replicas. If Kafka is slow, the buffer grows; if the process dies, the last few milliseconds of clicks are the exposure, and the impression log lets you estimate how many were lost.
Kafka is partitioned by ad_id so all clicks for one ad reach the same Flink task, which keeps per-ad state local. A single Flink job does three things in order: drop duplicates, score fraud, and aggregate into one-minute event-time windows. Closed windows are written to ClickHouse and the running spend per campaign goes to a low-latency store the ad server reads before every auction.
A second path reads the same raw events from object storage. Hourly and daily batch jobs recompute the aggregates with slower, more thorough fraud models and overwrite the provisional rows. Billing reads only from that reconciled data. The two paths agree on schema and keys, so the dashboard simply shows whichever version is newest for a given window.
Stateless and spread across edge regions. It does not call any database. The token carries everything needed to build the event.
Event-time windows with watermarks, keyed state in RocksDB and checkpoints every 30 seconds. Exactly-once output comes from checkpoint-aligned commits to idempotent sinks.
Column store with minute and hour tables. Queries over months scan the hourly table; recent windows come from the minute table.
Spark over the raw archive. It is slower and smarter, and it is the only writer of billing numbers.
The minute table
Ordering by advertiser and campaign first means a report for one campaign reads a narrow, contiguous slice of each daily partition. A materialised view sums minute rows into an hourly table with the same key minus the minute, kept for two years. The batch path writes the same keys with a version taken from its run epoch, which is always higher than anything the stream produced, so reconciled numbers replace provisional ones without a separate delete step.
05
Deep dive: counting correctly in a stream
Deep dive: counting correctly in a stream
Counting by event time when events arrive late, counting each click exactly once across failures, and deciding which clicks to count at all. Each needs a specific mechanism; none is solved by a faster database.
Windows, watermarks and late clicks
flowchart TD
E["Click event with event_time"] --> D{"click_id seen in last hour?"}
D -->|yes| X["Drop as duplicate"]
D -->|no| F["Fraud rules and model score"]
F -->|invalid| I["Count in invalid bucket"]
F -->|valid| W["Assign to 1 minute window by event_time"]
I --> W
W --> WM{"Watermark past window end plus 2 min?"}
WM -->|no| H["Hold in keyed state"]
WM -->|yes| O["Emit row with window id and version"]
O --> CH["Upsert into ClickHouse"]
E -. "later than allowed" .-> LT[["Late topic for batch path"]]Bucket by when the click happened, not when it reached the job, or a two-minute Kafka hiccup will shift money from one minute to the next and make hourly charts lie. Mobile SDKs batch clicks offline and deliver them minutes later, so events arrive out of order.
A watermark is the job's estimate that it has seen everything up to time T. Set it to the maximum event time seen minus a bounded delay, say 90 seconds. Keep each window open for an extra two minutes of allowed lateness after the watermark passes, and emit an updated row if a straggler arrives. Anything later than that goes to a side topic. It is not lost: the batch path counts it in the next reconciliation.
Exactly once, in practice
End-to-end exactly-once is built from three at-least-once pieces plus idempotence. Kafka delivers events at least once. Flink restores state from its last checkpoint after a crash and replays from the matching offsets, so its internal counts are consistent. The sink is where double counting would sneak in, so make writes idempotent: every output row is keyed by (window_start, ad_id, country, device, placement) and carries a version number.
In ClickHouse, a ReplacingMergeTree on that key keeps only the highest version after merges, and queries use FINAL or an argMax pattern for recent windows. Replaying a window just writes the same key with the same or higher version. Nothing is added twice.
Upstream duplicates are a separate problem. A user double-clicks, or the redirect retries a Kafka send. The click token carries an impression_id, and the job keeps a one-hour keyed set of seen click ids per ad in RocksDB. Duplicates inside that window are dropped before counting; the batch path catches any older ones.
Hot ads and skew
Partitioning by ad is natural, until a soft drink runs a halftime ad and one key receives 40,000 clicks a second. One Flink task cannot keep up and its backpressure slows the whole job. Fix it with a two-stage aggregate: append a salt from 0 to 15 to hot keys, pre-aggregate per salted key, then sum the sixteen partial counts in a second, much lighter stage. Detect hot keys from the previous minute's counts so the salt only applies where needed.
Filtering invalid clicks
Run cheap rules first and expensive checks later. In the stream: drop clicks with bad or expired token signatures, clicks from known data-centre IP ranges, clicks arriving under 300 ms after the impression, and more than a few clicks per device per ad per minute. These catch the noisy majority. The batch path then runs models over hours of history, finding click farms that look normal one event at a time but form tight clusters across devices and campaigns.
Invalid clicks are counted, not discarded. Advertisers see them as a separate column, and the batch job can promote a click from invalid to valid if a rule was too aggressive. Credits issued after the fact are entries in the billing ledger, never edits to old invoices.
The ad server needs to know remaining budget in seconds, not minutes. The stream job emits running spend per campaign every few seconds to a Redis-like store. The ad server stops bidding at about 98% of budget, leaving a small margin for clicks already in flight.
06
How to handle the interview
How to handle the interview
Start from the money. Say early that clicks are billing events. It justifies idempotent sinks, a raw archive and a reconciliation path before anyone asks.
Say event time, not processing time. Explain watermarks and allowed lateness with a concrete delay. Most candidates bucket by arrival time and lose points here.
Show how exactly once is assembled. Replayable log, checkpointed state, idempotent writes. Name the key that makes the sink idempotent.
Pre-aggregate before storage. Never write 185,000 rows a second into the analytics store. Reduce in the stream first.
Bring up hot keys. A single viral ad breaks key-by-ad partitioning. Salting and two-stage sums is the expected fix.
Keep the redirect fast and dumb. No database calls on the click path. A signed token and an async buffer are enough.
Mention fraud as two tiers. Cheap rules in the stream, heavier models in batch, and credits in a ledger.
07
Designs that look fine and are not
Designs that look fine and are not
UPDATE counts SET n = n + 1 for every event turns one hot ad into a single locked row and caps throughput at what that row can take. Under retry it also double counts.
Any lag in Kafka or a consumer restart moves clicks into the wrong minute. Charts show dips and spikes that never happened, and hourly billing totals shift.
Window by the event timestamp from the click token, with a watermark and a bounded lateness allowance.When a fraud rule or a timezone bug is found, nothing can be recomputed and the only fix is a manual credit spreadsheet.
Archive every raw click to object storage for at least the billing dispute window and keep batch jobs able to rebuild any table.Writing the click to Kafka or a database before sending the 302 puts broker latency directly in front of the user and drops clicks whenever the broker is slow.
Buffer locally, respond immediately, and flush asynchronously with bounded memory.Discarded clicks cannot be audited or restored, so advertisers cannot see what was removed and support cannot fix a false positive.
Count invalid clicks in their own column and let reconciliation move clicks between buckets.08
Decisions and their costs
Decisions and their costs
Two code paths compute the same aggregates, but each can be tuned for its job: low latency and good-enough fraud in the stream, thorough models and final numbers in batch. Shared schemas and keys keep them comparable.
Fits provisional versus finalOne code path, replay the log to recompute. Clean, but heavy fraud models over hours of history are awkward in a streaming job, and Kafka retention must cover the whole dispute window.
Good if fraud logic stays simpleClickHouse or Druid scan billions of narrow rows per second and compress repeated dimensions very well. Group-bys over months on one campaign are their home ground.
Right for analytical readsWrites scale well, but ad hoc group-bys across country and device turn into client-side scans and fan-out reads.
Poor for flexible reportsMinute rows give the live dashboard its resolution. Rolling them into hours after seven days cuts storage by about 25x for long reports.
Resolution where it is usedMaximally flexible, but every dashboard load scans billions of rows and costs grow with time.
Too expensive per queryAbout 11 GB of state across the job is affordable and gives exact answers for the duplicates that matter, which almost all happen within seconds.
Exact where it countsSmaller state, but a false positive drops a valid paid click. For billing, the memory saving is not worth it.
Use only for non-billing counts09
Failure modes
Failure modes
State grows during a traffic spike and checkpoints take longer than their interval. Recovery after the next crash would replay a long stretch and dashboards stall.
Use incremental RocksDB checkpoints, alert on checkpoint duration and size, add parallelism before peaks, and keep enough Kafka retention to replay from the last good checkpoint.Writes to its shard fail and queries on that shard error or time out.
Replicate each shard twice, let the sink retry with idempotent rows, and route queries to the healthy replica. Because writes are idempotent, a retry storm cannot inflate counts.A new rule blocks a mobile carrier's shared IP range and marks 12% of one country's clicks invalid. Advertisers see spend collapse and pause campaigns.
Ship rules in shadow mode first, alert on sudden shifts in the invalid ratio per country or publisher, and let reconciliation restore the clicks with an audit trail.A mobile SDK bug holds clicks for six hours before sending. The stream drops them from live windows.
Route them to the late topic, count them in the next batch reconciliation, and alert when late volume for an app crosses a threshold.A broker dies during a shopping-day spike and partitions move leaders. Producers see errors for a few seconds.
Replication factor 3 with minimum in-sync replicas of 2, producer retries with idempotence enabled, and local buffers on the redirect fleet sized for at least 30 seconds of traffic.The pacing store lags by a minute during an incident and a campaign spends 140% of its daily budget.
Stop bidding at a safety margin below the cap, fall back to conservative pacing when spend data is stale, and credit overspend automatically per the contract.10
Growing the pipeline in stages
Growing the pipeline in stages
Each stage is forced by a number you can watch. Do not deploy a streaming job before a nightly batch would have been too slow.
Insert raw clicks into Postgres, run a nightly SQL rollup, bill from that. Correct, cheap and easy to debug.
The redirect writes to Kafka, events land in object storage and hourly Spark jobs fill a warehouse. Dashboards are an hour behind.
Flink produces minute windows into ClickHouse, batch remains the billing source, and the hourly-only dashboard is gone.
Redirect and Kafka clusters run per region, aggregates merge centrally, and the ad server reads spend every few seconds.
Watch and read
Try next
Free to read · better with landed
Whiteboard this with landed
landed runs it as a live system design round on the whiteboard and grades your trade-offs.