SYSTEM DESIGN · DESIGN PROBLEMS · COUNTING AND RANKING · HARD · 36 OF 41

Design an Ad Click Aggregator: Fast Redirects, Exactly-Once Counts and Billing-Grade Accuracy

How to count ad clicks for billing and live dashboards at fifty thousand clicks a second: a redirect path that never loses a click, signed impression IDs for deduplication and fraud checks, per-ad per-minute stream aggregation with event time and watermarks, exactly-once sinks, salted keys for hot ads, an OLAP store for advertiser queries, and a nightly reconciliation that billing trusts.

Reported atNetflix

  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
  12. 12
  13. 13
  14. 14
  15. 15
  16. 16
  17. 17
  18. 18
  19. 19
  20. 20
  21. 21
  22. 22
  23. 23
  24. 24
  25. 25
  26. 26
  27. 27
  28. 28
  29. 29
  30. 30
  31. 31
  32. 32
  33. 33
  34. 34
  35. 35
  36. 36
  37. 37
  38. 38
  39. 39
  40. 40
  41. 41
1 / 19

Press play to hear the narration, or step through at your own pace.

Notes

Listen

Listen. When a user clicks an ad, we record it and redirect them to the advertiser's page. Advertisers query clicks per ad per minute, filtered by country, device or campaign. Billing needs exact daily totals. Ad selection, auctions and impression counting are out of scope, though impressions use the same pipeline.

Qualities. The redirect must be fast, under fifty milliseconds at p ninety-nine, or users bounce. No click lost, none counted twice. Dashboards can be a minute behind and should answer in a second, even over ninety days. Billing must be exact but can wait a day. And bot clicks must be filtered out.

Functional
  • user clicks an ad →
  • record it, redirect to the
  • advertiser's page
  • advertisers query clicks per
  • ad per minute, by country,
  • device, campaign
  • billing: exact daily totals
  • out of scope: ad serving,
  • auctions, impressions
Non-functional
  • redirect p99 ≤ 50 ms
  • never lose a click
  • never count a click twice
  • dashboards ≤ 1 min behind
  • queries ≤ 1 s over 90 days
  • billing: exact, can be
  • a day late
  • filter fraud and bots

Ask scale

Scale. A billion clicks a day is ten thousand a second, fifty thousand during a big live event. Each raw click is about two hundred bytes, so two hundred gigabytes a day, over seventy terabytes a year. The aggregates are much smaller: around three hundred million ad per minute rows a day, rolled up to hours after thirty days.

Back of the envelope
1B clicks/day
  1B / 10⁵ s = 10,000 clicks/s
  peak (big events) ×5 = 50,000/s

raw click ≈ 200 B
  1B × 200 B = 200 GB/day
  ≈ 73 TB/year raw

aggregates: ad × minute × dims
  ≈ 300M rows/day, roll up hourly
  after 30 days

Define API

The API. The click link carries an impression id, the ad id and a signature, all created when the ad was served. It returns a three oh two redirect. Advertisers get a query endpoint with a time range, granularity and optional group by, and billing reads exact daily totals from a separate endpoint.

API
GET /c?imp=8f3a…&ad=501&sig=…
  → 302 Location: advertiser URL

GET /v1/ads/{id}/clicks
  ?from=…&to=…
  &granularity=minute|hour|day
  &group_by=country
→ [{ t, country, clicks }]

GET /v1/billing/{account}/daily

Data model

Data model. Each raw click is an immutable event, kept in Kafka and then in a data lake. Dashboards read a pre-aggregated table in a column store such as ClickHouse or Druid: one row per ad, minute and dimension, with click counts and unique users estimated with HyperLogLog. Billing totals live in a small relational table.

Data model
click event (Kafka + lake)
  click_id, impression_id, ad_id,
  campaign_id, user_hash, ip,
  country, device, event_time

ad_clicks_minute (OLAP)
  ad_id, minute, country, device
  clicks, unique_users (HLL)
  sort key (ad_id, minute)

billing_daily (SQL), exact

High-level design

The high-level design. The click service validates the link, appends the click to Kafka, then redirects. A stream aggregator such as Flink groups clicks by ad and minute and writes rows to the column store. The dashboard queries that store. Raw events are also copied to the lake, which we will use later.

Why not insert every click into a database and run group by when advertisers ask? Because a month is thirty billion rows, and every dashboard refresh would scan them. Aggregating once, in the stream, turns a billion clicks into a few hundred million small rows that are already grouped. Dashboards read sums, never raw events.

Why not query raw clicks?
  • GROUP BY ad, minute over
  • 30B rows a month per query
  • → seconds to minutes, costly
  • pre-aggregate once in the
  • stream: 1B clicks → 300M
  • small rows, already grouped
  • dashboards read sums, never
  • raw events

Deep dive: the click

Deep dive one: the click itself. Write to Kafka with acks set to all, meaning every in-sync replica has it, before you send the redirect. That costs a few milliseconds. If Kafka is slow, append to a local disk spool, redirect anyway, and replay the spool later. The user never waits on aggregation or a database.

Next, duplicates and fraud. When the ad is served, attach a random impression id and an H M A C signature over it and the ad. A click with a bad signature is a forged link and is dropped. A second click with the same impression id within an hour is a duplicate. Add simple bot rules, like one IP clicking a hundred times a minute.

Never lose a click
  • write to Kafka with acks=all
  • (every in-sync replica) first
  • Kafka slow? append to a
  • local disk spool, redirect,
  • replay the spool later
  • never block the user on
  • counting or databases
Dedupe and fraud
at ad serve time:
  imp_id = random 128-bit
  sig = HMAC(secret, imp_id|ad)

at click time:
  bad sig → drop (forged link)
  imp_id seen in last 1 h → dup
    (state per key with TTL)

bot rules: same IP 100×/min,
  no impression, datacentre IPs

Deep dive: event time

Deep dive two: time. A phone in a tunnel can deliver a click six minutes late. Group by event time, when the click happened, not when it arrived. A watermark tells the aggregator when a minute is probably complete, so it can emit that row. Allow a few minutes of lateness by updating the row. Anything later is left for the nightly batch to fix.

Event time and late clicks
  • phone offline: click 6 min late
  • window by event_time,
  • not processing time
  • watermark: "all clicks up to
  • 14:05 have probably arrived"
  • → close the 14:05 window
  • allow 5 min late; later → batch

Deep dive: exactly once

Deep dive three: exactly-once. The aggregator checkpoints its window state and its Kafka offsets together. After a crash it restores and replays from those offsets. That only works if the output is idempotent, so write the full count for each ad and minute as an upsert, never as plus some delta. Replaying then rewrites the same row with the same number.

Exactly-once counting
  • Flink checkpoints window state
  • and Kafka offsets together
  • crash → restore checkpoint,
  • replay from saved offsets
  • sink must not double-add:
  • upsert by (ad, minute, dims)
  • with the full count, or
  • a transactional sink
  • → replaying writes same row

Deep dive: hot ads

Hot ads. A halftime ad during a big final can draw thirty thousand clicks a second, all on one key and one task. Salt the key: add a small suffix from zero to seven, so eight tasks share that ad. A second step adds the eight partial counts for each minute. Only salt ads above a threshold, so ordinary ads stay simple.

Salt the hot key
key = ad_id → one task

hot ad: key = ad_id + "#" +
  (click_id mod 8)
→ 8 tasks share the load

second step sums the 8
partial counts per minute

salt only ads over a threshold

Deep dive: queries

Querying. A column store reads only the columns a query needs and compresses them well. Sorting by ad id, then time, means one advertiser's ninety days is a small contiguous range. Keep minute rows for thirty days and hourly rows for two years. A query over months reads the hourly table and comes back in a fraction of a second.

Fast advertiser queries
  • column store: read only the
  • columns asked for, compressed
  • sorted by (ad_id, time)
  • → one ad = one small range
  • roll-ups:
  • minute rows for 30 days
  • hour rows for 2 years
  • query picks the coarsest fit

Deep dive: reconcile

Reconciliation. Every night a batch job reads yesterday's raw clicks from the lake, deduplicates fully, includes the very late events and applies the latest fraud rules. It compares its totals with the stream totals per ad. A gap above a tenth of a percent raises an alert. Billing uses only the batch numbers, and the dashboard is corrected to match.

Nightly reconciliation
  • batch over raw clicks:
  • full dedupe, late events,
  • updated fraud rules
  • compare with stream totals
  • per ad per day
  • diff ≥ 0.1% → alert
  • billing uses batch numbers
  • and corrects the dashboard

Trade-offs

Failure modes. If Kafka is slow, the spool keeps redirects fast. If the aggregator is down, dashboards go stale but every click sits safely in Kafka, which keeps seven days. If the column store is down, replay its writes later. And if the counting logic has a bug, fix it and replay history from the lake. Speed can be lost, clicks cannot.

Monitor redirect latency, consumer lag, the share of late and dropped events, the stream versus batch difference, and duplicate and fraud rates. At ten times the traffic, add partitions and salt more keys, then run a click pipeline per region, keeping clicks close to users, with a global roll-up only for billing.

Failure modes
  • Kafka slow → local spool,
  • redirect still fast
  • aggregator down → dashboards
  • stale, clicks safe in Kafka
  • (keep 7 days retention)
  • OLAP down → queue writes,
  • replay from Kafka
  • bug in stream logic → fix,
  • replay from lake or Kafka
Monitor and grow
  • monitor:
  • redirect p99 and errors
  • consumer lag (seconds)
  • late and dropped events
  • stream vs batch diff
  • duplicate and fraud rates
  • at 10x:
  • more partitions + salting
  • regional clusters, global
  • roll-up for billing

Say it like this

In the interview: the click goes to a durable log before the redirect. Signed impression ids catch duplicates and forged links. A stream job aggregates by ad and minute using event time and watermarks, with checkpoints and an upsert sink for exactly-once counts. Salt hot ads, serve dashboards from a column store with roll-ups, and bill from a nightly reconciled batch.

Interview summary
  • log to Kafka, then 302
  • signed impression id:
  • dedupe + forged-link check
  • Flink: ad × minute, event
  • time, watermarks, lateness
  • checkpoint + upsert sink
  • = exactly-once counts
  • salt hot ads
  • OLAP with minute/hour roll-ups
  • nightly batch → billing

Interview questions

Why aggregate in a stream instead of running GROUP BY on raw clicks?

Raw clicks reach tens of billions of rows per month, so ad-hoc GROUP BY queries would be slow and expensive; aggregating once per ad and minute in the stream gives dashboards small pre-computed rows to read.

How do you make sure a click is never lost?

The click service writes the event to a replicated Kafka topic with acks=all before returning the redirect, and falls back to a local disk spool that is replayed later if Kafka is slow.

How do you deduplicate clicks?

Each served ad carries a unique impression ID signed with an HMAC; the pipeline drops clicks with invalid signatures and ignores repeat clicks with the same impression ID within a time window, kept as keyed state with a TTL.

What is the difference between event time and processing time here?

Event time is when the user clicked and processing time is when the event reached the aggregator; windows use event time with watermarks so late-arriving clicks are counted in the minute they happened.

How do you achieve exactly-once aggregation?

Flink checkpoints window state together with Kafka offsets and replays from them after a crash, and the sink upserts the full count for each (ad, minute, dimensions) key, so replays overwrite rather than add.

How do you handle one ad receiving a huge share of clicks?

Salt the aggregation key for ads above a threshold, for example ad_id plus click_id mod 8, so several tasks share the load, then sum the partial counts per minute in a second step.

Why run a batch reconciliation if the stream is exactly-once?

The batch job sees very late events, applies updated fraud rules and catches bugs in the stream logic, so billing uses its exact totals and alerts fire when stream and batch numbers diverge.

Which store would you use for advertiser queries and why?

A column-oriented OLAP store such as ClickHouse or Druid, sorted by ad ID and time with minute and hourly roll-ups, because it scans only the needed columns and answers time-range aggregations in under a second.