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.
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.
The word "count" hides four different requirements. Separate them before you draw, because
each one pulls the design somewhere else.
You ask
They say
What 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.
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.
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.
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.
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.
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.
0.1 + 0.20.30000000000000004a billion clicks of that100,000 + 200,000300,000 microsexact, every timestore money as whole millionths
ad_minute_countscolumnar store, partitioned by day, clustered by advertiser
minute
timestamp
PK
Event time, truncated. Never arrival time.
ad_id
bigint
PK
advertiser_id
bigint
IDX
Clustering key, since almost every query filters by advertiser first.
clicks
bigint
Set, never incremented. That is what makes reprocessing safe, and the counting deep dive shows why.
impressions
bigint
spend_micros
bigint
Integer micros. Money is never a float.
finalised
boolean
IDX
False from the stream, true after the recompute. Billing reads only where this is true.
computed_at
timestamp
Which run produced this row. A late row never overwrites a newer one.
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.
batch path, exactstream path, in a minuteclick1append234fast, approximate5archive67overwrite, exact8910Browserclick redirectAd serversregionalEvent logpartitioned by ad idDedupeby click idStream processorminute windowsServing storeone tableDashboardand invoicesRaw archiveobject storage, yearsBatch recomputehourly, exactReconciliationvs advertiser
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:
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.
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).
Events are deduplicated by the click id minted at step 1, before anything counts them.
The stream processor groups them into one minute windows by event time.
Those windows land in the serving store within about a minute of the click.
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.
An hourly batch job reads the archive and computes the same windows exactly, with all
the late data that has arrived since.
It overwrites the rows the stream already wrote. Same table, same rows, better number.
Reconciliation compares your totals against what the advertiser's own system saw,
because the disagreement is the thing that actually costs money.
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.
earlyad 88231, 14:59~ approximatea straggler landsad 88231, 14:59updatedbatch jobad 88231, 14:59finalisedone row that gets more accurate, then stops changing
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?
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.
stamped14:5914:59 window15:04: arrivedcounts where it happenedevent time, not arrival time
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.
event time, when it happenedarrival time, when we saw itwindow endswatermark fires
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.
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.
add each note: total += 11, 2, 3, restart,4, 5, 6 ...a retry counts twicecount the drawer: total = 3count: 3, restart,count: 3the same, however oftenwrite the window's total, never an increment
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.
Ad serverEvent logProcessorStoreclick, id c-8f21retry, same id c-8f21dedup by id within windowwrite count with window versioncrash, window reprocessedoverwrite same key, higher version
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.
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
12recent3settled4Event logStream pathapproximateBatch pathexactServing layermerge at read
12recent3settled4Event logStream pathapproximateBatch pathexactServing layermerge at read
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.
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.
key: ad 88231one partition takes it allkey: ad 88231 + a small saltthe same clicks, spread outpartitionsthen sum the partial counts
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.
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
set by keyAd serversEvent logProcessorsServing store
set by keyAd serversEvent logProcessorsServing store
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
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.
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.
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.
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.
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.
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
Which minute
Counting once
Two speeds
The hot campaign
Under backlog
Pick 0, 1 or 2 on every row. Be the strict interviewer, not the kind friend.
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.
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.