System design interview
Design an ad click aggregator
Event-time windows, late events, dedupe, exactly-once sinks and reconciliation.
At a glance
- Advanced
- Queues and streams, Storage, Consistency
- Click tracker · Click log · Stream job · Raw archive · Fraud filter · Count store · Batch job · Dashboard API · Billing
Press Play to follow a request through the design, or step through it.
- Client and edge
- Service
- Data store
- Cache and queue
- A user clicks an ad. The tracker stamps the event time and click ID, appends the event to the log, and acknowledges once it is safe.
- A reader copies the event to the raw archive, where it is kept unchanged.
- The stream job reads the same event from its partition, which holds all events for this ad.
- After dropping a repeated click ID, the job asks the fraud filter whether the click looks valid.
- The filter answers from in-memory rules in under a millisecond: valid, or invalid with a reason.
- Every 10 seconds, and again when the window closes, the job overwrites that window's row with its full valid and invalid counts.
- Click tracker: stamps time, ID
- Click log: by ad ID
- Stream job: windows, dedupe
- Raw archive: every event
- Fraud filter: fast rules
- Count store: per ad, minute
- Batch job: recount, correct
- Dashboard API
- Billing: final totals
- Advertiser
On this page
In 2 minutes
- The numbers. Aggregation shrinks the data 25 times, and the dedupe state fits in memory.
- Which time to use. Event time, with watermarks.
- Writing results. Overwrite the whole window row, with a version.
- Dedupe. A one-hour set in the stream, plus a full dedupe in batch.
- Paths to billing. A fast stream plus a batch recount that bills.
Read the 2-minute answerHide the 2-minute answer
I would build two paths over one durable log. The click tracker stamps each click with an event time and a click ID created when the ad was shown, and appends it to a log partitioned by ad ID. Every event is also copied to an immutable raw archive. A stream job counts clicks per ad in one-minute windows by event time, using a watermark with two minutes of lateness, and keeps window state for ten minutes so slightly late events update the row.
It drops repeated click IDs using a one-hour set, about 3.3 GB over 24 partitions, and asks a fast fraud filter whether the click is valid. It checkpoints its state and log offsets every 30 seconds. It rewrites each open window's full count every 10 seconds and once more at the close, always as a versioned overwrite, so a restart or replay never counts anything twice. That gives dashboards in seconds.
For billing, a batch job reads the archive for each closed hour, removes duplicates globally, applies the final fraud rules, writes final counts and compares them with the stream. A difference above 0.1% raises an alert, because that error is half a million dollars a day. Billing reads only final rows, with corrections issued as adjustments.
Step 1 of 6: Clarify the scope
Questions to ask first
Ask these before you draw anything. Guess why each answer changes the design, then open it to check.
What do we count by: ad, campaign, country, device?
Which numbers must be exact, and which can be a little off for a few minutes?
How late can a click event arrive?
Does every click have a unique ID created at the source?
Do we filter fraud before billing, and how long do we keep raw events?
Requirements
Functional
- Ingest click events with an ad ID, a click ID, a user or device ID and an event time.
- Show clicks per ad per minute on a dashboard within seconds of the click.
- Produce exact, final counts per advertiser for billing.
- Count each real click once, even if it is sent twice or processed twice.
- Mark invalid or fraudulent clicks and keep them out of billing, while keeping the raw event.
Non-functional
- Handle 60,000 clicks a second at the peak.
- Dashboard counts update within 30 seconds of the click.
- After a crash, the job resumes from a checkpoint with no loss and no double count, within 10 minutes.
- Final billing totals match a recount of the raw events exactly.
Step 2 of 6: Size it
Show the mathsHide the maths
These numbers are assumptions for the exercise, not facts about a real system:
- There are 1 billion clicks a day and the peak is 5 times the average. An event is 200 bytes.
- A window that has any click has 5 clicks on average. An output row takes 40 bytes.
- Duplicates are caught within a 1-hour dedupe window, using 16-byte click IDs.
- 99% of events arrive within 1 minute of the click, and 99.9% within 10 minutes.
- One stream worker handles 10,000 events a second. The average price is $0.50 per click.
The arithmetic:
- Clicks per second
- 1B ÷ 86,400 s ≈ 11,574; × 5 for the peak
- ≈ 11,600/s on average, ≈ 58,000/s at the peak
- Raw events per day
- 1B × 200 bytes
- 200 GB a day, 11.6 MB/s at the peak
- Aggregate rows per day
- 1B clicks ÷ 5 per row = 200M rows × 40 bytes
- 8 GB a day, 25 times smaller than the raw events
- Dedupe state
- 58,000/s × 3,600 s = 208.8M IDs × 16 bytes
- ≈ 3.3 GB, or about 140 MB on each of 24 partitions
- Stream workers
- 58,000 ÷ 10,000 per worker = 5.8, rounded up to 6; × 2 for headroom
- 12 workers, over 24 partitions
- Cost of a small error
- 0.1% of 1B = 1M clicks × $0.50
- $500,000 a day
Step 3 of 6: Sketch the path of one request
Step through the diagram at the top of the page to watch a request move through these parts.
The Click tracker receives each click, stamps it with the event time and appends it to the Click log, a durable log partitioned by ad ID. It acknowledges the click only after the write is safe. A second reader copies every event to the Raw archive, an append-only store that is the source of truth.
The Stream job reads the log. It drops duplicate click IDs, asks the Fraud filter whether each click looks valid, and counts clicks per ad in one-minute windows by event time. While a window is open it overwrites that window's row in the Count store every 10 seconds, and once more when the window closes. Every write is a full-count overwrite, so repeating one is harmless. The Dashboard API reads the count store for advertisers.
A Batch job is the check on all of this. For each closed hour, it reads the raw events, removes duplicates over the whole period, applies the final fraud rules and recomputes the counts. It corrects the count store and sends the final totals to Billing. It also reports any difference from the stream, which exposes bugs.
Other paths through the system
Show a dashboard
- An advertiser opens the dashboard for a campaign and asks for clicks per minute over the last hour.
- The Dashboard API reads those window rows from the count store.
- The store returns 60 rows. The newest minute is still open, so its row is provisional and is overwritten every 10 seconds.
- The chart is drawn, with the latest points marked as provisional.
Recount and bill
- An hour after a billing hour closes, the batch job asks the archive for all raw events of that hour, by event time.
- The archive returns them. The job removes duplicates over the whole hour and applies the final fraud rules.
- The job reads the stream's counts for the same hour, to compare them with its own.
- The store returns the stream's numbers. The job reports every row where they differ.
- It writes its own counts back as final, marked with the rule version it used.
- It sends the final totals per advertiser to billing, which never reads the provisional numbers.
Step 4 of 6: Go deep on the hard part
Event-time windows, watermarks and late events
How do you count clicks per minute when events arrive late and out of order?Count by event time, the moment the click happened, stamped by our own tracker.
Count by event time, the moment the click happened, stamped by our own tracker. Do not count by processing time, the moment the job sees it. If the job stalls for ten minutes and then catches up, processing time would pile ten minutes of clicks into one minute, and a billing day could even be wrong. Never trust the user's device clock.
Use tumbling windows of one minute, such as 12:05:00 up to 12:06:00. Partition the log by ad ID, so every event for an ad meets the same task and the same state.
The job needs to know when a window is complete. That is the job of the watermark: the job's claim that no events older than this time will arrive. It is the largest event time seen, minus an allowed lateness, say 2 minutes. If the newest event is 12:08:10, the watermark is 12:06:10. The window ending at 12:06:00 is now closed, and its final result is written. The dashboard does not wait for this. While a window is open, the job fires early: every 10 seconds it overwrites the row with the count so far, marked provisional, so a click shows up within about 10 seconds and the newest minute only grows. The close writes the row once more as complete, and late events can write it again. With many partitions, take the lowest watermark, and treat a partition with no traffic as idle after a timeout, so it does not hold everything back.
Late events are those that arrive after their window closed. Handle them in three tiers:
- Slightly late. Keep window state for 10 minutes after the close. A late event updates the count and the job writes the row again. This is safe because the write replaces the row with the new full count.
- Very late. Past 10 minutes, send the event to a late output and do not touch the stream's count. With 99% of events arriving within 1 minute and 99.9% within 10, this tier catches almost everything.
- Always safe. The raw archive has every event, so the batch job includes late events in the final count for the billing period.
Billing needs a cut-off. Finalise an hour a fixed time after it ends, such as one hour. A click that arrives after the cut-off goes into an adjustment on the next invoice rather than changing a closed one.
Dedupe and exactly-once counting
How do you make sure a click is counted once even when the system retries and restarts?Duplicates come from everywhere: a double tap, the tracker retrying after a timeout, the log delivering a message twice, and the job replaying after a restart.
Duplicates come from everywhere: a double tap, the tracker retrying after a timeout, the log delivering a message twice, and the job replaying after a restart. The defence is a click ID created at the source, when the ad is shown, and reused by every retry. Without it a duplicate cannot be told from a second real click.
Dedupe in the job. Keep a set of click IDs seen in the last hour, in state that is partitioned the same way as the log. If an ID is already there, drop the event and count it as a duplicate. Our estimate is 208.8M IDs × 16 bytes, about 3.3 GB, or about 140 MB on each of 24 partitions. Expire old entries by event time. A duplicate that arrives after an hour slips past the job, and the batch job catches it later.
Exactly once. Real exactly-once delivery does not exist. What we build is at-least-once delivery with effects that are safe to repeat. It takes two parts.
- Checkpoints. Every 30 seconds the job saves its state, the window counters and the dedupe set, to durable storage together with the log offsets it has read. After a crash it restores that snapshot and reads again from the saved offsets. State and offsets match, so the replay leads to the same result and nothing is counted twice.
- An idempotent sink. The events between the last checkpoint and the crash were already counted, and their output may already be written. If we added 1 to a counter for each click, the replay would add them again. Instead write the whole window's count to a row keyed by ad and window start. Writing the same value twice leaves one row. Add a version, so an older write never overwrites a newer one.
Take ad 77 at 12:05. The job writes 41 and then crashes. After the restore it counts the same events again, reaches 41 and writes the same row. If it reads two more late events it writes 43. At no point is there a 'plus one' that could be applied twice.
A transactional sink that commits offsets and output together also works, but it is heavier. The idempotent write is simpler.
Batch reconciliation and fraud filtering
Why run a second, batch path, and how do you deal with fraudulent clicks?The stream is fast, but its edges are loose: a lateness cut-off, a one-hour dedupe window, and a fraud rule set that was current at the time.
The stream is fast, but its edges are loose: a lateness cut-off, a one-hour dedupe window, and a fraud rule set that was current at the time. A bug could skew it silently. Since 0.1% is half a million dollars a day, billing cannot rest on it alone. So there is a batch path over the immutable raw events, which is the source of truth.
For each closed hour the batch job reads the archive, selecting by event time. It removes duplicates by click ID over the whole period, applies the latest fraud rules, groups by ad and minute, and writes the result to the count store as final, together with the run ID and the rule version. The same input and the same rules always give the same output, so it can be rerun at any time. Share the counting code with the stream job, so the two cannot drift apart.
The job also compares its numbers with the stream's. Alert if any hour differs by more than, say, 0.1%. A difference is not an annoyance. It is a signal that one path has a bug. Billing reads only final rows. If a correction comes after an invoice was sent, issue a credit or an extra charge, never a silent edit of the invoice.
Raw events are kept for 90 days, in compressed columnar files partitioned by hour. At about 5 to 1 compression, 200 GB a day is 40 GB, or about 3.6 TB for 90 days. That is enough for recounts and disputes.
Fraud. Sources include bots, click farms, and a publisher clicking its own ads. In the stream, apply cheap in-memory rules: too many clicks from one device or network a minute, a click within a third of a second of the impression, known data-centre address ranges, and an odd user agent. Mark the click invalid with a reason. Never delete it, so counts show valid and invalid separately and a dispute can be explained. The batch job adds heavier analysis over the whole day, such as grouping devices that behave alike, and can flip a click to invalid after the fact. The stream must never wait on a slow check.
Step 5 of 6: Name the trade-offs
- Event time, with watermarks
- Processing time
Counts stay right through stalls, catch-up and retries. The cost is state for open windows and a lateness rule to choose.
- Overwrite the whole window row, with a version
- Add one to a counter for each click
A replay leaves the same row, so nothing is counted twice. The cost is that the job must hold the full count of each open window.
- A one-hour set in the stream, plus a full dedupe in batch
- A global exact dedupe in the stream
The state stays near 3.3 GB. The cost is that a very late duplicate is caught only by the batch path.
- A fast stream plus a batch recount that bills
- The stream alone
Billing gets exact, rerunnable numbers and bugs are caught. The cost is two code paths to keep in agreement, and a delay before totals are final.
Step 6 of 6: Say what breaks next
Follow-up questions an interviewer asks once the design is on the board. Answer each one out loud, then open it to compare.