LearnHLDDesign an ad click aggregator

Design an ad click aggregator

Counting is the easiest thing a computer does, right up to the moment the count is money. Then a duplicate is fraud, a dropped event is a refund, and an advertiser who cannot reconcile your number against theirs stops spending.

This question is asked at senior level because it has no comfortable answer. Streaming gives you fast numbers that are occasionally wrong. Batch gives you correct numbers hours late. The design is how you offer both without maintaining two truths.

Your answer

A click event for 14:59 arrives at 15:04, after you already published the 14:00 to 15:00 total. What do you do with it, and what does your answer cost?

What you are building

In scope
  • Ingest click and impression events. From ad servers worldwide, at rates that make per event database writes impossible.
  • Aggregate by advertiser, campaign, ad and minute. Minute granularity, rolled up on read.
  • A near real time dashboard. Advertisers watch spend live and pause campaigns based on it.
  • A billing grade number. Correct, reproducible, and reconcilable, even if it arrives hours later.
The numbers you commit to
  • 1 million events per second at peak.
  • Dashboard within about a minute of the event.
  • Billing figures must be exact and must not change after they are finalised.
  • A replay must produce byte identical results, because disputes get audited.
Cut, and say so out loud
  • Ad serving and auction. Microsecond budgets and a completely different design.
  • Click fraud detection. It sits downstream of this and is a machine learning problem.
  • Attribution windows and conversion tracking. Related, larger, and its own question.

The numbers

Ingest and storage
1000 thousand
400 bytes
200 thousand
Events per second1,000,000
Raw event volume per day1,000,000 x 400B x 86,400 = 35 TB
Aggregated rows per minute200,000
Aggregated rows per day200,000 x 1,440 = 288,000,000
Compression from aggregating300x
300x
fewer rows after aggregation, and that ratio is the whole business case

Writing 1,000,000 rows a second to a database is not a thing you can buy. Aggregating first turns it into 200,000 rows a minute, which any columnar store handles comfortably. Everything difficult in this design comes from the fact that aggregation happens before storage, so correctness has to be established in the stream rather than fixed later with a query.

The design

Figure 1. One ingest path feeds two consumers of the same log. The stream produces fast numbers, the batch job produces the ones you can bill against.

Edge 6 is the design. The batch job does not write to a different table for a different audience. It overwrites the same rows the stream produced, so a number on the dashboard gets more accurate over the following hours and then stops changing. There is one table and one truth, with a finalised flag saying which rows are safe to bill against.

Deep dive one: event time is not arrival time

The click happened at 14:59 on a phone with a poor connection. It reached you at 15:04. Which minute does it belong to?

Event time, always. Bucketing by arrival time means a network delay silently moves revenue between advertisers, and it means replaying the log produces different answers than the original run, which destroys any hope of reconciliation.

Choosing event time creates the problem this whole section is about: you never know when a window is complete, because there might always be one more straggler.

0s
counted 13 of 16 result available at t = 60s
3 events belonging to this window arrive after the watermark fires, so they are not in the result. Waiting longer would catch them, at the cost of every window being reported later.

Drag the slider from zero to the end. At zero you publish the moment the window closes and lose three events. At thirty you lose none and every number is thirty seconds older. There is no setting that gives both, and finding one is not an engineering problem you have failed to solve.

So do not choose. Emit early with what you have, keep the window open, and emit again if stragglers arrive. The dashboard shows the early number and corrects itself. Billing waits for the batch job, which reads the archive long after every straggler has landed.

The clock you use decides whether replay works

Arrival time is tempting because it needs no state and never has stragglers. It also means the same log replayed tomorrow produces different totals than it did today, since arrival times are gone. Any system that has to defend a number to an advertiser needs replay to be deterministic, and that requires event time.

Deep dive two: counting each click exactly once

Every layer here delivers at least once. The ad server retries on a timeout, the log redelivers on a consumer restart, and the stream processor reprocesses a window after a crash. Without care, a single click becomes three.

1/6 The ad server generates the event id at the edge, before anything can retry. This id is the only thing that identifies this click for the rest of its life.

Three ideas are doing all the work there, and they are worth naming separately because each one solves a different failure:

Event ids created at the edge. Anything generated later cannot tell a retry from a second real click.

Deduplication scoped to the window. Remembering every id ever seen is not affordable. Remembering ids within a window is, and a duplicate arriving after its window has closed is rare enough to be handled by the batch job.

Writes that set rather than increment. An increment is not idempotent, so reprocessing doubles it. Writing the computed total for a window keyed by that window makes reprocessing free, which in turn makes crash recovery boring.

Deep dive three: two pipelines, one number

Architecture
Two pipelines with different code, merged at read time: recent data from the stream, older data from batch. It works and it is what most large systems actually run. The cost is that the same business logic exists twice in two languages, and every discrepancy between them is a bug that only shows up as a number nobody can explain.

The data model

ad_minute_countscolumnar store, partitioned by day, clustered by advertiser
minutetimestampPKEvent time, truncated. Never arrival time.
ad_idbigintPK
advertiser_idbigintIDXClustering key, since almost every query filters by advertiser first.
clicksbigintSet, never incremented. That is what makes reprocessing safe.
impressionsbigint
spend_microsbigintInteger micros. Money is never a float.
finalisedbooleanIDXFalse from the stream, true after the recompute. Billing reads only where this is true.
computed_attimestampWhich run produced this row. A late row never overwrites a newer one.
Sample row
2026-08-25 14:59 | 88231 | 4471 | 1,204 | 91,338 | 3612000 | true | 15:47
One row per ad per minute, written by set rather than by increment, with a finalised flag separating the number on the dashboard from the number on the invoice. Everything above exists so that this table can be overwritten safely by anything, at any time, without double counting.

Partitioning the stream

Consumer group
part 0
lag 3consumer 0
part 1
lag 2consumer 1
part 2
lag 4consumer 2
part 3
lag 3consumer 0
part 4
lag 2consumer 1
part 5
lag 3consumer 2
All events for one ad land on one partition, so a processor can aggregate that ad entirely in local memory with no cross partition coordination. That locality is what makes a million events a second tractable.

Break it

Pipeline health
200k/s
200k/s1M/sprocessor lagsstate too largeshed and recompute
Healthy. Windows close on time, the dashboard is about a minute behind, and the hourly recompute finalises yesterday overnight. Nobody is thinking about any of this.

The API

GET/v1/reports?advertiser=4471&from=2026-08-25T00:00Z&to=2026-08-25T23:59Z&granularity=hour
returns 200 { rows: [{ ts, clicks, spendMicros, finalised }], allFinalised: false }
Why: Every row carries whether it is final, and the response says whether all of them are. An advertiser looking at a number that may still move should be able to see that, and a system reading this for billing should be able to refuse a response where allFinalised is false.
POST/v1/events
{
  "eventId": "c-8f21ab...",
  "type": "click",
  "adId": 88231,
  "eventTime": "2026-08-25T14:59:58.221Z"
}
returns 202
Why: The event id and the event time both come from the ad server. Generating either at ingest would make retries indistinguishable from real clicks and would make replay non deterministic.
POST/v1/admin/recompute
{ "from": "2026-08-25T14:00Z", "to": "2026-08-25T16:00Z" }
returns 202 { jobId }
Why: Recompute has to be an operation anyone can trigger for a range, because disputes arrive about specific hours. If fixing a number requires an engineer to write a one off script, the numbers will not get fixed.

Trade-offs

ChoiceWhat you gainWhat you payPick it when
Event time over arrival timeReplay is deterministic, and a network delay cannot move revenue between minutes or advertisers.Windows are never provably complete, so you need watermarks and a late data policy.Anything that gets audited. For internal traffic dashboards, arrival time is fine and much simpler.
Set the aggregate rather than increment itReprocessing a window is free, which makes crash recovery and replay boring.The processor has to hold the whole window in state rather than emitting deltas.Always, for anything that gets recomputed. Increments and at least once delivery cannot coexist.
One table with a finalised flagOne number, converging. No second system for anyone to reconcile against.Consumers must respect the flag, and one that ignores it will bill against provisional data.When the same figure serves both a live dashboard and an invoice.
Salting hot keysOne enormous campaign stops holding up its partition.A second aggregation stage, and locality lost for that key.The moment one key exceeds what a single partition can carry, which for ads is every large campaign launch.

Interview replay

Interviewer
A click that happened at 14:59 arrives at 15:04, after you published the hourly total. What do you do?
The question the whole design exists to answer, asked first.
You
It belongs to 14:59, because I bucket by event time. If I bucketed by arrival, a slow network would move revenue between hours and replaying the log would produce different totals, which makes disputes unresolvable. So the window is reopened and the aggregate for that minute is rewritten. The dashboard number moves slightly, and the billing number was never final yet.
Answers with the principle and then names the two failures the alternative causes. Both are concrete.
Interviewer
How long do you wait before deciding a window is done?
The completeness against latency trade. There is no correct number and they know it.
You
I would not pick one number for both purposes. The dashboard emits on a short watermark, a minute or so, and updates if stragglers arrive, because a number that is 99% right immediately is worth more there than one that is perfect later. Billing does not use the streaming number at all: a recompute over the archived raw events produces it hours later, when every straggler has landed. Trying to serve both from one deadline is where these systems go wrong.
Refuses the premise that one deadline serves both. That reframing is the senior answer here.
Interviewer
Your processor crashes mid window and reprocesses it. Is the count now double?
Exactly once, asked concretely rather than as a buzzword.
You
No, because the write sets a value for a key rather than incrementing. Reprocessing a window computes the same total and overwrites the same row. If I were incrementing a counter, at least once delivery would double it and there would be no way to tell afterwards. Within the window I also deduplicate on an event id generated by the ad server, which handles the ad server retrying a publish.
The distinction between setting and incrementing is the crux, and it is stated in one sentence.
Interviewer
One campaign is 30% of all your traffic. What breaks?
The hot key, in the specific form this domain produces.
You
Events partition by ad id for locality, so that campaign lands on one partition, and that partition falls behind while others idle. More processors do not help because one partition has one owner. I would detect the hot key and salt it across several partitions, then sum the partials in a second stage. I lose locality for that one ad and pay one extra aggregation, which is much better than that advertiser watching an hour old dashboard during their launch.
Names why the obvious remedy fails before giving the real one, and quantifies what the fix costs.
Interviewer
How would you prove to an advertiser that your number is right?
Open ended, and it is really about whether you designed for audit.
You
By replaying. The raw events are archived, so I can rerun the aggregation for a disputed hour and get the same answer, and I can show which event ids contributed. That only works because bucketing is by event time and the logic is deterministic. I have not run a pipeline at a million events a second, so I would want to measure how long a full day replay actually takes before promising a turnaround on disputes.
Ties auditability back to the event time decision, and ends with an honest limit rather than a guess.

Checkpoint

Checkpoint

1. Why bucket by event time rather than arrival time, given arrival time never has stragglers?

2. Your processor crashes and reprocesses a window. Which design choice prevents double counting?

3. A backlog forms and you hold watermarks back so no late events are dropped. What is the danger?

Say this in 60 seconds

Events are bucketed by event time, not arrival time, because replay has to be deterministic for a number anyone might audit, and because a network delay should not move revenue between hours. That means windows are never provably complete, so I run two speeds against one table: the stream emits provisional minute aggregates on a short watermark and updates them if stragglers arrive, and a recompute over the archived raw events overwrites the same rows hours later and marks them finalised, which is what billing reads. Double counting is prevented by writing the computed total for a window rather than incrementing, so reprocessing is idempotent, plus deduplication on an event id generated at the ad server to absorb publish retries. Partitioning is by ad id for aggregation locality, with salting for campaigns large enough to swamp one partition. And under backlog I would close windows on schedule with an explicit completeness flag rather than holding watermarks back, because holding them turns a throughput problem into unbounded processor state.

IndGeek provides solutions in the software field, and is a hub for ultimate Tech Knowledge.