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.

Case study34 minLesson 1 of 7
On this page
  1. Clarify the problem
  2. Estimate the scale
  3. Define the API
  4. Model the data
  5. High-level design
  6. Deep dives
  7. Bottlenecks and failures
  8. What a strong answer sounds like
  9. Follow-up questions
  10. What to practise next

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

The first five minutes
Interviewer

Design a system that aggregates ad clicks.

You

A user clicks, we record it and redirect them to the landing page, and advertisers see counts. At what granularity, and how fresh?

Interviewer

Clicks per ad per minute. Advertisers want to see a click on the dashboard within a few seconds.

You

And are we billing from these counts?

Interviewer

Yes. Advertisers pay per click.

You

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?

Interviewer

About 10 billion clicks a day across 1 million active ads.

You

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.
Estimate: Raw click storage

How much raw click data arrives per day? We keep it to recount clicks and to settle billing disputes.

You know
  • 10 billion clicks a day
  • About 300 bytes per raw click event

Show the working
  1. 1010 × 300 B = 3 × 1012 B = 3 TB a day
  2. 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.

Your turn: Define the API

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

HTTP 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=hour

The 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.

Your turn: Model the data

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

Records
-- 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 row

Clicks 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.

Your turn: High-level design

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.

Clicks become durable in the log before the redirect. The stream processor makes fresh counts; the batch job recounts from the archive and marks them final.User's browser to Click service (GET /c?t=...); Ad server to User's browser (signed token); Click service to Click log (append); Click log to Raw archive, asynchronous; Click log to Stream processor; Raw archive to Batch recount; Stream processor to OLAP store (upsert); Batch recount to OLAP store (final); Advertiser to Query service; Query service to OLAP store (sum by minute).User's browserAd serverClick serviceClick logRaw archiveStream processorBatch recountAdvertiserQuery serviceOLAP storeGET /c?t=...signed tokenappendupsertfinalsum by minute

request and replyasynchronous message

Clicks become durable in the log before the redirect. The stream processor makes fresh counts; the batch job recounts from the archive and marks them final.

Step through one click from impression to invoice, including a double click, a crash and a late arrival.

A click: count it once, keep it fresh, make it finalUse the arrows or ← → keys
  1. A click: count it once, keep it fresh, make it final, step 1User's browser to Click service; Ad server to User's browser; Click service to Click log; Click log to Raw archive, asynchronous; Click log to Stream processor; Raw archive to Batch recount; Stream processor to OLAP store; Batch recount to OLAP store; Advertiser to Query service; Query service to OLAP store.User's browserAd serverClick serviceClick logRaw archiveStream processorBatch recountAdvertiserQuery serviceOLAP store
    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.

  2. A click: count it once, keep it fresh, make it final, step 2User's browser to Click service; Ad server to User's browser; Click service to Click log; Click log to Raw archive, asynchronous; Click log to Stream processor; Raw archive to Batch recount; Stream processor to OLAP store; Batch recount to OLAP store; Advertiser to Query service; Query service to OLAP store.User's browserAd serverClick servicesignature okClick logRaw archiveStream processorBatch recountAdvertiserQuery serviceOLAP store
    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.

  3. A click: count it once, keep it fresh, make it final, step 3User's browser to Click service; Ad server to User's browser; Click service to Click log; Click log to Raw archive, asynchronous; Click log to Stream processor; Raw archive to Batch recount; Stream processor to OLAP store; Batch recount to OLAP store; Advertiser to Query service; Query service to OLAP store.User's browserAd serverClick serviceClick log3 copiesRaw archiveStream processorBatch recountAdvertiserQuery serviceOLAP store
    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.

  4. A click: count it once, keep it fresh, make it final, step 4User's browser to Click service; Ad server to User's browser; Click service to Click log; Click log to Raw archive, asynchronous; Click log to Stream processor; Raw archive to Batch recount; Stream processor to OLAP store; Batch recount to OLAP store; Advertiser to Query service; Query service to OLAP store.User's browserAd serverClick serviceClick logRaw archiveStream processor+1 at 10:05Batch recountAdvertiserQuery serviceOLAP store
    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.

  5. A click: count it once, keep it fresh, make it final, step 5User's browser to Click service; Ad server to User's browser; Click service to Click log; Click log to Raw archive, asynchronous; Click log to Stream processor; Raw archive to Batch recount; Stream processor to OLAP store; Batch recount to OLAP store; Advertiser to Query service; Query service to OLAP store.User's browserAd serverClick serviceClick logRaw archiveStream processor7f3a seen: dropBatch recountAdvertiserQuery serviceOLAP store
    ad 42 @ 10:05
    1,288
    duplicates dropped
    1

    5A second click follows. Its event reaches the same task, which finds 7f3a already seen and drops it.

  6. A click: count it once, keep it fresh, make it final, step 6User's browser to Click service; Ad server to User's browser; Click service to Click log; Click log to Raw archive, asynchronous; Click log to Stream processor; Raw archive to Batch recount; Stream processor to OLAP store; Batch recount to OLAP store; Advertiser to Query service; Query service to OLAP store.User's browserAd serverClick serviceClick logRaw archiveStream processorBatch recountAdvertiserQuery serviceOLAP store1,288 (draft)
    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.

  7. A click: count it once, keep it fresh, make it final, step 7User's browser to Click service; Ad server to User's browser; Click service to Click log; Click log to Raw archive, asynchronous; Click log to Stream processor; Raw archive to Batch recount; Stream processor to OLAP store; Batch recount to OLAP store; Advertiser to Query service; Query service to OLAP store.User's browserAd serverClick serviceClick logRaw archiveStream processorBatch recountAdvertiserQuery serviceOLAP store
    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.

  8. A click: count it once, keep it fresh, make it final, step 8User's browser to Click service; Ad server to User's browser; Click service to Click log; Click log to Raw archive, asynchronous; Click log to Stream processor; Raw archive to Batch recount; Stream processor to OLAP store; Batch recount to OLAP store; Advertiser to Query service; Query service to OLAP store.User's browserAd serverClick serviceClick logRaw archiveStream processorrestore: 1,270Batch recountAdvertiserQuery serviceOLAP store
    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.

  9. A click: count it once, keep it fresh, make it final, step 9User's browser to Click service; Ad server to User's browser; Click service to Click log; Click log to Raw archive, asynchronous; Click log to Stream processor; Raw archive to Batch recount; Stream processor to OLAP store; Batch recount to OLAP store; Advertiser to Query service; Query service to OLAP store.User's browserAd serverClick serviceClick logRaw archiveStream processorBatch recountAdvertiserQuery serviceOLAP store1,288 again
    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.

  10. A click: count it once, keep it fresh, make it final, step 10User's browser to Click service; Ad server to User's browser; Click service to Click log; Click log to Raw archive, asynchronous; Click log to Stream processor; Raw archive to Batch recount; Stream processor to OLAP store; Batch recount to OLAP store; Advertiser to Query service; Query service to OLAP store.User's browserAd serverClick serviceClick logRaw archiveStream processortoo lateBatch recountAdvertiserQuery serviceOLAP store
    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.

  11. A click: count it once, keep it fresh, make it final, step 11User's browser to Click service; Ad server to User's browser; Click service to Click log; Click log to Raw archive, asynchronous; Click log to Stream processor; Raw archive to Batch recount; Stream processor to OLAP store; Batch recount to OLAP store; Advertiser to Query service; Query service to OLAP store.User's browserAd serverClick serviceClick logRaw archiveStream processorBatch recountAdvertiserQuery serviceOLAP store1,289 final
    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.

1 / 11

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.

Where the counting happens
OptionGood atBad atPick it when
Increment a row per clickSimple, 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 onlySimple 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 defaultFresh 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_at enforces that). An impression belongs to one ad and the log is keyed by ad_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.

Where to remember which impressions were clicked
OptionGood atBad atPick it when
Exact set in processor state Usual defaultLocal 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 filterTen 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 absentAny 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 diesWhat happensWhat you do
A click serverClicks in flight fail.Health checks remove it; a retry hits another server, and dedup drops any copy already logged.
The click logAppends 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 taskCounts for its ads stop updating.Restart from the last checkpoint and replay; counts are exact again within a minute or two.
The OLAP storeDashboards show nothing new.The processor retries its upserts; replicas serve reads. The log still holds every event.
The batch recountMinutes stay provisional.Billing waits for final rows; rerun the job, which is idempotent because it overwrites.
The signing key leaksForged 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

LevelWhat the answer does
JuniorCounts with a database row per ad; with prompting, adds a queue so the redirect is not slowed.
MidUses a log and a stream processor with one-minute windows, writes to an analytics store, and handles duplicates with an id once asked.
SeniorSplits 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.
Exactly onceStrong answer
Interviewer

How do you guarantee a click is billed exactly once?

You

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.

Interviewer

And if the stream still gets it wrong?

You

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.

Self-review: ad click aggregator0 of 12

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.