Library
35 min read

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.

Every time someone taps an ad, the advertiser owes money. An ad click aggregator is the system that counts those taps, adds them up per ad and per minute, and hands the totals to two readers: a live dashboard the advertiser watches, and the invoice they pay at the end of the month. Watch one click go through it.

BrowserAd serversEvent logDedupeStream processorServing storeDashboardRaw archiveBatch recompute

Click. Someone taps an ad at 14:59. The tap goes through your ad server as a redirect, and the ad server gives the click its id, c-8f21.

A few words come up on every line of this page:

  • An event is one thing that happened: a click, or an impression (the ad was shown).
  • A window is a bucket of time, here one minute. Every click that happened between 14:59:00 and 14:59:59 belongs to the 14:59 window.
  • To aggregate is to turn many events into one number: 1,204 clicks on ad 88231 in the 14:59 window, stored as one row.
  • Stream processing counts events as they arrive, a little at a time. Batch processing waits, then counts a whole hour or day in one go.

This is a staff level question 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 keeping two versions of the truth.

Step 1: Understand the problem#

The word "count" hides four different requirements. Separate them before you draw, because each one pulls the design somewhere else.

You askThey sayWhat it settles
How many events a second, and how spiky?A million a second, with regional peaks.An append only log in front of everything: a list you can only add to the end of, which is one of the cheapest writes there is. Nothing that counts events is allowed to slow down the ad server that produces them.
Is the count for a dashboard, or an invoice?Both. Advertisers watch it live and are billed from it monthly.The hardest requirement on the page. Live means seconds and approximate, billing means exact and reconcilable, and that tension is the whole design.
How fresh does the dashboard have to be?About a minute behind is fine.Minute windows in a stream processor rather than an update per event, which is what makes a million events a second affordable.
What happens to an event that arrives late?It still has to be counted correctly.Counting by the time the click happened, a rule for how long to wait for stragglers (a watermark), and the ability to correct a window you already published. This one answer rules out every design that can only add numbers and never fix them.
Can the same click be counted twice?Never. That is fraud, and it gets refunded.A click id created at the source, and a pipeline where each click changes the total exactly once, even when messages repeat. The cheap approximations are off the table.
How long do we keep raw events?Years. Advertisers dispute invoices.The raw log is archived and is the real source of truth. Every aggregate on this page can be deleted and rebuilt from it, which is what makes restatement safe.

What you are building#

Functional requirements
What the system does.
  • 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.
Non-functional requirements
How well it has to do it. 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.
Out of scope
What you 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.

Back of the envelope#

The trick that makes this system affordable is in the last two rows. (New to estimates like this? The back of the envelope chapter shows the method.)

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, one per event, into the table advertisers query would take a large cluster just to keep up. 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 settled in the stream rather than fixed later with a query.

A columnar store keeps each column's values together on disk, so summing clicks across millions of rows reads only the clicks column. That is the opposite of an app database, which keeps each row together. OLTP vs OLAP shows the difference live.

Step 2: Propose the high level design#

The core entities
  • Click eventOne click or impression, with an event id from the ad server and the time it happened.
  • Minute countClicks, impressions and spend for one ad in one minute of event time.provisionalfinalised
  • WatermarkThe stream's judgement that no more events older than this will arrive. It decides when a window is published.
  • Raw archiveEvery event, kept. The source of truth that every count can be rebuilt from.
Never breaks: Each click changes a billed total exactly once.

The API#

Three endpoints. The interesting part of each is what it refuses to do.

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.

The data model#

One row per ad per minute. Two columns need a word first. Micros are millionths of the currency unit, so 3,612,000 micros is 3.612. Money is stored as a whole number of micros because floating point numbers cannot hold most decimals exactly: in JavaScript, 0.1 + 0.2 is 0.30000000000000004, and those errors add up across a billion clicks. Finalised says whether a row can still change.

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, and the counting deep dive shows why.
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.

The whole system on one whiteboard#

Figure 1. One log, two consumers, one output table. The stream path gives you a number in a minute, the batch path gives you the number you can bill against, and both write to the same rows.

Walking Figure 1:

  1. The click is a redirect through your ad server, which is what makes the event yours to count rather than something you have to be told about.
  2. The ad server appends to the log and returns immediately. It never waits for anything downstream, and it never aggregates. The log is an event stream: it keeps events for a set retention period, so many readers can each read it at their own pace and replay it later (event streams explains the idea).
  3. Events are deduplicated by the click id minted at step 1, before anything counts them.
  4. The stream processor groups them into one minute windows by event time.
  5. Those windows land in the serving store within about a minute of the click.
  6. In parallel, everything on the log is archived raw to object storage, a cheap store for huge files (design object storage). This archive, not the serving store, is the source of truth.
  7. An hourly batch job reads the archive and computes the same windows exactly, with all the late data that has arrived since.
  8. It overwrites the rows the stream already wrote. Same table, same rows, better number.
  9. Reconciliation compares your totals against what the advertiser's own system saw, because the disagreement is the thing that actually costs money.
  10. The dashboard and the invoice read the same table. A row starts out approximate and becomes final, and a flag says which.

Edge 8 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.

Your answer

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

Step 3: Design deep dive#

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?

Two clocks are in play. Event time is when the click happened, stamped by the ad server. Arrival time is when it reached your pipeline. Think of a postmark: a letter stamped on Monday was sent on Monday, even if it is delivered on Wednesday.

Which minute does a late click belong to, and when do you publish that minute?

  1. First try
    Count by arrival time
    Put each click in the minute it reached you. A window is complete the moment the minute ends, so there are no stragglers to wait for.
    Where it breaks: A slow network quietly moves revenue between minutes, hours and invoices. Worse, a replay reaches the processor at a different time than the first run did, so replaying the log tomorrow gives different totals than today, and no disputed number can ever be reproduced.
  2. Better
    Count by event time, wait a fixed delay, publish once
    Put each click in the minute it happened. Hold each window open for a while to collect stragglers, then publish its total. The rule for how long to wait is called a watermark.
    Where it breaks: Every delay is wrong for someone. A short wait drops late clicks from the number. A long wait makes the live dashboard stale. One deadline cannot serve a dashboard and an invoice at the same time.
  3. What you ship
    Publish early, correct later, bill from the recount
    Publish each window on a short watermark and keep it open, updating the total if stragglers arrive. The dashboard shows that number. The invoice uses the hourly recount over the archive, which runs long after every straggler has landed.

Choosing event time creates the problem the rest of this section is about: you never know when a window is complete, because there might always be one more straggler. The widget below is the middle rung, so you can feel why it fails.

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.

Each line joins when a click happened (top) to when it arrived (bottom). 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. That is the third rung: 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.

Counting each click exactly once#

Every layer here delivers at least once: when in doubt it sends again, so nothing is lost but things can repeat. 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. (Why no layer can promise "exactly once" delivery is explained in delivery guarantees.)

Think of a cashier counting the till at closing. If they lose their place and start again, "add each note to the running total" counts some notes twice. "Count the drawer and write down the total" gives the same answer however many times they start over.

How do you make sure each click changes the total exactly once?

  1. First try
    Add 1 to a counter for every event
    Each event that reaches the processor increments the count for its ad and minute.
    Where it breaks: The first retry from the ad server counts a click twice. Nothing downstream can tell afterwards which clicks were repeats.
  2. Better
    Skip ids you have seen, then add 1
    Every click carries an id from the ad server. The processor remembers the ids it has seen and ignores repeats, so retries no longer double count.
    Where it breaks: The processor crashes and replays the window from its last saved position, with its memory of seen ids gone. Every click in that window is added a second time. And remembering every id ever seen would not fit in memory anyway.
  3. What you ship
    Ids at the edge, dedupe per window, write the window total
    The ad server creates the id. The processor remembers ids only for the open window, and writes the total for each ad and minute as a value that replaces the old one. Replaying a window just writes the same total again.

Try all three. Send a click, have the ad server retry it, then crash the processor.

Count one minute of clicks
Event log for 14:59
empty
Clicks that really happened
0
Add 1 for every event
0right
Skip seen ids, then add 1
0right
Skip seen ids, write the window total
0right
Send a click, then make the ad server retry it, then crash the processor. Watch which stored number stops matching the clicks that really happened.
One window, six possible clicks. Real windows hold far more, and the processor keeps seen ids only until the window closes.

Here is the same thing as a sequence of messages.

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. How long a dedupe memory has to last is its own question, worked through in exactly once in practice.

Writes that set rather than increment. An operation is idempotent when doing it twice has the same effect as doing it once (idempotency keys has more examples). 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.

Two pipelines, one number#

You now have a fast path that is nearly right and a slow path that is exactly right. How do they become one number? There are two well known names for the answer. Lambda runs a stream pipeline and a separate batch pipeline and merges them when someone reads. Kappa runs only the stream pipeline and fixes history by replaying the log through it. Switch between them, then look at the third option, which is what this page proposes.

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.

Partitioning the stream#

A million events a second needs many processors. The log is split into partitions, and each partition is read by exactly one processor, so the partition key decides which machine sees which events (partitions decide your parallelism).

How do you split a million events a second across processors?

  1. First try
    Spread events evenly, at random
    Every processor gets an equal share of the traffic, whatever ad it is for.
    Where it breaks: A retry of a click can land on a different processor than the original, so per window dedupe never sees both copies. And every processor holds a partial count for nearly every ad, which then has to be merged across machines every minute.
  2. Better
    Partition by ad id
    All events for one ad go to one partition. A processor counts that ad entirely in its own memory, and a retry of a click always meets the original.
    Where it breaks: One huge campaign hashes to one partition. That partition falls behind and its advertiser watches a stale dashboard while the other processors sit idle.
  3. What you ship
    Partition by ad id, and salt the hot ads
    Detect an ad that is too busy for one partition and split it across several by adding a small number, a salt, to its key. A second stage adds the partial counts back together. Take the salt from the click id rather than at random, so a retry of the same click still lands next to the original.

Here are the three situations side by side. Salting is also used to spread view counts on Design YouTube.

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#

Raise the traffic and watch where the pressure shows up first. It is not where most people expect.

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.
Read every step as text
  1. 200k/s · 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.
  2. 1M/s · Degraded. Peak traffic. Processors are holding a large deduplication set and open windows in memory. State size, not throughput, is what is tightening: every open window keeps its event ids until it closes.
  3. processor lags · Down. A deploy pauses consumption for four minutes. Now event time and processing time diverge sharply, and events for windows that should have closed are still arriving. Emitting on schedule would publish badly incomplete numbers to advertisers who are making spending decisions on them.
  4. state too large · Down. To avoid dropping the backlog, watermarks are held back and windows stay open. Open windows hold deduplication state, so memory grows with the backlog and the processors start failing, which makes the backlog worse. This is the failure loop specific to stream processing.
  5. shed and recompute · Degraded. Close the windows on schedule, publish provisional numbers with an explicit completeness signal, and let the recompute over the archive fix them within the hour. Correctness moves to the path that has time for it, and the streaming path stops trying to be something it cannot be under backlog.

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.

The level bar#

One moment from this round, answered three ways. Pick the one you think is staff level before the labels appear. (What changes at senior and staff explains the gap.)

Pick the staff level answer

InterviewerA deploy pauses the stream processors for four minutes. What do you do when they come back?

Show which is which without guessing
  • Answer A: Mid level. Open windows hold deduplication state, so memory grows with the backlog until processors fail, which makes the backlog bigger.
  • Answer B: Staff. Puts correctness on the path that has time for it, tells the reader which numbers are provisional, and names the failure loop the obvious fix causes.
  • Answer C: Senior. The right call. An advertiser looking at the dashboard still cannot tell a provisional number from a final one.

Then run the question cold, with a 45 minute timer and no notes, and score the run on the five things this question is really testing.

Score your last run
0 / 10
  1. Which minute
  2. Counting once
  3. Two speeds
  4. The hot campaign
  5. Under backlog
Pick 0, 1 or 2 on every row. Be the strict interviewer, not the kind friend.

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?

Worth memorising
  • A million events a second, and every count is money.
  • One minute windows cut the row count by orders of magnitude. That ratio is the whole business case for aggregating.
  • Event time, not arrival time, with a watermark that says when a window is safe to publish.
  • The raw archive is the truth. Every aggregate on the page can be deleted and rebuilt from it.
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.