Vibe Engines
YouTube

Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path

System Design

Design an Ad Click Aggregator

Step 1 / 9

Learn system design by building a real-time ad click aggregator step by step.

The numbers to beatmillions/sclicksappendonlyreplayablebuffer

The whole design, in writing

Every step of the build above, written out: the problem each piece solves, the option that was taken and the ones that were not, the numbers, and how it fails in production.

The big idea

What are we counting?

Millions of ad clicks per second, and advertisers want to see totals — by campaign, by minute, by region — almost instantly, to decide where to spend. The counts must be fast, accurate (it’s billing), and queryable in many ways.

Ad Clickuser taps an ad
New in this step: Ad Click.

Treat clicks as a stream: ingest them cheaply, aggregate them in real time into rollups advertisers can query, and run a slower batch pass to guarantee the numbers are exactly right. Speed from the stream, truth from the batch.

What the new pieces do

Ad Clickevent
A single click event among millions per second. It must be recorded fast and counted accurately — advertisers pay per click, so every count is money.

Step 1 · Catch the firehose

Ingest to a stream

At millions of events per second, writing each click straight to a database would melt it, and any slow downstream step would drop clicks — which means dropping revenue.

Ingest APIEvent Stream
New in this step: Ingest API, Event Stream. · swipe to pan the diagram

Millions of clicks/sec, and every click is billable revenue. How do you ingest without dropping any?

  1. A database can’t absorb millions of writes/sec, and any slow downstream step backs up and drops clicks — dropping revenue. Ingestion must not be coupled to processing speed.

  2. In-memory counts vanish on restart and can’t be replayed or reconciled — fatal for a billing system. You need a durable record of every raw click.

  3. Validate and append to Kafka, then return — ingestion is a fast append that absorbs spikes, buffers durably, and lets many consumers read independently. Nothing is lost if a consumer lags.

A thin Ingest API validates each click and appends it to a durable Event Stream (Kafka), then returns. The stream absorbs spikes, buffers durably, and lets multiple consumers read independently. Ingestion is now just a fast append.

Why this piece earns its place

The word doing all the work here is durably, and it hides an acknowledgement policy. If the Ingest API returns the moment a leader broker accepts the append, the click is acknowledged but not yet replicated — lose that broker in the next instant and the click is gone, and unlike a user-facing request there is nobody left to retry it, because the browser has already followed the ad to the landing page. So wait for replication to a quorum before returning and accept the extra time at the edge; an unacknowledged drop here is revenue that never appears in any audit, because no record of it was ever written. The second thing this step quietly assumes is retention. The log is a buffer, not an archive, so "nothing is lost while a consumer lags" is only true inside the retention window — a consumer stalled longer than that resumes at an offset that no longer exists. That is the real reason the lake write in step 5 is the consumer you never let fall behind: it is the only copy with no expiry date on it.

  • millions/sclicks
  • appendonly
  • replayablebuffer

What the new pieces do

Ingest APIbackend
A thin, massively scaled endpoint that validates a click and writes it to the event stream. It does almost nothing else, so it can absorb huge spikes.
Event Streambus
The durable buffer between fast ingestion and slower processing. It absorbs spikes, allows replay, and feeds every downstream consumer independently.

Step 2 · Count as it flows

Stream aggregation

Advertisers won’t wait for a nightly job — they need totals within seconds. But you also can’t store every raw click in a way that’s fast to query by campaign and minute.

Stream Processorwindowed aggAggregates DBOLAP · time-series
New in this step: Stream Processor, Aggregates DB.

Advertisers want totals within seconds, queryable by campaign/minute. How do you produce them?

  1. Scanning billions of raw clicks per dashboard query is far too slow and expensive. You want to pre-summarize the firehose, not re-scan it on every read.

  2. Flink/Spark consumes the log and rolls clicks into per-minute, per-campaign counts written to an OLAP store. Dashboards query small pre-summed buckets — orders of magnitude less data, seconds fresh.

  3. A nightly job can’t give within-seconds freshness — advertisers manage live spend. Batch is the truth layer (step 5), but the real-time number comes from the stream.

A Stream Processor consumes the log and continuously aggregates clicks into time windows (per-minute, per-campaign counts), writing rollups to an Aggregates DB (OLAP/time-series). Dashboards query small, pre-summed buckets, not raw events.

Why this piece earns its place

The knob this step hands you is the grain of the rollup key, and it costs more than it looks. Per-minute, per-campaign is one counter per campaign per minute. Add region and device — because step 3’s dashboard slices by them — and you are holding the cross-product of every combination that saw traffic, all of it live in the processor’s window state and all of it copied into every checkpoint. Checkpointing is what fails first: its duration grows with state, and once it grows past the checkpoint interval the job spends its life snapshotting instead of counting, and then takes forever to recover when it does restart. So pick the grain from the queries advertisers actually run rather than from every dimension you happen to have collected, and push the rare high-cardinality slices — creative id, placement, individual publisher — down to the batch path in step 5, which can afford a full scan of the lake and does not have to hold anything open while it waits.

  • per-minutewindows
  • rollupsnot raw
  • secondsfreshness

What the new pieces do

Stream Processorworker
Consumes clicks and continuously aggregates them into per-minute, per-campaign counts (Flink/Spark Streaming) — turning a firehose into rollups.
Aggregates DBstore
Stores the rolled-up counts by dimension and time bucket, optimized for the slice-and-dice queries advertiser dashboards run.

Back of the envelope

store counts, not clicks
orders of magnitude less data
tumbling / sliding windows
infinite stream → finite queryable buckets
per-minute rollups ⇒ seconds fresh
dashboards read pre-summed rows

Step 3 · Let them see it

The query path

Advertisers need to slice the data many ways — by campaign, region, device, time range — and fast, while ingestion and aggregation keep hammering at full volume.

Ad ClickAdvertiserIngest APIStream ProcessorAggregates DB
New in this step: Advertiser. · swipe to pan the diagram

Advertisers slice by campaign, region, device, time — while ingestion hammers at full volume. How do you keep dashboards fast?

  1. Querying the raw firehose for every slice is slow and pits read load against the write path. Dashboards should read summaries, never the raw events.

  2. The processor’s working state is transient and tuned for aggregation, not flexible slice-and-dice, and reads would contend with processing. Persist rollups to a query-optimized store instead.

  3. Reads hit small pre-aggregated, time-bucketed rows built for slice-and-dice, never the firehose — so query load and write load stay isolated. Separate read and write models.

Point dashboards at the Aggregates DB, which is built for exactly these slice-and-dice reads over time-bucketed data. Reads hit small pre-aggregated rows and never touch the raw firehose, so query load and write load stay isolated.

Why this piece earns its place

This step quietly assumes every metric on the dashboard is additive — that a day is the sum of its minutes and a country is the sum of its cities. Clicks are, which is the only reason pre-aggregation works at all. The assumption breaks the first time someone asks a question the stored shape cannot answer by summing: unique users reached by a campaign, or click-through rate against impressions. Distinct counts do not add up — one person clicking in two different minutes is one user, not two — so rolling buckets together overcounts, and you cannot repair it at read time, because the identities were discarded at write time. Both defenses have to be chosen here, in the rollup: keep a sketch such as HyperLogLog per bucket so distincts can be merged approximately, and for any ratio store the numerator and denominator as separate additive columns and divide after summing. Never store the ratio itself — averaging pre-computed rates across buckets is the classic wrong answer, and the dashboard renders it just as confidently as a right one.

What the new pieces do

Advertiserclient
Wants near-real-time totals — clicks by campaign, minute, region — to manage spend. Reads aggregates, never the raw firehose.

Step 4 · Count it once

Exactly-once aggregation

Streams deliver at least once, and processors restart and replay. Naively, a replayed click gets counted twice — and since clicks are billed, double-counting is charging advertisers for clicks that never happened.

Stream Processorwindowed aggDedupidempotency keys
New in this step: Dedup.

The stream delivers at-least-once and processors replay on restart. Clicks are billed — how do you not double-count?

  1. Exactly-once delivery is impossible across processor restarts and retries — a replayed click would inflate billed totals. The counting itself must be resilient to redelivery.

  2. Track click ids already counted (or use the engine’s exactly-once state + idempotent writes) so reprocessing the same click leaves totals unchanged. At-least-once delivery → exactly-once counts.

  3. Guessing and subtracting duplicates is inaccurate — unacceptable for billing. You need precise per-click dedup, not a statistical correction.

Give every click a unique id and dedup on it: the processor keeps track of ids already counted (or uses the framework’s exactly-once state + idempotent writes) so reprocessing the same click leaves the totals unchanged.

Why this piece earns its place

Remembering ids is not free, and the question this step skips is how long you remember them. You cannot keep every id forever — that set grows with every click ever ingested, and it lives in the same processor state that has to be checkpointed — so in practice the dedup set is a bounded window, and a duplicate that arrives after it expires is counted again. The width is the knob, and it has a floor: it has to cover the whole envelope in which a redelivery can plausibly arrive, which includes a client retrying, a processor replaying from its last committed offset after a long outage, and the stragglers of step 7 landing well after their window. Set it from the observed lateness of real traffic, not a round number, and know which failure each direction buys — too narrow double-counts and overbills, too wide inflates state and slows every checkpoint. It is also why the guarantee here is scoped rather than absolute: the unbounded backstop is the batch pass in step 5, which dedups across the entire lake at once and has no window to fall out of.

What the new pieces do

Dedupservice
Recognizes a click it has already counted (by a unique click id) so retries and replays don’t inflate totals — the key to accurate, exactly-once counts.

Back of the envelope

at-least-once + replay
a naive count double-counts on retry
dedup on unique click id
reprocessing leaves totals unchanged
= exactly-once counts
the effect happens once, not the delivery

Step 5 · Trust the numbers

Batch reconciliation

The fast stream path can still drift — a bug, a dropped window, a late-arriving correction. For a billing system, “probably right” isn’t good enough; you need a number you can defend.

Stream Processorwindowed aggDedupidempotency keysBatch RecomputereconcileAggregates DBfast + reconciledRaw Event Lakeevery click
New in this step: Batch Recompute, Raw Event Lake.

The fast stream path can drift (a bug, a dropped window). For a billing system, how do you get numbers you can defend?

  1. Even a well-tested stream path drifts from dropped windows, dedup gaps or late data — and "trust us" doesn’t survive a billing dispute. You need an independent, authoritative recomputation.

  2. Manual reconciliation doesn’t scale to billions of events and erodes trust. The system itself must produce a defensible number automatically.

  3. Store every raw click in a durable lake and re-aggregate from scratch periodically to correct the fast path. Stream gives speed, batch gives authoritative truth — a lambda architecture you can always recompute from.

Keep every raw click in a durable Event Lake, and run a periodic Batch Recompute that re-aggregates from scratch and corrects the Aggregates DB. The stream gives speed; the batch gives an authoritative, reconciled source of truth (a lambda architecture).

Why this piece earns its place

Lambda’s real cost is not running two systems, it is that the definition of a billable click now lives in two places. The stream and the batch job have to agree on the dedup key, the window boundaries, the timezone, and exactly which of step 6’s fraud rules disqualify a click. Let one drift and reconciliation reports a difference every single cycle, and you will lose days deciding whether you are looking at a data problem or a code problem — the diff looks identical either way. So derive both paths from one shared definition wherever the engines allow it, and treat a persistent gap as a defect to explain rather than a number to accept. The other thing this step needs is a policy nobody asks for at the whiteboard: when does a number stop moving? Advertisers screenshot dashboards and finance invoices off them, so a bucket has to seal at some point, after which a correction becomes a line on the next invoice instead of a silent rewrite of yesterday. Reconciliation that rewrites history forever is indistinguishable from a system that cannot count.

What the new pieces do

Batch Recomputeworker
Periodically re-aggregates the raw events from scratch to correct any drift in the fast path — the slow, authoritative source of truth.
Raw Event Lakestore
A durable, append-only archive of every raw click. The ground truth that batch jobs replay and that lets you recompute anything later.

Back of the envelope

fast stream = speed, approximate
near-real-time, but may drift
batch over raw lake = truth
re-aggregate from scratch, reconcile
keep raw forever
you can always recompute anything later

Step 6 · Real clicks only

Fraud detection

A big slice of ad traffic is bots and click farms. Counting fraudulent clicks bills advertisers for nothing and corrupts the metrics they make decisions on — left unchecked, it poisons the whole product.

Stream Processorwindowed aggBatch RecomputereconcileAggregates DBfast + reconciledFraud Filterbots · dupes
New in this step: Fraud Filter.

A big slice of ad traffic is bots and click farms. How do you keep from billing advertisers for fraud?

  1. Billing fraud then refunding erodes trust, is operationally costly, and corrupts the live metrics advertisers decide on. Fraud must be excluded before billing, not after.

  2. A fraud filter flags impossible rates, suspicious IPs/devices and bot patterns, excluding them from billable counts while keeping them in raw data. Real-time catches obvious abuse, batch models catch the subtle kind.

  3. Aggressively dropping "unusual" clicks throws away real human clicks (false positives) — also lost revenue. Keep everything, bill only on validated clicks, and let fraud rules evolve against retained evidence.

Run a Fraud Filter over the stream: flag impossible click rates, suspicious IPs/devices, and known bot patterns, excluding them from billable counts (while keeping them in raw data for analysis). Real-time signals catch obvious abuse; batch models catch the subtle kind.

Why this piece earns its place

The knob here is where the line between observed and billable sits, and its two errors neither cost the same nor announce themselves the same way. A miss bills an advertiser for a bot: visible, disputed, corrosive to trust. A false positive throws away a real human click: nobody complains, because no one notices a charge they never received, and the loss surfaces only as revenue you quietly never invoiced. That asymmetry pushes teams to tighten the filter until the invisible error is the larger one, and nothing in the metrics will tell you that happened. The only defense is a labelled evidence set — sampled clicks properly adjudicated, crucially including the ones you rejected — because without labels you can measure how much you blocked but never how much of it was real. The second thing to say out loud is that this is the only adversarial component on the page. Whoever is on the other side gets to observe the effect of every rule you ship, so a fraud path needs a release cadence and a periodically retrained batch model, not a rule list written once and left alone.

What the new pieces do

Fraud Filterservice
Flags non-genuine clicks — bots, click farms, impossible rates — so advertisers aren’t billed for fraud and counts reflect real humans.

Step 7 · Scale & late events

The sharp edges

A viral campaign creates a hot partition that overwhelms one processor, and a phone offline for an hour sends its click late — after its window already closed and was reported.

Ad ClickAdvertiserIngest APIStream ProcessorDedupBatch RecomputeAggregates DBRaw Event LakeFraud FilterEvent Stream
The system as it stands at this step. · swipe to pan the diagram

Spread load by partitioning on a high-cardinality key (and pre-aggregating at the edge for hot keys). Handle stragglers with watermarks and a grace period: keep windows open briefly for late events, then emit corrections that the batch layer ultimately reconciles.

Why this piece earns its place

A watermark is the disciplined version of a guess, and the guess is the grace period — so the useful thing to say is how you would set it. It comes from the measured distribution of arrival delay: find the point where waiting longer stops recovering a meaningful number of clicks, because every extra moment of grace delays the emission of every window, including the overwhelming majority that were complete on time. That is the direct trade against step 2’s promise of totals within seconds, and erring short is survivable — it only pushes more clicks into the correction path and makes yesterday’s number visibly move once. The failure worth naming is the stalled watermark. A watermark advances with the slowest partition’s event time, so one partition with no traffic — a quiet region overnight, or a shard left empty after a partitioning change — holds it back, and every window in the job stays open waiting for a straggler that will never come. Emit idle markers from silent partitions, so no data reads as no data rather than as no time passing.

You did it

You just designed an ad click aggregator.

Ad ClickAdvertiserIngest APIStream ProcessorDedupBatch RecomputeAggregates DBRaw Event LakeFraud FilterEvent Stream
The finished design, end to end. · swipe to pan the diagram

Everything you assembled, in order

  • A thin ingest API appends clicks to a durable, replayable event stream.
  • A stream processor aggregates into time windows, storing rollups in an OLAP DB.
  • Dashboards query pre-aggregated buckets, isolated from the write path.
  • Per-click dedup keys turn at-least-once delivery into exactly-once counts.
  • A raw event lake + batch recompute reconcile the fast path (lambda architecture).
  • A fraud filter separates observed clicks from billable, valid ones.
  • Partitioning handles hot keys; watermarks and event-time handle late events.

Where an interviewer pokes next

Getting the boxes right is the easy half. These are the questions that separate a candidate who drew the diagram from one who has run the thing. Answer each one out loud before you open it.

  1. Why a lambda architecture instead of just trusting the stream?

    The streaming layer optimizes for latency and is vulnerable to subtle errors — dropped windows, dedup gaps, late data, a bug deployed mid-stream. The batch layer re-derives counts from the immutable raw lake and overwrites the fast path, giving a defensible source of truth for billing. Speed from streaming, correctness from batch. (Kappa is the alternative: one replayable stream layer, recomputed by replaying the log.)

  2. How do you handle late-arriving clicks after a window closed?

    Aggregate by event time (when the click happened), not processing time. Watermarks track "we’ve probably seen everything up to T" and keep windows open for a grace period; clicks within it still land in the right bucket. Anything later emits a correction, and the batch layer reconciles it — so a phone offline for an hour still counts correctly.

  3. How do you stop a viral campaign creating a hot partition?

    Partition the stream on a high-cardinality key (click id, or campaign+shard) rather than raw campaign id, and pre-aggregate at the edge so one hot campaign’s clicks are partially summed before hitting a single processor. You can also split a hot key across sub-partitions and merge their counts — the same hot-key playbook as caches and queues.

  4. Is exactly-once across the whole pipeline really achievable?

    End-to-end exactly-once holds within a closed system (Flink + Kafka transactions: read-process-write with offsets committed atomically), but the ingest edge is at-least-once because a client may retry a click it already sent. So you assign a unique click id at the source and dedup on it — exactly-once becomes "at-least-once delivery + idempotent counting," which is what actually holds up.

  5. What’s the difference between the raw lake and the aggregates DB?

    The raw event lake is an append-only archive of every individual click — huge, cheap, immutable, the ground truth you replay and audit. The aggregates DB holds small pre-summed rollups by dimension and time bucket, optimized for fast dashboard slice-and-dice. One is write-once/read-rarely; the other is the hot read path. Keeping both makes the system both correct and fast.

Check yourself — the answers, and why

Eight steps in, these are the calls you should be able to make cold. Pick one, then read why.

  1. Clicks are ingested into a durable stream first because…

    A durable log absorbs spikes, loses nothing if a consumer lags, and feeds many consumers independently.

  2. Dashboards query the Aggregates DB rather than raw events because…

    Store counts, not clicks — small pre-aggregated rows keep interactive queries fast over billions of events.

  3. At-least-once delivery becomes exactly-once counts via…

    Exactly-once is about the effect happening once — dedup keys make replays harmless.

  4. The batch recompute exists to…

    Lambda architecture: stream for speed, batch over the raw lake for defensible billing numbers.

  5. Late-arriving clicks are handled by…

    Aggregate by when the click happened; watermarks bound the wait, batch fixes the rest.

Worked example: one click on campaign 42, sent twice, counted once

A phone taps an ad for campaign:42 at 12:03:41, event time. The response is slow, so the client resends the same click. Follow both copies through the pipeline. The design names millions of clicks a second and per-minute windows. The grace period and dedup window are illustrative.

StepWhereWhat happensNumbers
1Ad ClickThe client creates a unique click_id and stamps the event time before sending. The retry carries the same id.1 click, 2 sends
2Ingest APIValidates each send, appends it, and acknowledges only after quorum replication. The browser has already moved on, so nobody would retry a lost click.2 appends, 0 DB writes
3Event StreamBoth copies land on the partition for campaign 42 + shard, a higher-cardinality key so a viral campaign doesn't pin one processor.2 offsets, replayable within retention
4Raw Event LakeBoth copies are archived as they are. The lake keeps everything, and the batch job dedups later across all of it.2 raw rows, no expiry
5Stream Processor + DedupCopy 1's id is unseen, so it counts. Copy 2's id is in the bounded dedup set, so the update does nothing.+1, then +0 (window 24 h, illustrative)
6Fraud FilterRate and device checks pass, so the click adds to both the observed and the billable counts.observed +1, billable +1
7Stream Processor (window)Adds to the event-time bucket 12:03 × campaign 42 × region × device. The window emits once the watermark passes 12:04 plus grace.grace 2 min (illustrative)
8Aggregates DB → AdvertiserThe bucket row is upserted with additive columns. An hour-long dashboard view of this slice reads 60 per-minute rows, not raw events.60 rows per slice-hour
9Batch RecomputeThe nightly pass dedups across the whole lake, re-aggregates and compares with the fast path. They match, so the 12:03 bucket seals for invoicing.diff 0, bucket sealed

Row 5 is where billing is won or lost. Without the click id, the retry is a second charge for one click (Step 4). The dedup set is bounded, though, so a resend after it expires is caught only by row 9, which is why Step 5 exists. At 10× traffic the log absorbs the spike, and the processor's checkpoint breaks first. Window and dedup state grow until a snapshot takes longer than its interval. Step 2 keeps the rollup grain narrow, and Step 7 splits hot keys.

How you’d open this design in an interview

Before any boxes: agree what it must do, pin the qualities that shape everything, then build — naming each trade-off as you make it. The walkthrough above is that exact order.

What it must do

Agree on these before drawing a single box.

  • Ingest: accept a click event and durably record it fast — an append to the stream, then return.
  • Aggregate: roll clicks into per-minute, per-campaign counts advertisers can read.
  • Query: slice totals by campaign, region, device and time range on a live dashboard.
  • Count once: a replayed or retried click must never inflate a billed total.
  • Bill real humans: exclude bots and click farms from the numbers advertisers pay on.

The qualities that shape everything

Each one names the mechanism that buys it.

Absorb millions of clicks/sec without dropping revenue
A thin Ingest API appends each click to a durable Event Stream (Kafka) and returns — ingestion is decoupled from processing, so a slow consumer can’t drop clicks.
Totals fresh within seconds
A Stream Processor continuously aggregates clicks into per-minute, per-campaign windows written to an OLAP store — dashboards read pre-summed buckets, not raw events.
Interactive dashboards under full write load
Point reads at the Aggregates DB of pre-summed, time-bucketed rows so query load and write load stay isolated.
Exactly-once counts despite at-least-once delivery
Give every click a unique id and dedup on it (or use the framework’s exactly-once state + idempotent writes), so reprocessing leaves totals unchanged.
Numbers you can defend in a billing dispute
Keep every raw click in a durable lake and run a periodic Batch Recompute that re-aggregates from scratch and reconciles the fast path (a lambda architecture).
Late events still land in the right bucket
Aggregate by event time with watermarks and a grace period; anything later emits a correction the batch layer reconciles.

The trade-offs you say out loud

Senior signal isn’t the boxes — it’s naming what you gave up and why it was the right price.

Ingest to a durable log over a synchronous DB write per click

A database can’t absorb millions of writes/sec, and any slow downstream step backs up and drops billable clicks. The log absorbs spikes, buffers durably, and lets many consumers read independently.

Pre-aggregated rollups over aggregating raw clicks at query time

Scanning billions of raw events per dashboard query is far too slow. Storing per-minute counts instead of clicks is orders of magnitude less data and exactly what dashboards ask for.

Dedup on a unique click id over trusting exactly-once delivery

Exactly-once delivery is impossible across processor restarts and retries. Idempotent counting keyed on a click id turns at-least-once delivery into exactly-once counts.

Lambda: stream + batch over trusting the stream path alone

Even a well-tested stream path drifts from dropped windows, dedup gaps or late data. A batch recompute over the immutable raw lake gives a defensible source of truth for billing.

Two counts — observed and billable-valid over billing all clicks then refunding disputes

Billing fraud then refunding erodes trust and corrupts the live metrics advertisers decide on. Keep everything but bill only on validated clicks.

The answer, out loud

What a strong answer to “Design an Ad Click Aggregator” sounds like, first question to last trade-off. It is about 7 minutes of talking; the whiteboard and the interviewer fill the rest of the 45. Read it aloud once, then close the page and give it yourself.

  1. 0–4 min

    Scope, and what makes this one different

    Let me set the scope first. We’re counting ad clicks, and what makes this different from a normal analytics pipeline is that the count is an invoice — somebody gets billed for it — so being approximately right isn’t something I can trade away for speed. Two audiences want these numbers and they want different things. The advertiser wants totals moving within seconds, sliced by campaign, region, device, time, so they can shift spend today. Finance wants a number they can defend in a dispute weeks later. Those are genuinely different systems, and most of my design is about getting both without pretending one can do the other’s job.

  2. 4–9 min

    Get the click out of the request path

    So the first move is to make ingestion do almost nothing. At millions of clicks a second, a synchronous database write per click isn’t a capacity problem I can buy my way out of, and worse, it couples ingestion to whatever is slowest downstream — a slow write becomes a dropped click, and a dropped click is money nobody can recover, because the browser has already moved on to the landing page and there’s no one left to retry. So a thin ingest service validates the event, appends it to a durable log, and returns. Everything downstream reads that log at its own pace.

    Built in step 1: Ingest to a stream
  3. 9–15 min

    Turn the firehose into rollups

    Then I have to turn a firehose into something a human can read. Nobody scans a billion raw clicks per dashboard refresh. A stream processor consumes the log and aggregates into time windows — per-minute counts per campaign — and writes those rollups into a store built for time-bucketed reads. I’m storing counts instead of clicks, which is orders of magnitude less data and is exactly the shape a dashboard asks for. The thing I’d flag while drawing it is that I’m choosing a grain here, and every extra dimension I roll up by multiplies the state the processor has to hold open.

    Built in step 2: Stream aggregation
  4. 15–20 min

    The read path

    The query path then just points at that aggregates store. Reads hit small pre-summed rows and never touch the raw events, so a heavy dashboard user can’t slow ingestion down and full-volume ingestion can’t slow the dashboard. If the interviewer pushes on it, I’d say what pre-aggregation quietly assumes is that everything on the dashboard adds up. Clicks do. Unique users don’t, and a rate isn’t the average of rates — so if those are coming, I have to decide how to store them at write time, because at read time the information is already gone.

    Built in step 3: The query path
  5. 20–27 min

    Counting each click exactly once

    Now the part I’d actually spend time on, because it’s where billing systems go wrong. The log delivers at least once, and processors restart and replay, so a naive counter double-counts — and double-counting means charging for clicks that never happened. I wouldn’t try to argue the delivery is exactly once, because at the edge it isn’t: a client can resend a click it already sent. Instead I assign every click a unique id at the source and make the counting idempotent, so if I’ve already counted that id the update does nothing. At-least-once delivery plus idempotent counting gives me exactly-once effects, which is the version that actually holds up.

    Built in step 4: Exactly-once aggregation
  6. 27–33 min

    Why I still want a batch pass

    Even with that, I wouldn’t bill straight off the streaming path. Streaming is tuned for latency and it drifts — a dropped window, a bug shipped mid-stream, data that turned up after I’d already reported. So I keep every raw click in a durable append-only lake and run a batch job that re-aggregates from scratch and reconciles the fast numbers. Stream for speed, batch for truth. The honest cost is that I now maintain two implementations of what a click means, and if they ever disagree about the dedup key or a window boundary, reconciliation reports a gap forever and nobody can tell whether it’s a data bug or a code bug.

    Built in step 5: Batch reconciliation
  7. 33–38 min

    Bots, and two different counts

    Then fraud, which I’d raise myself if nobody asked, because a real share of ad traffic is bots and click farms. I wouldn’t bill everything and refund disputes — that corrupts the live metrics advertisers are deciding on, not just the invoice. So I filter on the stream for the obvious things, impossible click rates and known bot patterns, run heavier models in batch for the subtle ones, and carry two numbers: what we observed and what is billable. Everything stays in the raw lake either way, because I want to be able to re-judge old traffic when the rules get better.

    Built in step 6: Fraud detection
  8. 38–43 min

    Hot campaigns and late clicks

    Two edges I’d want to name before time runs out. One is hot keys: a viral campaign sends all its clicks down one partition to one processor, so I partition on something higher-cardinality and pre-aggregate before the merge. The other is late arrivals. A phone that was offline for an hour sends its click after the window it belongs to has closed and been reported. So I aggregate by when the click happened rather than when it arrived, hold windows open for a bounded grace period, and let anything later emit a correction that the batch layer reconciles.

    Built in step 7: The sharp edges
  9. 43–45 min

    The trade-off, and what’s next

    The trade-off in one line: the streaming path buys freshness and pays in drift, the batch path buys defensibility and pays in latency, and the whole design is an argument for needing both rather than choosing. With more time, the next thing I’d work out is when a number stops moving — sealing a bucket so an invoice isn’t being quietly rewritten a week later — and I’d pressure-test whether one replayable stream layer could replace the batch path, so a click gets defined once instead of twice.

What this teaches

Learn system design by building a real-time ad click aggregator step by step. An interactive guide covering high-volume event ingestion, stream processing with windowed aggregation, exactly-once counting, a batch reconciliation (lambda) path, fraud detection, and handling late events at scale.

Key takeaways

  • A thin ingest API appends clicks to a durable, replayable event stream.
  • A stream processor aggregates into time windows, storing rollups in an OLAP DB.
  • Dashboards query pre-aggregated buckets, isolated from the write path.
  • Per-click dedup keys turn at-least-once delivery into exactly-once counts.
  • A raw event lake + batch recompute reconcile the fast path (lambda architecture).
  • A fraud filter separates observed clicks from billable, valid ones.
  • Partitioning handles hot keys; watermarks and event-time handle late events.

Concepts covered

  • What are we counting?
  • Ingest to a stream
  • Stream aggregation
  • The query path
  • Exactly-once aggregation
  • Batch reconciliation
  • Fraud detection
  • The sharp edges
built to be counted, not memorized — make the calls, kill the processor, run the gauntlet.
Finished this one? 0 / 65 System Designs done
NextDesign a Notification System Closest system design to this one

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More System Designs