📊 System Design ad click aggregation
📊 Part IV · Modern Case Studies · chapter 20 / 24

Billions of clicks,
counted live

An ad click aggregator turns an endless firehose of click events into running counts — clicks per ad per minute, top-N ads, filtered every which way — fast enough to act on. This is the canonical stream-processing problem: windowing, late data, exactly-once, and keeping a fast-but-fuzzy number honest.

A single ad on a big platform can be clicked millions of times an hour. Advertisers want to see — almost immediately — how their campaigns are doing, and the platform needs those numbers to bill correctly and to spot fraud. So the job is: take an unbounded stream of click events and keep accurate running totals, grouped by ad and by time, that anyone can query in seconds.

This sounds like a database problem, but it isn't really. The clicks never stop arriving, so you can't just "run a query at the end" — there is no end. Instead you process the stream as it flows, folding each event into a running tally. That shift — from querying data at rest to continuously aggregating data in motion — is what stream processing is, and this chapter is the canonical example of it. It builds directly on the message queue from Chapter 9: the queue is the firehose; everything here is what you bolt onto the end of it.

1What "counting clicks" actually means

Before any boxes-and-arrows, pin down what the system must answer. For an ad click aggregator the core queries are narrow and well-defined:

  • Clicks per ad per minute. Given an ad_id and a minute, how many clicks? This is the atomic unit everything else is built from.
  • Top-N ads. Over the last M minutes, which ads got the most clicks? Useful for dashboards and for spotting a campaign suddenly going viral (or being click-bombed).
  • Filter by attributes. Slice those counts by country, device, advertiser, or campaign — "clicks for ad 42 from mobile users in Germany this hour."

Then the question that shapes the whole architecture: how fresh, and how exact? These two pull against each other.

  • Near-real-time means the number you see is a few seconds behind reality. Great for a live dashboard, but a count that's still "settling" can be slightly wrong — a late or duplicate click hasn't been reconciled yet.
  • Exact means the number is the true, final count — what you bill the advertiser. That requires waiting for stragglers and de-duplicating, which takes time.

The standard answer is to provide both: a fast approximate number for the dashboard, and a slower exact number for billing that corrects the fast one later. Hold that thought — it's the Lambda architecture in section 5.

📐
Scale check (worth doing in your head): 1 billion clicks/day is about 12,000 clicks per second average, with peaks several times higher. Each event is tiny (~100 bytes), but you can't afford a database write per click on the hot path — you aggregate first, then store the much smaller per-minute totals.

2The pipeline: firehose in, counts out

The shape of every system like this is a conveyor belt of stages, each doing one job and handing off to the next. Reading left to right:

  • Click events are emitted by the ad servers the instant a user clicks — a small record like {ad_id, ts, user, country, device}.
  • A message queue (Kafka is the usual choice — see Ch.9) sits at the front and swallows the firehose. It's a durable, replayable buffer: producers append, the rest of the pipeline reads at its own pace. If a downstream stage is slow or crashes, events pile up safely in the log instead of being lost.
  • Stream aggregation reads from the queue and does the real work: folding events into running counts keyed by (ad_id, minute). This is the heart of the system (sections 3–4).
  • An aggregation database stores those compact per-minute totals — far smaller than the raw stream.
  • A query service sits in front of the aggregation DB and answers the dashboard's questions.

One more box, easy to forget and impossible to live without: keep the raw events too. Alongside the aggregation, write every raw click to cheap bulk storage (a data lake on blob storage or HDFS). You'll need it when — not if — you have to reprocess: a bug in the aggregation logic, a new attribute you forgot to count, or a billing dispute. Aggregates are derived; raw events are the source of truth. Throw away the source and a wrong number can never be made right.

The ingestion pipeline · stage by stage A buffer up front, raw events kept on the side
The queue decouples the firehose from the aggregator; the raw store on the side is what makes reprocessing possible.
λ
The FP framing: the whole pipeline is a fold over a stream. The queue is the (infinite, replayable) list of events; the aggregator's state is the accumulator; each event updates the accumulator. Because the source list is durable and replayable, the fold is reproducible — re-run it on the same events and you get the same counts. That reproducibility is the property everything else in this chapter leans on.

3Windowing: chopping an endless stream into buckets

"Clicks per minute" implies cutting the never-ending stream into minute-sized buckets and counting each. That cut is a window, and there are two common kinds:

  • Tumbling windows are fixed, back-to-back, non-overlapping slots: 12:00–12:01, 12:01–12:02, and so on. Every event lands in exactly one window. This is what "clicks per minute" wants, and it's the simplest to reason about.
  • Sliding windows overlap: "clicks in the last 5 minutes," recomputed every minute. Each event lands in several windows. Great for smooth trailing metrics; more bookkeeping.

Now the hard part. Events arrive over a network from millions of devices, so they show up out of order and late. A click that happened at 12:00:59 might not reach your aggregator until 12:01:30 — after you'd want to close and report the 12:00–12:01 window. When do you decide a window is "done"?

The answer is a watermark: a moving threshold that says "I believe I've now seen all events up to time T." When the watermark passes a window's end, you close that window and emit its count. The watermark trails real time by a grace period (say, 30 seconds) to wait for stragglers. Set it too tight and you drop late clicks; too loose and your numbers lag. Mechanically, aggregation is map-reduce in miniature: map each event to a key (ad_id, ts/60_000), then reduce by summing — partial sums computed per partition, combined into the final count.

Interactive · windowed aggregation & a late event Slide the window width; a late click arrives past the watermark
2 s
watermark idle
windows closed
0
on-time clicks counted
0
late clicks
0
⚠️
Event time vs processing time. Always bucket by when the click happened (event time, carried in the event), never by when you happened to process it (processing time). Bucket by processing time and a network hiccup silently moves clicks into the wrong minute — your per-minute graph becomes a graph of your own lag, not of user behavior.

4Counting each click exactly once

A queue like Kafka gives you at-least-once delivery: it guarantees a message won't be lost, but it may be delivered more than once. If a consumer reads a batch, adds it to the counts, then crashes before recording how far it got, it'll re-read that batch on restart and count those clicks twice. For billing, double-counting is as bad as losing.

Turning at-least-once into effectively-once takes three cooperating tricks:

  • Idempotent processing. Design the update so that applying the same event twice has the same effect as applying it once. Counting isn't naturally idempotent (a += 1 done twice gives +2), so you make it idempotent by combining it with the next two tricks.
  • Deduplication. Give every click a unique id (the ad server stamps it). The aggregator keeps a set of recently-seen ids (within the watermark window) and ignores any id it's already counted. Now even genuine duplicates — the same click event re-sent — land only once. This also catches some click fraud (section 8).
  • Checkpointing & offset commits. The trick that ties it together: commit your read position (the Kafka offset) and your aggregated state together, atomically. After a crash you resume from the last checkpoint, replaying only events not yet folded into the saved state. State and progress move in lockstep, so a replay re-does work without re-counting it.

"Exactly-once" is really "at-least-once delivery + idempotent, checkpointed processing." Nobody delivers exactly once over an unreliable network; you achieve the effect of exactly-once by making redundant deliveries harmless.

λ
Idempotence is the whole game. An idempotent fold step f(state, event) where f(f(s, e), e) == f(s, e) means you can safely replay the tail of the stream after any crash. Purity + idempotence is what makes "just replay it" a valid recovery strategy instead of a corruption risk.

5Fast-but-fuzzy vs slow-but-exact: Lambda & Kappa

Back to the freshness-vs-exactness tension. The classic resolution is the Lambda architecture: run two paths over the same event stream.

  • The speed layer processes the stream in real time and produces an approximate count within seconds. It may miss late clicks, may briefly double-count — but it's instant. This feeds the live dashboard.
  • The batch layer periodically reprocesses the raw events from scratch (the data lake from section 2), with no time pressure: it waits for all stragglers, de-duplicates thoroughly, and produces the exact count. This feeds billing.

The serving layer merges them: show the speed layer's number now, then overwrite it with the batch layer's exact number once it's ready. The fast number gets quietly corrected — this reconciliation is the point of the whole design.

The cost of Lambda is real: you maintain two codebases that must compute the same thing the same way, or the corrected number contradicts the live one. The Kappa architecture answers this by deleting the batch layer entirely: keep one stream-processing codebase, and when you need to "recompute," just replay the stream from the start (the queue is retained and replayable) through the same code. Correction becomes "rewind and re-run," not "maintain a second system." Kappa is simpler to operate; Lambda can be cheaper for genuinely huge historical recomputes. Both rely on the same foundation: a durable, replayable log of raw events.

Interactive · the batch layer corrects the speed layer Inject a duplicate or a late click, then run the batch pass
true clicks
100
speed layer (live)
100
batch layer (exact)

6Two stores: tiny hot totals, cheap cold raw

The pipeline ends in two very different storage systems, because aggregates and raw events have opposite needs.

  • The aggregation store holds the per-minute totals the dashboard queries. The access pattern is "give me a metric over a time range, sliced by a few dimensions" — exactly what time-series databases and columnar stores (Druid, ClickHouse, Cassandra with time buckets) are built for. They're small (per-minute totals, not per-click rows), indexed by time, and fast to range-scan and roll up. This is your serving layer: query latency must be low.
  • The raw-event store holds every individual click forever, or for a long retention window. The access pattern is "scan all of it occasionally for a full reprocess," so you want cheap, durable bulk storage — blob storage (S3) or a Hadoop/HDFS data lake — not fast random access. Write-once, read-rarely, pay-by-the-terabyte.

Why both? The aggregation store is fast but derived and lossy — it's just sums, you can't change what you counted after the fact. The raw store is slow but complete and authoritative — it's the receipts. Keep the raw store and any aggregate can be rebuilt or audited; lose it and a wrong total is wrong forever. It's also your defense in a billing dispute: "here is every click we charged you for."

🗄️
A useful rule: store the answer in the shape of the question. The dashboard asks "metric over time, by dimension," so the aggregation store is laid out by time and dimension. Reprocessing asks "every event again," so the raw store is laid out as a flat, append-only log. Same data, two shapes, two costs.

7Scaling out — and the hot-ad problem

To handle the firehose, you split work across many aggregator instances by partitioning the stream by ad_id: all clicks for a given ad go to the same partition (and the same aggregator), so its running count lives in one place and never needs cross-machine coordination. Add partitions, add throughput. Clean — until the load isn't even.

Real ad traffic is brutally skewed. One viral ad or one Super Bowl spot can pull 90% of all clicks. Partition by ad_id and every one of those clicks lands on a single partition, which melts while the others idle. This is the hot-key / celebrity problem from sharding (Ch.1), back again in stream form.

The fix is key salting: split the hot key artificially. Instead of ad_id, partition by (ad_id, salt) where salt is a small random number 0–9. Now the hot ad's clicks spread across ten partitions, each keeping a partial count; a cheap second step sums the ten partials back into the true total. Load spreads; the answer stays correct. Two more must-haves at scale:

  • Backpressure. When a stage can't keep up, it must signal upstream to slow down (or let the queue absorb the burst) rather than fall over. The retained queue is your shock absorber.
  • Replay on failure. If an aggregator dies, a new one resumes from the last committed offset and replays from the queue — no data lost, because the log still has it. This is the same replayability that powers Kappa-style recomputes.
Interactive · hot-ad skew & key salting One ad floods a partition — salt the key to spread it
85%
balance: —
hottest partition load
coolest partition load
imbalance ratio

8Staying correct: late data, reconciliation & fraud

A click aggregator's numbers go straight into invoices, so "roughly right" isn't good enough at billing time. Four habits keep it honest:

  • Late-data correction. The watermark decides a cutoff, but stragglers beyond it still happen. Rather than drop them silently, route very-late events into a correction path: the batch/reconciliation pass re-opens the affected window and adjusts its total. Late doesn't have to mean lost.
  • Reconciliation jobs. Run a scheduled job that recomputes counts from the raw store and diffs them against the served aggregates. Small drift is expected (that's the speed layer being approximate); a large gap is a bug or data loss, and you want an alert, not a surprised advertiser.
  • Click-fraud filtering. Not every click is real. Bots, click farms, and accidental double-taps inflate counts. Before (or during) aggregation, filter out the obvious frauds: impossible click rates from one user, duplicate ids, clicks with no matching impression, traffic from known-bad IPs. Dedup (section 4) is the first line; richer scoring runs in the batch layer where there's time to be thorough.
  • Aggregation-lag monitoring. The one metric you must watch: how far behind real time is the aggregator? If lag creeps up, the queue is backing up and the dashboard is going stale — page someone before advertisers notice. Watermark position, consumer offset lag, and reconcile drift are your core SLOs.

Here's the whole chapter as the order you'd actually build it:

  1. 1Pin the queries & the freshness/exactness target — per-ad-per-minute, top-N, filters; decide where approximate is OK and where exact is required.
  2. 2Put a durable queue up front — Kafka swallows the firehose and decouples ingest from aggregation.
  3. 3Keep the raw events — write every click to a cheap data lake so you can always reprocess and audit.
  4. 4Aggregate with windows + watermarks — fold events into (ad_id, minute) counts; bucket by event time.
  5. 5Make it effectively-once — dedup by click id, checkpoint offset + state together.
  6. 6Serve from a time-series/columnar store — small, fast, queryable per-minute totals.
  7. 7Reconcile fast with exact — Lambda batch (or Kappa replay) corrects the live number for billing.
  8. 8Salt hot keys, monitor lag, filter fraud — spread skew, watch the watermark, keep the invoices honest.

9Aggregation as a fold into a map

Strip away the queues and machines and the core of this whole chapter is four lines: bucket each click by (ad_id, ts / windowMs), then count by key. That's a fold into a map keyed by (entity, window) — and it's the same fold whether it runs in the real-time speed layer or the from-scratch batch layer, which is exactly why a batch recompute and a live stream can agree. Flip between the languages; the shape is identical.

λ
Aggregation is a fold into a map keyed by (entity, window). The key bucketing — ts / windowMs — is the tumbling window; the (+) combine is associative, so partial counts from different partitions merge cleanly (that's what makes salting and map-reduce work). Same pure function in the speed layer and the batch layer means the corrected number can never disagree in kind with the live one — only be more complete.