← System design

Social & Feed

Design Ad Click Aggregator

Every click on an ad is a small invoice line. Advertisers want to watch spend move in near real time, finance wants totals that match to the cent at month end, and somewhere in the stream a botnet is clicking the same banner ten thousand times. You are building a counter that has to be fast, correct and suspicious all at once.
Core: event-time windows + idempotent sinks4B clicks a day, 185K per second peakWrite-heavy stream, billing gradeDashboards fresh within 30 seconds

01

What the system must do

02

What the system must do

Functional
  • 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.
Non-Functional
  • 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.
Settle the money rules early

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

03

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.

QuantityWorkingResult
Clicks per dayassumption4B
Average rate4B / 86,400 sabout 46,000 / s
Peak rate4x average on a big shopping dayabout 185,000 / s
Raw ingest volume4B x 300 Babout 1.2 TB a day
Kafka retention7 days x 1.2 TB x 3 replicasabout 25 TB
Raw archive in object storage1.2 TB a day compressed 6x, 2 yearsabout 145 TB
Distinct keys per minutead x country x device x placement with at least one clickabout 1.5M
Minute rows per day1.5M x 1,440about 2.2B rows
Minute table size2.2B x 40 B, compressed about 5x in a column storeabout 18 GB a day
Hourly rollupkept 2 years, roughly 1/25 of minute rowsabout 0.7 GB a day
Dedup stateclick ids for a 1 hour window: 185K x 3,600 x 16 Babout 11 GB across the job
The shape of the problem

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

04

API design

There are two audiences. Browsers hit the click endpoint and must be redirected instantly. Advertisers and internal tools read aggregates.

GET/c/{click_token}Click redirect
// click_token is signed by the ad server at impression time and carries // impression_id, ad_id, placement, issued_at and the landing URL HTTP/1.1 302 Found Location: https://brand.example.com/autumn?utm_source=adnet Cache-Control: no-store // The event is appended to a local buffer before the response is written
GET/v1/reports?campaign_id=cmp_51&from=2026-09-01T00:00Z&to=2026-09-30T23:59Z&granularity=day&group_by=countryAggregated report
{ "campaign_id": "cmp_51", "as_of": "2026-10-01T08:14:30Z", "rows": [ { "day": "2026-09-01", "country": "BR", "valid_clicks": 18240, "invalid_clicks": 611, "spend_micros": 9120000000 } ], "status": "provisional" // becomes "final" after reconciliation }
GET/v1/billing/statements?advertiser_id=adv_7&month=2026-09Monthly statement
{ "advertiser_id": "adv_7", "month": "2026-09", "billable_clicks": 2318470, "credited_invalid": 40112, "amount_micros": 1159235000000, "finalised_at": "2026-10-01T06:00:00Z" }
Money is an integer

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

05

Architecture overview

Click ingest, streaming aggregation and the reconciliation pathDiagram
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.

Click redirect

Stateless and spread across edge regions. It does not call any database. The token carries everything needed to build the event.

Flink aggregation job

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.

ClickHouse

Column store with minute and hour tables. Queries over months scan the hourly table; recent windows come from the minute table.

Reconciliation batch

Spark over the raw archive. It is slower and smarter, and it is the only writer of billing numbers.

The minute table

CREATE TABLE clicks_minute ( window_start DateTime, ad_id UInt64, campaign_id UInt64, advertiser_id UInt64, country LowCardinality(String), device LowCardinality(String), placement LowCardinality(String), valid_clicks UInt64, invalid_clicks UInt64, spend_micros Int64, version UInt64 -- stream writes 1..n, batch writes a higher epoch ) ENGINE = ReplicatedReplacingMergeTree(version) PARTITION BY toDate(window_start) ORDER BY (advertiser_id, campaign_id, ad_id, window_start, country, device, placement) TTL window_start + INTERVAL 7 DAY;

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

06

Deep dive: counting correctly in a stream

Three hard parts

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

Life of one click inside the stream jobDiagram
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.

Budget pacing is a separate consumer

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

07

How to handle the interview

1

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.

2

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.

3

Show how exactly once is assembled. Replayable log, checkpointed state, idempotent writes. Name the key that makes the sink idempotent.

4

Pre-aggregate before storage. Never write 185,000 rows a second into the analytics store. Reduce in the stream first.

5

Bring up hot keys. A single viral ad breaks key-by-ad partitioning. Salting and two-stage sums is the expected fix.

6

Keep the redirect fast and dumb. No database calls on the click path. A signed token and an async buffer are enough.

7

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

08

Designs that look fine and are not

🧮
Counting with a database increment per click

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.

Aggregate in the stream and write one idempotent row per key per window.
⏱️
Windows by arrival time

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.
🗑️
Throwing raw events away after aggregation

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.
🌊
Synchronous tracking before the redirect

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.
🎯
Treating fraud as a yes or no drop

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

09

Decisions and their costs

Chosen
Stream for speed, batch for the invoice

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 final
Alternative
Stream only (kappa style)

One 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 simple
Chosen
Column store for aggregates

ClickHouse 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 reads
Rejected
Wide-column store keyed by ad and minute

Writes scale well, but ad hoc group-bys across country and device turn into client-side scans and fan-out reads.

Poor for flexible reports
Chosen
One-minute windows with hourly rollups

Minute 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 used
Rejected
Keep raw events queryable for every report

Maximally flexible, but every dashboard load scans billions of rows and costs grow with time.

Too expensive per query
Chosen
Exact dedup with a one-hour keyed set

About 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 counts
Alternative
Bloom filter for dedup

Smaller state, but a false positive drops a valid paid click. For billing, the memory saving is not worth it.

Use only for non-billing counts

09

Failure modes

10

Failure modes

💾
Flink checkpoints start failing

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.
📉
A ClickHouse replica goes down

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 fraud rule fires on real users

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.
🐢
Late events beyond the lateness window

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.
📡
Kafka broker lost at peak

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.
💸
Budget overspend

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

12

Growing the pipeline in stages

Build it in this order

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.

Stage 1: under a million clicks a day
Log to a database, aggregate nightly

Insert raw clicks into Postgres, run a nightly SQL rollup, bill from that. Correct, cheap and easy to debug.

Stage 2: tens of millions a day
Kafka and a batch warehouse

The redirect writes to Kafka, events land in object storage and hourly Spark jobs fill a warehouse. Dashboards are an hour behind.

Stage 3: billions a day
Streaming aggregation plus reconciliation

Flink produces minute windows into ClickHouse, batch remains the billing source, and the hourly-only dashboard is gone.

Stage 4: global, real-time pacing
Regional ingest and live budget feedback

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.