Vibe Engines
YouTube
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

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.

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.

  • 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.

  • 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.

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.

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).

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.

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.

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…

    • It’s cheaper than a database
    • It decouples fast ingestion from slower processing and can replay
    • Streams are exactly-once

    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…

    • Raw events are encrypted
    • Pre-summed buckets make slice-and-dice fast and isolate read from write
    • It’s the only copy

    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…

    • A faster network
    • Deduping on a unique click id + idempotent updates
    • Counting twice and halving

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

  4. The batch recompute exists to…

    • Make the stream faster
    • Re-aggregate raw events into an authoritative, reconciled truth
    • Store dashboards

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

  5. Late-arriving clicks are handled by…

    • Dropping them
    • Event-time windows + watermarks + a grace period (then reconcile)
    • Counting them in the current window

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

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.

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

Explore the topic

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

More System Designs