Home › High-Level Design › Design an Ad Click Aggregator
D

Design an Ad Click Aggregator

This is the pipe that counts ad clicks and turns them into numbers advertisers pay against. It forces you to hold two opposite things in your head at once: brutal write volume and precise, queryable counts. Lean too hard on one and you lose the other. Design purely for throughput and billing drifts, so an advertiser disputes their invoice. Design purely for exactness and the pipeline falls over at 500k clicks per second. The whole 45 minutes is about keeping both alive, and the candidates who pass split the fast path from the exact path in the first few minutes.

High-Level DesignHard14 min read

If you remember five things

The whole answer in one glance. Everything below is the defense of these five calls.

  1. Log first, aggregate later. The gateway validates and appends every click to Kafka, then returns. Durability lives in the log, so a slow or dead processor can never drop a click.
  2. Lambda split. Streaming gives the dashboard fast-and-approximate counts; a daily batch job recomputes exact totals from the raw lake for billing. Speed for humans, exactness for money.
  3. Idempotency via client click_id. A window-scoped seen-set drops duplicate retries, and Kafka is partitioned so both copies of a dupe land on the same worker.
  4. Hot keys get salted. A viral ad fans across N partitions with a two-stage sum, applied only to ads that are actually hot, never blanket.
  5. Event-time windows with watermarks. Late clicks land in the bucket where they happened, with a grace window, and batch catches the stragglers past it.

1Clarifying the requirements#

Pin down scope before drawing a single box. The four questions below each kill a different ambiguity, and every answer narrows the design hard. Here is how I would open:

Candidate: When you say "click aggregator," are we counting raw click events, or also doing attribution, like which campaign and creative drove a conversion later?

Interviewer: Just clicks for now. Every click already carries the ad ID. We tie it back to the advertiser and campaign. No downstream conversion modeling.

Candidate: Who reads the aggregated data? A dashboard a human refreshes, or automated bidding systems that need near real time?

Interviewer: Both. Advertisers watch a dashboard that should feel live, say within a minute. And our billing and pacing systems query the same counts. Billing has to be exact at the end of the day.

Candidate: So I have a soft real-time path and a hard correctness path. Can clicks arrive late or duplicated?

Interviewer: Yes to both. Mobile SDKs retry. A click can show up ten minutes late or get sent twice. You cannot overcharge advertisers, and you cannot lose clicks either.

Candidate: Last one. What query shapes? Arbitrary slicing, or a known set?

Interviewer: Known set. Clicks per ad, per campaign, bucketed by minute, hour, and day. Filterable by a few dimensions like country and device type. No "group by anything you feel like."

What those answers just told me:

  • The read side is OLAP-style rollups on fixed dimensions, not a general analytics engine.
  • "Known set" is what lets me precompute tables instead of building a query planner. "Arbitrary slicing" would have me designing a columnar warehouse, a completely different answer.
  • "Late and duplicated, and you cannot overcharge" is the interviewer handing me the two theses: idempotency and the streaming-versus-batch split.

Functional requirements

  • Ingest click events at very high volume, each tagged with an ad ID, timestamp, and a handful of dimensions.
  • Aggregate clicks into time buckets (minute, hour, day) per ad and per campaign.
  • Serve queries: "clicks for ad X over the last N minutes," sliceable by country and device.
  • Near real-time freshness for the dashboard, roughly one minute.
  • Exact, reconciled daily totals for billing.

Non-functional requirements

  • Scale to around 500k clicks/sec at peak.
  • Ingestion must not drop events, even when a downstream stage is slow.
  • Idempotent: duplicate clicks counted once, late clicks still land in the right bucket.
  • Query latency under about 200 ms for the dashboard.
  • Multi-region, because ads serve globally and clicks happen everywhere.
From the interviewer's chair

This opening is scored before you draw a box. I am listening for whether you separate the fast path from the exact path on your own, and whether you ask about dupes and lateness unprompted. Start by jamming all the counting into one database and I have quietly downgraded you. Name the two paths first, then go spend your time there.

2Back-of-the-envelope estimation#

Get the scale in your head, because it decides the architecture. I do this math out loud, because the design falls out of exactly one shrink, and I want the interviewer to watch me find it.

The point of this section

You are not here to compute storage to three decimals. You are here to surface one load-bearing fact: raw ingest is 8.6 TB/day, but aggregated answers are tens of GB/day. That 100x shrink is the entire reason this problem is tractable. Find it fast, then move on.

Clicks and traffic.

  • 10 million active ads, platform doing about 150k clicks/sec on average.
  • Peak is spiky (a Super Bowl ad, Black Friday), so design for 500,000 clicks/sec.
  • Size for the average and the spike takes you down at the worst possible moment.

Write QPS (the number that shapes everything).

  • 500k/sec of counted, correct data at the top.
  • No single database swallows that without help. A well-tuned Postgres tops out in the low tens of thousands of writes/sec.
  • We are an order of magnitude past that, which is why the log-first design below is not optional.

Event size.

  • ad ID (8 bytes), timestamp (8), user/device hash (16), country (2), device (1), plus framing.
  • About 100 bytes on the wire, about 200 bytes once we add a click ID for dedup and JSON overhead.
  • Round to 200 bytes.

Ingestion bandwidth.

  • 500k/sec x 200 bytes = 100 MB/sec, about 800 Mbps.
  • A single 10 Gbps NIC could carry the raw bytes, but one node cannot do TLS, validation, and Kafka appends for 500k requests/sec.
  • So this is a horizontally scaled stateless tier, never one box.

Raw storage, if we kept every event.

  • 500k/sec x 200 bytes x 86,400 sec/day = ~8.6 TB/day, about 3 PB/year.
  • Keep raw events in cheap object storage for a retention window (30 to 90 days) for recompute and audits.
  • Never serve queries off raw. Scanning 8.6 TB to answer "clicks for ad X last minute" is insane.

Aggregated storage (the number that matters).

  • 10M ads, minute buckets, 1,440 minutes/day.
  • If 1M ads are active in a given minute: 1M rows/minute x 1,440 = 1.44 billion minute-rows/day.
  • At about 50 bytes/row (keys, a counter, a few dimension counts), that is ~72 GB/day at minute granularity.
  • Roll minutes into hours after a day, hours into days after a week, and the long-term footprint drops by orders of magnitude.
~100x
The shrink that makes this tractableAggregation turns 8.6 TB/day of raw clicks into tens of GB/day of answers. That is why I precompute rollup tables and never serve from raw. Remember this one; it comes back in every trade-off.

Read QPS.

  • Dashboards plus automated pacing, maybe 50k reads/sec, almost all hitting recent buckets.
  • Reads are 10x smaller than writes and overwhelmingly repeat the same "last few minutes" slice.
  • A thin Redis layer absorbs most of them, so the read path is cache-friendly and dwarfed by writes.

So the whole problem is three goals: absorb 500k writes/sec safely, aggregate them correctly despite dupes and lateness, and keep the read path cheap. Everything in the rest of this answer serves one of those three.

3Core APIs#

Two surfaces: a fat write endpoint and a thin read endpoint. The asymmetry is deliberate. The write path does the least possible work so it survives peak, and the read path only ever maps to a precomputed table.

POST /v1/clicks
Content-Type: application/json

{
  "click_id": "c_8f2a1b90e4",
  "ad_id": "ad_44921",
  "ts": 1793462400,
  "country": "IN",
  "device": "android"
}

Two choices worth defending here:

  • click_id is the dedup key, generated by the client SDK and reused on retries. This is the hinge of the whole correctness story, so I call it out early.
  • The response is a fast 202, because the gateway accepts the click into the log and returns before any aggregation happens.
{ "status": "accepted" }

Read side, fixed shapes only:

GET /v1/ads/ad_44921/clicks?granularity=minute&from=1793462400&to=1793466000&country=IN
{
  "ad_id": "ad_44921",
  "granularity": "minute",
  "buckets": [
    { "ts": 1793462400, "clicks": 1204 },
    { "ts": 1793462460, "clicks": 1388 }
  ]
}

The campaign rollup is the same shape with campaign_id. I keep the read API dumb on purpose:

  • Every supported query maps to a precomputed table, so the query service never aggregates at read time.
  • It does a range scan on a key and returns rows.
  • If a product manager wants a new slice later, that is a new precomputed table plus a backfill job, not a new code path in the query service.

4High-level design#

The point of this section

The box diagram is table stakes. What earns points is the single seam: a durable log between the gateway and everything downstream. That seam lets the write path survive 500k/sec, absorbs spikes, and guarantees a slow aggregator can never drop a click. Draw the boxes fast, then defend that seam.

The spine is clicks in through a stateless gateway, written to a durable log immediately, with a stream processor reading that log to maintain rolling counts and a separate serving store answering queries.

flowchart LR SDK[Client SDK] --> GW[Ingestion Gateway] GW --> K[(Kafka Log)] K --> SP[Stream Processor] SP --> AGG[(Aggregate Store)] SP --> RAW[(Raw Event Lake)] AGG --> QS[Query Service] QS --> CACHE[(Redis Cache)] CACHE --> DASH[Dashboard] QS --> DASH RAW --> BATCH[Batch Reconciler] BATCH --> AGG
The whole system at a glance. The one seam that matters is the Kafka Log between the gateway and everything downstream: it lets ingestion return instantly and stops a slow aggregator from ever dropping a click.

Write to a log first, aggregate later.

  • The gateway does almost nothing: validate the payload, stamp a server-side receive time, append to Kafka.
  • Kafka is the shock absorber. If the stream processor falls behind or crashes, clicks pile up in the log, they do not get dropped.
  • This is the single most important choice in the whole design. Durability lives in the log, not in the processor.
  • Put aggregation inline in the request handler and a slow DB write at 500k/sec means dropped clicks, which means lost revenue.

Stream processing for the real-time path.

  • A Flink-style job (or Kafka Streams) consumes the log, keys events by ad ID plus time bucket, and keeps running counts in windowed state.
  • Every few seconds it flushes partial counts to the aggregate store.
  • That is what makes the dashboard feel live and comfortably meets the one-minute freshness requirement.

Batch reconciliation for the correctness path.

  • The same raw events also land in an object store (an S3-style lake).
  • Once a day, a batch job recomputes exact totals from the raw log and overwrites the streaming estimates.
  • Streaming gives you fast and approximately right; batch gives you slow and exactly right. Billing reads the reconciled numbers.
  • This is the lambda architecture pattern, and for ad billing it is the honest answer: speed for humans, exactness for money.

The aggregate store is a time-series or columnar store.

  • Think Druid, ClickHouse, or Cassandra with careful partitioning.
  • It is optimized for "sum a counter over a time range for a key," which is literally the only read we do.
  • I would not reach for a general relational database, because the access pattern is so narrow that a purpose-built store is both cheaper and faster.
Pause before you read on: a mobile SDK on a flaky network sends the same click three times. With a naive counter that increments on every POST, what exactly goes wrong, and who complains?
Reveal the answer
You count the click three times, so the advertiser is overcharged and finance gets a dispute it cannot defend. Worse, the counts look plausible, so nobody notices until an invoice is challenged. This is the case that forces client-generated click_id plus a window-scoped seen-set, and reaching for it unprompted is what earns the room.

5Data model#

The aggregate table is the heart of it. Partition by ad plus a coarse time bucket so a query touches few partitions. The whole goal of this schema is that every supported query is a tight contiguous range scan, never a full scan.

Table: clicks_by_minute (the live serving table)

Field Type Notes
ad_id string Partition key component
bucket_ts timestamp Truncated to the minute, clustering key
country string Dimension, part of composite key
device string Dimension, part of composite key
click_count counter / bigint The aggregated value
updated_at timestamp Last write, for staleness checks
  • Primary key is (ad_id, bucket_ts, country, device).
  • "Ad X, last hour, country IN" hits a tight contiguous slice: 60 minute-rows for one ad, filtered to one country. The opposite of a scan.

Tables: clicks_by_hour and clicks_by_day (same shape, coarser bucket)

  • Identical shape with coarser bucket_ts.
  • Roll minute into hour into day so historical queries do not scan 1,440 rows per ad per day.
  • A "last 30 days" dashboard query hits 30 day-rows, not 43,200 minute-rows.

Table: processed_click_ids (dedup, in the processor's state, not a global DB)

Field Type Notes
click_id string Seen-set key
bucket_ts timestamp Which window it belongs to
ttl int Expire after the lateness window closes
  • This never becomes a giant global table. We only dedup within a bounded lateness window (say 15 minutes).
  • After that the window closes and the IDs are forgotten.
  • That bound is what keeps the seen-set from growing without limit, the failure mode of a naive "store every click ID forever" design.

Table: raw_clicks (the lake, for the batch job)

  • Just the original JSON, partitioned by date and hour.
  • Partitioning this way means the daily reconciler reads only the partitions it needs instead of the full 90-day history.

6Deep dives#

6.1 Idempotency: counting a click exactly once#

This is where candidates usually hand-wave. Clicks get retried by flaky mobile networks, and a naive increment on every POST overcharges the advertiser.

The fix rides on click_id.

  • The client generates it once per real click and reuses it on retries.
  • The stream processor keeps a short-lived seen-set keyed by click_id, scoped to the window.
  • First time it sees an ID, it counts it and records it. Second time, it drops it.
sequenceDiagram participant SDK as Client SDK participant GW as Gateway participant K as Kafka Log participant SP as Stream Processor participant AGG as Aggregate Store SDK->>GW: POST click_id c1 GW->>K: append c1 SDK->>GW: retry click_id c1 GW->>K: append c1 again K->>SP: deliver both c1 events SP->>SP: c1 not seen count it SP->>SP: c1 already seen drop it SP->>AGG: increment by 1
The dedup path. Both copies of a retried click land on the same processor because Kafka is partitioned by a stable key, so the seen-set catches the second one and the aggregate store is incremented exactly once.

Two things make this real.

  • The seen-set has to be partition-local. I partition Kafka by click_id (or by ad_id, then dedup within partition) so both copies of a duplicate land on the same processor instance. If they split across workers, neither sees the other and dedup fails silently, the worst kind of bug because the counts look plausible but are wrong. Partitioning is the actual hard part here, not the set lookup.
  • The seen-set is bounded by the lateness window. I store IDs for 15 minutes, which is how long I promise to accept late events into the correct bucket. After that a straggler is caught by the batch reconciler, and the streaming layer gets to forget. This is the difference between a dedup set that stays a few GB in memory and one that grows to petabytes.

6.2 The hot key problem#

This is the one that separates senior from staff. Click volume is not uniform.

Why one popular ad melts a worker.

  • A Super Bowl ad, a flash sale, a viral campaign: one ad_id can suddenly take 50k clicks/sec while most ads take a handful.
  • Key the stream by ad_id and every one of those 50k events lands on a single partition. That worker cooks while 200 others idle.
  • Because Kafka preserves order within a partition, the backlog on that one partition grows unbounded while everything else is fine.
flowchart TD IN[Hot ad events] --> SALT[Add random salt 0 to N] SALT --> P0[Partition ad 0] SALT --> P1[Partition ad 1] SALT --> P2[Partition ad 2] P0 --> PRE[Pre aggregate] P1 --> PRE P2 --> PRE PRE --> FINAL[Final sum per ad] FINAL --> AGG[(Aggregate Store)]
Key salting with two-stage aggregation. The hot ad's clicks spread across N partitions counted in parallel, then a cheap second stage sums N small partial counts into one number per ad per bucket.

The technique: key salting plus a two-stage sum.

  • Instead of keying by ad_id, key by ad_id plus a random salt from 0 to N-1.
  • The hot ad's clicks spread across N partitions, N workers each count their slice.
  • A cheap second stage sums the N partial counts back into one number per ad per bucket. It only ever sees N small numbers, so it is trivial.
  • If N is 10, a 50k/sec hot ad becomes ten streams of 5k/sec, which any single worker handles easily.

I would not salt every ad.

  • Blanket salting doubles work for nothing on the cold 99% and needlessly fans out the dedup state.
  • I detect hotness: if an ad's rate crosses a threshold, promote it to the salted path dynamically, or just salt the top-K ads that pacing already flags as high spend.
  • This adaptive decision is what I want a candidate to reach for. Blanket salting looks clever and costs you throughput on the common case.
Red flag that fails you here

Keying by ad_id, feeling good about clean partitioning, then freezing when I say "a Super Bowl ad just went viral." Reach for salting on your own, say why in numbers, and then volunteer that you only salt the hot ads. Candidates who salt everything blindly have not run this at scale.

6.3 Late and out-of-order events, and watermarks#

Events do not arrive in timestamp order. A click that happened at 12:00:05 might be processed at 12:00:02's moment, or show up ten minutes later off a backgrounded phone.

Bucket by event time, not processing time.

  • A click that happened at noon must count in the noon bucket, so I bucket by the client's ts.
  • Sanity-check it: a phone with a wrong clock can claim a click happened in 1970 or next week, so I clamp ts against the server receive time and reject anything implausibly far off.

Watermarks keep the windows honest.

  • A watermark of 12:15 means "I believe I have seen all events up to 12:15."
  • Windows stay open until the watermark passes their end plus the allowed lateness (my 15-minute grace).
  • A click for the noon minute that arrives at 12:10 still updates the noon bucket, because that window is still open.

What about a straggler at 12:30, past the grace period?

  • The streaming layer ignores it to keep the real-time numbers from flapping. Reopen old windows forever and the dashboard never settles.
  • The batch reconciler, which scans all raw events regardless of arrival time, folds it into the exact daily total.
  • The dashboard might be off by a rounding error for a few hours; the bill is never wrong.
  • That split is the whole justification for keeping batch around, and the cleanest way to explain why lambda earns its complexity here.

7Bottlenecks and trade-offs#

The log is a single point of pressure.

  • Everything funnels through Kafka, so I defend it with hundreds of partitions (each carrying a few thousand writes/sec, comfortable), replication for durability, and generous retention so a slow consumer never loses data.
  • If Kafka is unavailable, ingestion is down. I would rather the gateway buffer briefly to local disk and backfill than accept clicks into a void and lose them.
  • For money data, losing silently is the worst outcome.

Aggregate store write amplification.

  • Minute, hour, and day tables all get updated, but I do not write all three on the hot path.
  • The processor writes minute granularity; a lightweight rollup job derives hour and day asynchronously.
  • This keeps the write path lean and lets rollups lag without hurting the live dashboard, which only reads minute buckets for recent data anyway.

Streaming versus batch divergence.

  • The real-time number and the reconciled number will disagree, sometimes visibly. I treat this as a feature, but I make it explicit.
  • The dashboard labels recent buckets "live estimate" and older ones "final."
  • Advertisers tolerate a 0.5% wobble on the last few minutes. They do not tolerate a wrong invoice.
  • Being honest in the UI about which is which prevents a lot of support tickets.

Multi-region.

  • Ingest regionally (low latency for the SDK, local Kafka) and replicate aggregates to a global serving layer.
  • I resist a single global Kafka: the cross-region latency and failure coupling are not worth it, and one region's outage should not stall another's ingestion.
  • Regional ingest, regional pre-aggregation, then ship the small aggregated results to a central store. You move kilobytes of counts, not terabytes of raw clicks, across oceans.

Cost of exactness.

  • I could drop batch and lean fully on streaming with strong exactly-once semantics (Flink checkpointing plus transactional sinks). Tempting and cleaner.
  • I keep batch anyway for one reason: auditability. "We recomputed it from the immutable raw log" holds up when finance or an advertiser disputes a number.
  • A streaming-only system asks you to trust the pipeline's internal state, and for billing I would rather have the receipts.

Cardinality creep.

  • If someone later asks for "slice by any of 40 dimensions," the precomputed-table approach explodes combinatorially. Every new dimension multiplies the rows.
  • I would push back hard, or move that specific need to a columnar engine like ClickHouse or Druid that does fast ad-hoc rollups, and keep precomputed tables for the known high-traffic queries.
  • Knowing where to draw that line is the trade-off I care most about.

The write path stays dumb so it can survive the spike; all the cleverness lives downstream where it can take its time.

8Summary#

The decisions that matter:

  • Durability in the log, not the processor. Append every click to Kafka first, aggregate second. The log absorbs spikes and failures, and is the single most important call.
  • Lambda split. Streaming for the live dashboard (fast, approximate), batch reconciliation from the raw lake for billing (slow, exact). Label which is which in the UI.
  • Idempotency via client click_id plus a window-scoped seen-set, with Kafka partitioned so duplicates co-locate on one worker.
  • Hot keys solved with salting plus a two-stage sum, applied only to the ads that are actually hot, never blanket.
  • Event-time windowing with watermarks so late clicks land in the right bucket, and a grace period beyond which batch takes over.
  • Precomputed rollup tables keyed by ad and time bucket, because the query set is fixed and small.
  • Out of scope by choice: conversion attribution, fraud detection, arbitrary slicing. Naming them keeps me honest about what 45 minutes covered.

The thing to internalize: this problem is not about counting. Anyone can increment a number. It is about counting correctly under duplication, lateness, and skew, while keeping the write path dumb enough to survive 500k/sec. Nail those three and the rest is wiring.

Follow-ups the interviewer will ask (and your answers)
"A mobile SDK retries and sends the same click twice. Do you overcharge?"
No. The client stamps one click_id per real click and reuses it on retry. The stream processor keeps a window-scoped seen-set, counts an ID the first time and drops it the second. Kafka is partitioned by a stable key so both copies land on the same worker; otherwise dedup fails silently and the counts look plausibly wrong.
"A single ad goes viral and takes 50k clicks per second. What melts, and how do you fix it?"
Keyed by ad_id alone, all 50k land on one partition and that worker backs up unbounded while 200 others idle. I salt the key: ad_id plus a random 0 to N-1, so the load spreads across N workers, then a cheap second stage sums the N partial counts per ad per bucket. I only salt ads that cross a hotness threshold, not everything, so the cold 99% keeps its throughput.
"A click happened at noon but arrives at 12:10 off a backgrounded phone. Which bucket?"
The noon bucket. I window by event time (the client's ts, clamped against server receive time to catch wrong clocks), and use watermarks: the noon window stays open until the watermark passes its end plus my 15-minute grace. A straggler past 12:15 is ignored by streaming to stop the dashboard flapping, and the daily batch reconciler folds it into the exact total.
"Why keep a batch layer at all? Streaming can do exactly-once now."
Auditability. Flink checkpointing plus transactional sinks can get exactly-once, but for billing I want receipts. "We recomputed it from the immutable raw log" survives a dispute; "trust the pipeline's internal state" does not. Streaming serves the live dashboard, batch produces the number finance bills against.
"A PM wants to slice by any of 40 dimensions. Does your design hold?"
Not the precomputed-table part. Every new dimension multiplies the row count, so precomputing a 40-way cross product is a non-starter. I keep precomputed tables for the known high-traffic queries and push the arbitrary-slice need to a columnar engine like ClickHouse or Druid that does fast ad-hoc rollups. Knowing where to draw that line is the real trade-off.

What you can now explain in the room

  • Why durability has to live in the log, not the processor, to survive 500k writes/sec.
  • Why aggregation shrinks 8.6 TB/day of raw into tens of GB/day of answers, and why that makes the problem tractable.
  • How client-generated click_id plus a window-scoped seen-set counts each click exactly once, and why Kafka partitioning is the hard part.
  • Why a viral ad melts one worker, and how salting plus a two-stage sum fixes it without penalizing the cold 99%.
  • Why you window by event time with watermarks, and where the grace period hands off to batch.
  • Why lambda earns its complexity here: fast and approximate for humans, slow and exact for money.

For how specific companies push on this one, see our related guides on streaming systems and real-time analytics interviews.

Get a prep plan built around your Design an Ad Click Aggregator interview

Tell us the role and your interview date. We will send back a dated, day by day plan. Free.

Thanks. Check your inbox, your Design an Ad Click Aggregator prep plan is on the way.