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.
Ad click aggregator / at a glance
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.
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. Nothing that processes events is allowed to sit in the path of producing 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.
Event time processing with watermarks, and the ability to restate a window you already published. This single answer rules out every design that only ever appends.
Can the same click be counted twice?
Never. That is fraud, and it gets refunded.
A click id minted at the source and exactly once semantics somewhere in the pipeline. 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.
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.
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, 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.
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.
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. 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.
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?
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.