Design an Ad Click Aggregator
Count ad clicks per minute, fresh in seconds and exact for billing: a click log, windowed stream processing, dedup, hot ads, late events and a recount.
On this page
Someone taps an ad and lands on the advertiser's site, and a counter for that ad goes up by one. The advertiser watches those counters on a dashboard and is billed for every click. Count too few and the platform loses money. Count too many and it overcharges a customer, which is worse.
Interviewers ask this because counting gets hard at this scale: over a hundred thousand events a second, a few ads that get most of them, clicks that arrive late or twice, and a number that must be both fresh and exact. The steps follow A Framework for Any Design. Try each "Your turn" box before you read on.
Clarify the problem
Design a system that aggregates ad clicks.
A user clicks, we record it and redirect them to the landing page, and advertisers see counts. At what granularity, and how fresh?
Clicks per ad per minute. Advertisers want to see a click on the dashboard within a few seconds.
And are we billing from these counts?
Yes. Advertisers pay per click.
Then there are two accuracy needs: the dashboard can show a provisional number, but the billed number must count every valid click exactly once. How many clicks and ads?
About 10 billion clicks a day across 1 million active ads.
I'll keep the redirect fast and never let counting slow it down, and I'll treat fraud detection as a separate system that can mark clicks invalid later.
- Functional: record a click and redirect the user to the ad's landing page; let advertisers query click counts per ad per minute over any time range; produce final counts for billing.
- Non-functional: the redirect adds under 50 ms; a click shows on the dashboard within about 10 seconds; billed counts are exact, with each valid click counted once; no accepted click is lost; the system survives the loss of any one machine.
- Out of scope: choosing which ad to show, fraud scoring (we leave a hook for it), unique-user counts, the billing system itself.
Estimate the scale
The scenario: 10 billion clicks a day, 1 million active ads, about 300 bytes per raw click event (ids, timestamps, a device hash, IP and user agent).
- Click rate: 1010 ÷ 86,400 s ≈ 116,000 clicks a second on average. Assume peaks of 3x: about 350,000 a second.
- Ingest bandwidth: 350,000 × 300 B ≈ 105 MB/s at peak. Easy for a partitioned log; impossible as single-row database increments.
- Aggregate rows: at most one row per ad per minute: 106 × 1,440 = 1.44 billion rows a day. At about 40 bytes each that is about 58 GB a day, a ceiling, since a minute with no clicks writes no row. The average ad gets 1010 ÷ 1.44 × 109 ≈ 7 clicks a minute.
- Partitions: if one log partition and one processor task comfortably handle 10,000 events a second (the scenario's assumption), the peak needs 350,000 ÷ 10,000 = 35. Use 64 for headroom: about 5,500 a second each, if clicks spread evenly. They do not, which is the hot-ad deep dive.
How much raw click data arrives per day? We keep it to recount clicks and to settle billing disputes.
- 10 billion clicks a day
- About 300 bytes per raw click event
Show the working
- 1010 × 300 B = 3 × 1012 B = 3 TB a day
- Kept 90 days for disputes: 3 TB × 90 = 270 TB, which belongs in object storage, not in the query store
Estimate 3 TB (anything from 1 TB to 10 TB is a good answer)
The raw log is 50 times the size of the minute aggregates (3 TB versus 58 GB a day). Queries read aggregates; only recounts read raw data.
Write the click endpoint and the query endpoint. What does the click URL carry, and where does the landing-page address come from?
Compare with a model answer
The click is a GET that returns a 302 redirect; the query is a read over a time range. The URL carries a signed impression token, and the landing page is looked up from the ad id, never taken from the URL, or the click service becomes an open redirect anyone can abuse.
Define the API
GET /c?t={signed impression token}
302 Found Location: the ad's landing page (looked up by ad_id)
The click is recorded before the response. A bad or expired token still
redirects, but the click is logged as invalid and never counted.
GET /v1/ads/{ad_id}/clicks?from=2026-11-14T10:00Z&to=2026-11-14T11:00Z
200 { "ad_id": 42, "minutes": [
{ "minute": "10:05", "clicks": 1288, "final": false }, ... ] }
403 Forbidden the ad belongs to another advertiser
400 Bad Request range longer than 31 days at minute granularity
GET /v1/advertisers/{id}/clicks?from=...&to=...&group_by=ad&granularity=hourThe token is the decision worth arguing. When the ad server shows an ad, it creates a random impression id and signs {impression_id, ad_id, shown_at} with a secret key (an HMAC). The click service checks the signature with no database call, which proves we really served that impression, and the impression id gives every click a natural key for spotting duplicates. Without it, a script can invent clicks on a competitor's ad.
Sketch the raw click event and the aggregate record. Which store holds each, and what is the key of the aggregate?
Compare with a model answer
Raw events live in an append-only log and then in object storage. Aggregates are one row per (ad, minute) in a columnar analytics store, keyed so that a write for the same (ad, minute) replaces the old value instead of adding to it.
Model the data
-- click event: in the log, then in object storage (about 300 B)
impression_id BINARY(16) -- random, from the signed token
ad_id BIGINT
advertiser_id BIGINT
shown_at TIMESTAMP -- from the token
clicked_at TIMESTAMP -- event time, set by the click service
device_hash, ip, user_agent, valid (bool)
-- aggregate: one row per ad per minute (about 40 B)
ad_clicks_minute
advertiser_id BIGINT
ad_id BIGINT
minute TIMESTAMP
clicks BIGINT
final BOOLEAN -- set by reconciliation
ORDER BY (advertiser_id, ad_id, minute) -- upsert replaces the rowClicks are written once, read in order by the processor, and read again in bulk by recounts: an append-only, partitioned log (such as Kafka), copied to object storage. Queries are "sum clicks for these ads over this range, grouped by minute or hour": a scan over a few columns of many rows, which a columnar analytics (OLAP) store such as ClickHouse or Druid does well. A key-value store cannot sum a range at all.
Store clicks as an absolute value that a later write replaces, never as an increment, so every writer is safe to retry.
Sketch the boxes from the click to the dashboard. Where is a click made durable before the redirect, where is it counted, and where does a recount come from?
Compare with a model answer
A stateless click service writes each click to a partitioned log and then redirects. A stream processor reads the log, drops duplicates, counts per ad per minute and writes to an OLAP store. The log is also archived to object storage, where a batch job recounts and overwrites the stream's numbers with final ones.
High-level design
Two paths compute the same numbers. The stream is fast and nearly always right; the batch recount is slower and is the truth for billing. Both write one OLAP table, and the batch write wins.
request and replyasynchronous message
Step through one click from impression to invoice, including a double click, a crash and a late arrival.
- impression
- 7f3a
- ad
- 42
1The ad server shows ad 42 with a signed token in its click URL: impression
7f3a, shown at 10:04:40. Nothing is stored; the signature is the proof.- impression
- 7f3a
- clicked_at
- 10:05:12
2At 10:05:12 the user clicks. The click service checks the HMAC in memory and stamps the event time.
- impression
- 7f3a
- redirect
- 302
3It appends the event to ad 42's log partition and waits for 3 replicas, a few milliseconds. Only then does it answer 302, so an accepted click cannot be lost.
- ad 42 @ 10:05
- 1,288
4The processor task for that partition has not seen
7f3a, so it records it and adds 1 to ad 42's 10:05 window.- ad 42 @ 10:05
- 1,288
- duplicates dropped
- 1
5A second click follows. Its event reaches the same task, which finds
7f3aalready seen and drops it.- ad 42 @ 10:05
- 1,288
6Every 5 seconds the processor upserts the open window: the row (42, 10:05) is replaced with 1,288 and
final = false.- ad 42 @ 10:05
- 1,288
- click to screen
- about 6 s
7The dashboard reads the last hour for ad 42 and shows 10:05 as provisional, about 6 seconds after the click.
- ad 42 @ 10:05
- 1,270 restored
8The task crashes. Its last checkpoint saved log offsets, counts and dedup state together, at 1,270. A new task restores it and replays from that offset.
- ad 42 @ 10:05
- 1,288
9Replay rebuilds exactly 1,288, because dedup state and counts rewound together. The upsert writes 1,288 again: nothing doubled.
- ad 42 @ 10:05
- 1,288
- late clicks
- 1
10At 10:09 a click stamped 10:05:58 arrives from a click server that spooled it to disk. The 10:05 window closed at 10:08, so it goes to a late-events side output.
- ad 42 @ 10:05
- 1,289 final
- stream was off by
- 1
11The hourly recount reads the archive for 10:00 to 11:00 with the same rules and finds 1,289 for (42, 10:05), the late click included. It overwrites the row as final.
Deep dives
The counting pipeline
The obvious design, UPDATE ads SET clicks = clicks + 1 per click, means 350,000 row writes a second, and a popular ad turns one row into a lock everyone waits for. Count in memory instead, and write totals.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| Increment a row per click | Simple, and the count is current. | One write per click; a hot ad makes one row the bottleneck. A retried request counts twice. | A few clicks a second. Not this question. |
| Batch job over the logs only | Simple and exact: recount from raw events every hour. | Counts are an hour or more old, which fails the 10-second requirement. | Billing only, with no live dashboard. |
| Stream processor, plus batch recount Usual default | Fresh counts in seconds from windowed in-memory state; the recount fixes anything the stream got wrong. | Two code paths that must apply the same rules, and more moving parts to run. | Fresh dashboards and exact billing. The usual answer. |
The stream processor (such as Flink) keys events by ad_id, so one ad's clicks reach one task. Each task keeps, per ad, a count for each open one-minute tumbling window (fixed, non-overlapping minutes). Windows use event time, the clicked_at stamp, so a backlog after an outage still lands clicks in the right minute. For freshness, the task emits each open window every 5 seconds instead of waiting for the minute to close.
The log makes this recoverable: it keeps 7 days of events (3 TB × 7 = 21 TB, 63 TB with three replicas), so any consumer can rewind.
Counting each click once
Duplicates come from users double-clicking, click servers retrying an append whose acknowledgement was lost, and the processor replaying after a crash.
- Users and retries: the processor keeps every impression id it has seen in the last 24 hours, the longest a click is valid after its impression (the token's
shown_atenforces that). An impression belongs to one ad and the log is keyed byad_id, so both copies of a click reach the same task. The cost: 1010 ids a day × 16 B = 160 GB, about 2.5 GB per task across 64 partitions, kept in a disk-backed state store. - Processor crashes: the processor checkpoints log offsets, window counts and the dedup set together. On restart it rewinds all three to the same point and replays, so its state ends up as if each event had been processed once.
- Writes to the OLAP store: the replay re-sends rows the store already has. Because each write replaces the row for (ad, minute) with an absolute count, writing it twice changes nothing.
Be precise: the log delivers at least once. What you build is exactly-once effect: dedup by a business key plus idempotent writes. "The broker gives exactly-once", with nothing about how the sink handles a replay, is a weak answer.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| Exact set in processor state Usual default | Local lookups; checkpointed with the counts, so a replay stays consistent. | Memory and disk grow with the clicks in the validity window: 2.5 GB per task here. | Billing-grade counts. The default. |
| Bloom filter | Ten times smaller or more for the same ids. | False positives drop real clicks. At a 0.1% rate that is 1010 × 0.001 = 10 million real clicks a day never billed. | Approximate dashboards where undercounting slightly is fine. |
| Shared key-value store, set if absent | Any task, or the batch job, can check it. | A network call per click, and its writes are not rolled back on replay, so a replayed click looks like a duplicate and is lost. | Several independent consumers must share one dedup decision. |
Hot ads and partition skew
Keying by ad_id puts all of one ad's clicks on one log partition and one task. Fine for 7 clicks a minute; not for an ad during a live final drawing 30,000 a second. The partition and its task were sized for 10,000, so they fall behind, and so does every other ad on them.
Split the hot key in two stages. The click service keys a hot ad's events by (ad_id, salt), with salt = hash(impression_id) mod 8, so ad 42's clicks spread over 8 partitions and tasks of about 3,750 a second each. A second stage keyed by ad_id keeps the latest partial count from each of the 8 and writes their sum: 8 records every 5 seconds instead of 1.8 million clicks a minute. The salt comes from the impression id, not a random number, so both copies of a double click still meet in one task.
Salt only the ads on a hot list that the click service refreshes every minute. One gap remains: if an ad joins the list between the two halves of a double click, the copies reach different tasks and the stream counts both. The recount removes it.
Late events and the recount
Some clicks arrive late: a click server spooled them to disk while the log was unreachable. The processor tracks a watermark, its estimate that "no more events older than this will come", and keeps each minute open until the watermark is 2 minutes past its end. A later event goes to a side output and is left to the recount.
The batch recount runs every hour on the hour before last, reading the archived raw events with the same signature, validity and dedup rules. It carries the last 24 hours of seen impression ids from run to run, so a double click that straddles an hour still counts once. It overwrites the stream's rows and sets final = true. A click that arrives after its hour is final reruns that hour, and billing gets an adjustment. Later fraud verdicts fit here too: the recount excludes clicks marked invalid. Billing reads only final rows. Alert if the two paths disagree by more than, say, 0.1% in an hour; that means a bug in one of them.
Bottlenecks and failures
At 10x, about 3.5 million clicks a second at peak, add click servers and go to 640 partitions at the same 5,500 a second each; dedup state stays 2.5 GB per task. The OLAP store gets more shards by advertiser. More ads cross the hot threshold, so salting becomes routine.
| When this dies | What happens | What you do |
|---|---|---|
| A click server | Clicks in flight fail. | Health checks remove it; a retry hits another server, and dedup drops any copy already logged. |
| The click log | Appends fail or time out. | Still redirect the user. Spool the click to local disk and append it when the log is back; it arrives late and the recount picks it up. |
| A stream task | Counts for its ads stop updating. | Restart from the last checkpoint and replay; counts are exact again within a minute or two. |
| The OLAP store | Dashboards show nothing new. | The processor retries its upserts; replicas serve reads. The log still holds every event. |
| The batch recount | Minutes stay provisional. | Billing waits for final rows; rerun the job, which is idempotent because it overwrites. |
| The signing key leaks | Forged tokens pass the check. | Rotate keys; tokens carry a key id so old and new keys overlap during rotation. |
What a strong answer sounds like
| Level | What the answer does |
|---|---|
| Junior | Counts with a database row per ad; with prompting, adds a queue so the redirect is not slowed. |
| Mid | Uses a log and a stream processor with one-minute windows, writes to an analytics store, and handles duplicates with an id once asked. |
| Senior | Splits fresh from final accuracy up front, uses signed impression ids, explains exactly-once effect as dedup plus idempotent writes and checkpoints, handles hot ads and late events, and makes a recount the source of truth for billing. |
How do you guarantee a click is billed exactly once?
Delivery is at least once, so I make the effect exactly once. Every click carries a signed impression id. The processor keys by ad, so both copies of a click meet in one task, which keeps a set of seen ids for 24 hours. The set, the counts and the log offsets are checkpointed together, so a crash rewinds all three. Writes to the store replace the row for that ad and minute, so a replay rewrites the same value.
And if the stream still gets it wrong?
An hourly recount from the raw archive overwrites the stream's numbers and marks them final, and billing reads only final rows. The stream is for freshness; the recount is for money.
Follow-up questions
Advertisers want counts by country and device too. What changes?
Add those columns to the aggregate key. Rows multiply by the combinations that occur, so keep the minute table to a few dimensions and serve finer breakdowns from hourly rollups.
How do you stop click fraud?
A separate system reads the same log, scores clicks (rate per device and IP range, impossible timing) and writes verdicts that the recount applies.
Why not query the raw events directly?
A month for one advertiser can be billions of rows. Minute aggregates are about 50 times smaller than raw data (58 GB versus 3 TB a day).
How long do you keep minute-level rows?
About 30 days (58 GB × 30 ≈ 1.7 TB), then roll up to hourly rows, 60 times fewer, kept for years.
How would you run it in several regions?
Run click services and a log per region, and ship raw events to one region for the recount. The impression id still deduplicates across regions, because it was minted once.
After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".
Before drawing
Numbers and interfaces
Design and depth
Failure
What to practise next
Design Top-K Trending counts at the same scale but trades exactness for memory, and Design Metrics Monitoring stores the counts as time series and alerts on them.