16. Ad Click Aggregation Advanced + High-Volume Stream Processing

A. Realistic Interview Prompt

"Design the ad click aggregation system behind an ad platform like Google Ads or Facebook Ads: every time a user clicks an ad, an event needs to be recorded, deduplicated, and rolled up into near-real-time counts — clicks per ad, per campaign, per minute and per hour — that advertisers see on a live dashboard and that ultimately determine how much they're billed. The system needs to survive hundreds of thousands of clicks per second at peak, including the case where a single ad unexpectedly goes viral."

Every prior problem in this document that deals with high write volume (News Feed's fanout, Rate Limiter's counters) still, underneath, imagines a single logical event landing as a single logical write. This problem is the first one where that assumption itself has to be abandoned: at the volumes an ad network actually operates at, there is no database that will accept one row per click and still answer "how many clicks did ad X get in the last minute" fast enough to render a dashboard — and there is no way to count the number of distinct people who clicked an ad without an amount of memory that grows without bound, if you insist on an exact answer. The load-bearing decision this design is built around is: land every click into a durable, append-only log (Kafka) rather than writing it directly to a database; aggregate that log continuously through windowed stream processing rather than running periodic COUNT(*) queries over raw rows; deduplicate the inevitable stream of duplicate click events using an idempotency key established at click-generation time; and, for the specific question of how many distinct users clicked an ad, stop insisting on an exact answer altogether and use a probabilistic structure (HyperLogLog) that trades a small, known error rate for a fixed, tiny memory footprint per ad, no matter how large the true count actually is.

B. Difficulty

Advanced — the individual building blocks (a log-based queue, a stream-processing window, a probabilistic counting structure) are each well-documented and not exotic in isolation, but a candidate who hasn't internalized just how much a naive per-event database write costs at this specific volume will reach for "just write it to Postgres and shard it" and burn most of the interview discovering, step by step, exactly why that doesn't work. The real signal here is recognizing, unprompted, that this is fundamentally a streaming/aggregation problem rather than a storage problem, and being comfortable stating out loud that the system's headline numbers — click counts, unique reach — are deliberately approximate, not as a shortcut but as the correct engineering answer at this scale.

C. What This Problem Tests

D. Requirements

Functional

  1. Every ad click is recorded, associated with the specific ad, campaign, and advertiser it belongs to
  2. An advertiser can view near-real-time aggregate click counts for their campaigns, broken down per ad and per time bucket (minute/hour/day)
  3. An advertiser can view an estimate of unique reach — the number of distinct users who clicked a given ad — for billing-adjacent reporting, separate from raw click volume
  4. A click that's billable (counts toward an advertiser's spend) is counted exactly once, even if the underlying event is delivered to the system more than once
  5. An eventual, fully-exact reconciled count is available for each time bucket after some bounded delay, for billing disputes and audits

Non-Functional

Out of scope: ad serving/targeting/auction logic (assumed to already exist upstream and simply hand this system a rendered ad's click-through event); the advertiser-facing billing/invoicing system itself (this design produces the click counts that system consumes as an input); impression tracking (a related but separate, even higher-volume pipeline sharing much of this same architecture).

E. Clarifying Questions

#QuestionWhy It MattersDesign Decision Affected
1Does "click count" for billing need to be exact, or is a near-real-time approximation acceptable everywhere, including for spend calculations?Determines whether there's a strongly-consistent, exact-counting requirement anywhere in the system at all, or whether every number can be provisionalConsistency Model (V), Deep Dive 4 (P)
2What's the acceptable end-to-end latency between a click happening and it showing up in an advertiser's dashboard?Directly sets the aggregation window size and the watermark/lateness toleranceDeep Dive 3 (O), Deep Dive 5 (Q)
3Do we need unique-reach (distinct clickers) as a first-class metric, or only raw click volume?Determines whether a probabilistic cardinality structure (HyperLogLog) is required at all, or whether this is a simpler pure-counting problemDeep Dive 4 (P), Core Entities (H)
4Is click-fraud detection in scope for this system, or handled entirely by a separate downstream service?Determines whether fraud filtering is a first-class stage in this pipeline or an out-of-scope dependency this design simply feedsDeep Dive 7 (S)
5How long does an advertiser have to dispute a billed click count, and how far back does "eventually exact" need to reach?Sets the retention window for raw events and the scope of the reconciliation jobCapacity Estimation (G), Consistency Model (V)

F. Assumptions

G. Capacity Estimation

MetricValue / CalculationWhy It Matters
Active ad campaigns / distinct adsIllustratively, ~3M active campaigns, ~80M distinct ad creativesThe cardinality of the aggregation key space — the number that actually matters for rollup storage, not the raw event count
Clicks/day, platform-wide~2B clicks/day → 2×10⁹ / 86,400 ≈ 23,150/sec averageThe baseline sustained write rate the ingestion path (M) must absorb continuously, every day, not just during a spike
Peak-to-average ratioIllustratively ~20x during a major shopping event or one ad going viral (R), ≈ 460,000 clicks/sec sustained for the peak windowThe real design target — nearly half a million writes/sec is where a per-event relational insert (J) stops being a discussion and becomes simply impossible on any single reasonably-sized database instance
What a single relational DB node can actually sustainA well-tuned Postgres/MySQL instance realistically sustains roughly 5,000-15,000 simple indexed inserts/sec before write-ahead-log and index-maintenance overhead dominatesEven at the 23,150/sec average — before any peak multiplier at all — a single-node relational approach is already short by nearly 2x, and by ~30-90x at peak; this is the concrete arithmetic that motivates abandoning per-event DB writes entirely (J), not a stylistic preference
Raw click event sizeclick_id, ad_id, campaign_id, advertiser_id, user/device_id, publisher_id, timestamp, IP, user agent, referrer, bid CPC ≈ ~250 bytesSets the raw ingest byte-rate the log (K) must sustain
Raw ingest volume~2B × 250 bytes/day ≈ ~500GB/day; retained "hot" in the log for ~14 days ≈ ~7TB, then archived to cold object storage for the ~30-day dispute window and beyond (F, K)Comfortably within a well-partitioned log's reach — an order of magnitude below the raw byte volumes this document's storage-heavy problems (Dropbox, P14) already handle
Rollup rows/dayBounded by distinct (ad_id, minute) pairs that received ≥1 click, not by total click count — realistically tens of millions of rows/day, one to two orders of magnitude smaller than the 2B raw-event count, because a huge number of raw clicks collapse into the same rollup keyThe entire reason windowed aggregation is tractable where per-event storage isn't — the thing actually being stored and queried scales with key cardinality × time buckets, not with event volume (O)
Dedup/idempotency window store, live keysBounded to a ~10-minute dedup window (N), not a full day: ~2×10⁹ × (10/1440) ≈ ~13.9M live keys at any moment ≈ well under 1GB of keyed stateSmall and bounded specifically because the window is short — the same TTL-bounding reasoning Payment System's idempotency store uses (P15-M), applied to a duplicate-event window instead of a client-retry window
HyperLogLog sketch footprintA dense HLL register array is a fixed ~12KB per key regardless of true cardinality (AD); most (ad, window) keys stay in a sparse, few-dozen-byte representation until their estimated cardinality grows large enough to need the dense formFixed, small, and — critically — independent of how many distinct users actually clicked, which is the entire value proposition over a naive per-ad hash set (P)

H. Core Entities

EntityPurposeKey Fields
Advertiser / Campaign / AdThe billing and organizational hierarchy a click ultimately rolls up intoadvertiser_id, campaign_id, ad_id, bid_cpc, daily_budget
ClickEventOne raw, immutable click as it lands in the log — the unit of truth this entire pipeline is built to process (K, M)click_id (idempotency key, N), ad_id, campaign_id, user_id/device_id, event_time, ip, user_agent, referrer
DedupRecordTracks which click_ids have already been counted within the bounded dedup window (N)click_id, first_seen_at, expires_at
WindowedRollupOne finalized aggregate for a single (ad_id, window_start, window_end) triple — the actual queryable unit dashboards read (O)ad_id, campaign_id, window_start, window_end, click_count, estimated_spend, hll_sketch (bytes, P), is_final
LateCorrectionAn adjustment record for events that arrived after their window had already closed and emitted — never a silent mutation of the original rollup (Q)ad_id, window_start, delta_click_count, applied_at
FraudSignalA flag attached to a click, or a suppression applied to a rollup, from heuristic bot/fraud detection (S)click_id or (ad_id, window), reason, confidence, action (flagged/excluded)
ReconciliationDiscrepancyA flagged mismatch between the streaming rollup's provisional count and the batch job's exact recount over the same window (V, cross-referencing P15-Q's identical reconciliation concept applied to a different domain)discrepancy_id, ad_id, window, streaming_count, exact_count, resolved

I. API Design

-- public, hit directly by a user's browser/device when they click a rendered ad:
GET  /click?ad_id=<id>&campaign_id=<id>&click_id=<generated-at-ad-serve-time>&redirect=<landing_url>
     -> 302 redirect to the advertiser's landing page, near-instantly (M, L)
     (as a side effect, produces one ClickEvent onto the ingestion log — never blocks the redirect on it)
-- advertiser-facing reporting:
GET  /campaigns/{campaign_id}/stats?window=1h&from=...&to=...
     -> { ad_id, click_count (provisional, V), unique_reach_estimate (P), spend, is_final }
GET  /campaigns/{campaign_id}/stats/exact?window=1h&from=...&to=...
     -> { ad_id, exact_click_count, reconciled_at }              (only available once reconciliation (V) has run for that window)
-- internal, cluster-facing:
(stream processor) consumes ClickEvent topic -> dedup (N) -> windowed aggregate (O) -> WindowedRollup   (K)
(fraud worker) consumes the same ClickEvent topic in parallel, tags/suppresses suspicious clicks close to ingestion (S)
(reconciliation job) periodic: recompute exact counts from the durable log/archive, diff against WindowedRollup (V)

Why the public click endpoint has no request body and does the absolute minimum before redirecting: this is the one endpoint in the entire system sitting directly in a real user's page-load/navigation path — every extra millisecond here is a millisecond added to a real person waiting for a page to load, which is exactly the same "keep expensive work off the hot path" reasoning named generally in Patterns Playbook C. Producing to the log (M) is a fast, fire-and-forget append; every heavier step — dedup, aggregation, fraud scoring — happens entirely downstream, off this request's critical path.

Why there are two separate stats endpoints, not one: the plain /stats endpoint returns whatever the streaming pipeline currently believes, refreshed roughly every window (V) — fast, always available, explicitly not final. /stats/exact only ever returns a value once the slower batch reconciliation (V) has actually finished recomputing that specific window from the durable log — the same two-speed shape Payment System's saga-versus-reconciliation split uses (P15), reused here for a click count instead of a ledger balance.

J. Basic / Naive High-Level Design (V1)

The simplest thing that satisfies the functional requirement in isolation: the click-tracking redirect endpoint, on every click, runs INSERT INTO clicks (ad_id, campaign_id, user_id, ts) VALUES (...) directly against a relational database, synchronously, before issuing the 302 redirect. An advertiser's dashboard, to show "clicks in the last hour," runs SELECT ad_id, COUNT(*) FROM clicks WHERE campaign_id = ? AND ts >= NOW() - INTERVAL '1 hour' GROUP BY ad_id directly against that same table, on demand, every time the dashboard refreshes.

flowchart LR User(["User's Browser"]) -- "clicks ad" --> Redirect["Click Redirect Endpoint"] Redirect -- "synchronous INSERT" --> DB[("clicks table
one row per click")] Redirect -- "302 redirect" --> Landing["Advertiser's Landing Page"] Dashboard["Advertiser Dashboard"] -- "SELECT ... COUNT(*) ... GROUP BY ad_id" --> DB

V1 — a direct, synchronous per-click insert, with reporting implemented as ad hoc aggregate queries over the raw table.

Why this collapses under this problem's actual load, in the two ways that matter most: first, the write side simply cannot keep up — G's arithmetic isn't a rough approximation, it's the concrete reason a single relational node is already short of the platform's average click rate before any peak multiplier is applied at all, and no amount of "just add read replicas" helps, because replicas don't add write capacity. Sharding the writes helps to a point, but every shard boundary now has to somehow still answer "give me the total across all ads in this campaign" without re-introducing a fan-out query across every shard on every dashboard refresh. Second, and just as fatal even if the write problem were solved: COUNT(*)/GROUP BY over a raw table that grows by tens of millions of rows a minute means every dashboard refresh re-scans an ever-larger slice of raw data to answer the exact same question it answered a few seconds ago — the query gets slower over the course of the day, not just at scale, because "the last hour" itself contains more rows every single time it's asked. Neither flaw is fixable by throwing more database hardware at it; both point at the same root cause — this design treats aggregation as something computed fresh, from scratch, on every read, over ever-growing raw data, instead of computed once, incrementally, as data arrives.

K. Data Model & Store Choice

The raw click log lives in Kafka, not in any database: every ClickEvent (H) is appended to a durable, partitioned Kafka topic (Prerequisites G), partitioned by ad_id (with the hot-key exception handled separately, R). This is the direct structural fix for V1's write-throughput flaw (J) — appending to a log's sequential, ordered partition is a fundamentally cheaper operation than a random-access, indexed relational insert, and a log's whole design point is absorbing exactly this kind of high-volume, append-only, rarely-if-ever-updated event stream. Raw events are retained "hot" in Kafka for roughly two weeks (G), then archived to cold object storage (Prerequisites I) for the remainder of the ~30-day dispute window (F) — a metadata/blob-style split in spirit, though here what moves to cold storage is the raw event stream itself, not a large binary payload.

The rollup store is a wide-column/OLAP-oriented store, not the same relational table V1 reached for: WindowedRollup rows (H) are written by the stream-processing layer (O), keyed by (ad_id, window_start), into a store optimized for exactly this access pattern — range-scan a specific key's time series, not join across arbitrary tables (Prerequisites B's wide-column category, e.g. Cassandra-style, or a purpose-built time-series/OLAP columnar engine). This is the direct fix for V1's second flaw: a dashboard query now reads a small number of already-computed rollup rows for the requested window, never re-scanning raw events, because the aggregation work already happened once, incrementally, as the stream flowed through.

The dedup/idempotency window store is a fast, TTL'd keyed store, deliberately small and short-lived: DedupRecords (H, N) live in the stream processor's own keyed state (backed by something like RocksDB) or an external fast store such as Redis, bounded to the dedup window (~10 minutes, G) rather than growing with total event volume — the same TTL-bounding discipline Payment System's idempotency store uses (P15-M), reused here for a duplicate-event window instead of a client-retry window.

HyperLogLog sketches are stored as compact binary blobs alongside each rollup row, or natively in Redis: a hll_sketch field (H) attached to each WindowedRollup — a fixed handful of bytes to a fixed ~12KB, never a growing set of raw user IDs (P).

L. End-to-End Flow — A User Clicks an Ad: Ingestion, Dedup, and Windowed Aggregation into a Queryable Rollup

sequenceDiagram participant U as User's Browser participant R as Click Redirect Endpoint participant K as Kafka Topic (M) participant S as Stream Processor participant Dedup as Dedup State Store (N) participant HLL as HLL Sketch (P) participant Roll as Rollup Store (K) participant Dash as Advertiser Dashboard U->>R: GET /click?ad_id=...&click_id=C1 R->>K: produce ClickEvent (fire-and-forget) R-->>U: 302 redirect to landing page (immediate, M) K->>S: consume ClickEvent S->>Dedup: has click_id C1 been seen (bounded window)? Dedup-->>S: no — record C1 S->>S: assign event to tumbling window (ad_id, minute) (O) S->>HLL: add user_id to (ad_id, minute) sketch S->>S: watermark passes window end + allowed lateness (Q) S->>Roll: emit finalized WindowedRollup (count, hll bytes, is_final=true) Dash->>Roll: query recent windows Roll-->>Dash: provisional near-real-time counts (V)

The redirect to the user completes before any of the aggregation work even starts — everything from the Kafka produce onward is fully decoupled from the request the user is actually waiting on.

M. Deep Dive 1 — Ingesting at Scale via a Log

Requirement this addresses: the extremely-high-write-throughput NFR (D) — the direct fix for V1's fatal write-side flaw (J).

Why a log, specifically, and not just "a faster database": Prerequisites G draws the general distinction — a traditional queue deletes a message once consumed, while a log-based broker like Kafka retains every event and lets multiple independent consumer groups each read the full stream at their own pace. This system needs exactly that fan-out: the windowed-aggregation consumer (O), the fraud-detection consumer (S), and the cold-storage archival consumer (K) all need to read the identical stream of clicks, independently, without the click-redirect endpoint needing to know any of them exist or coordinate delivery to each. A traditional single-consumer queue would force either one consumer to do all three jobs, or the producer to write to three separate queues — a log lets the producer write once and stay entirely ignorant of how many downstream systems are reading it.

Why appending to a log is cheaper than a relational insert at this specific volume: a Kafka partition is, mechanically, a sequential append to the end of a file — no index to update, no row-level lock to acquire, no page to potentially split. A relational insert with even a couple of indexes pays for random-access index maintenance on every single write; at tens of thousands of writes/sec sustained, and hundreds of thousands at peak (G), that per-write overhead is exactly what makes the relational approach infeasible and the log-append approach tractable.

Why the click-redirect endpoint's produce call is fire-and-forget, not a blocking, fully-acknowledged write: the endpoint's only real job (I) is getting the user redirected quickly — it needs the produce call to be durable enough to survive a normal broker failure (acknowledged by at least the partition leader) but does not need to wait for the aggregation pipeline downstream to do anything at all before responding. This is the same "keep expensive work off the hot path" reasoning as Patterns Playbook C, applied to a redirect instead of a job submission.

Partitioning strategy: partitioned by ad_id by default, which keeps every click for a given ad in delivery order on one partition (the ordering guarantee Kafka only makes within a partition, per Prerequisites G) — a property the windowed-aggregation stage (O) benefits from, though it does not strictly require it, since the watermark mechanism (Q) is designed to tolerate some disorder regardless. This default partitioning is exactly what breaks down for one specific case, covered on its own (R).

N. Deep Dive 2 — Deduplication of Duplicate Click Events

Requirement this addresses: the exactly-once-billable-count NFR (D) — duplicate click events are not a rare edge case here, they're an expected, routine byproduct of at-least-once delivery (Prerequisites G) compounded by real client-side behavior: a user double-clicking, a flaky mobile connection causing a browser to silently retry the same GET request, or a bot deliberately re-firing the same tracking URL.

The idempotency key, established at ad-serve time, not at click time: a click_id is generated once, when the ad is actually rendered to the user (embedded directly into the tracking URL the ad itself links through), so that a double-fire of the exact same click — whether from a genuine double-click or a network-level retry — carries the identical click_id both times. This is precisely the same idempotency-key mechanism named generally in Prerequisites O and cataloged across three other domains in Patterns Playbook E (Chat's client_msg_id, Ticketmaster's payment key, Job Scheduler's execution key) — the only thing different here is that the "client" retrying isn't an API caller reacting to a lost response, it's a browser or bot reacting to normal, everyday network flakiness.

Bloom filter as a cheap pre-filter in front of the exact dedup store — and why the direction of the check matters: checking every incoming click_id against an exact dedup store (K) for every single click is itself real work at this volume. A Bloom filter (Prerequisites M) sitting in front of that store answers the cheap, common case fast: if the filter says "definitely not seen," that's a guaranteed-correct answer — the click is genuinely novel, no exact-store lookup needed at all. Only when the filter says "probably seen" does the click fall through to the heavier exact check, to rule out a false positive before treating it as a real duplicate. Because the overwhelming majority of clicks in any real workload are genuinely novel, this ordering means the expensive exact lookup is paid only for the minority of cases that actually need it — and, critically, the filter's one-directional error (false positives only, never false negatives, Prerequisites M) means this optimization can never itself cause a real click to be wrongly discarded as a duplicate.

Why the dedup window is bounded, not permanent: a DedupRecord only needs to live as long as a duplicate could realistically still arrive — roughly ten minutes (F, G) comfortably covers a retried request or a delayed redelivery, without growing the dedup store's size in proportion to the platform's total daily click volume. This mirrors the exact TTL trade-off Payment System's idempotency store makes for its own, differently-scoped window (P15-M).

O. Deep Dive 3 — Batched / Windowed Aggregation

Requirement this addresses: the near-real-time-visibility NFR (D) — the direct fix for V1's second flaw (J), replacing "re-scan raw rows on every query" with "aggregate incrementally, once, as data arrives."

Tumbling windows, the primary aggregation unit: a stateful stream processor (conceptually, a Flink- or Spark-Streaming-style engine) groups deduplicated events into fixed, non-overlapping one-minute buckets keyed by (ad_id, window_start) — every click belongs to exactly one tumbling window, which is what makes higher-level rollups (per-hour, per-day) a simple matter of summing already-computed child windows rather than ever re-touching raw events. The processor maintains a running count (and a running HyperLogLog sketch, P) as its keyed state for each currently-open window, and emits a finalized WindowedRollup row once that window closes (governed by the watermark, Q).

Sliding windows, for the dashboard's "trailing N minutes" display: a dashboard's "clicks in the last 5 minutes, updated every 30 seconds" view is a sliding window — overlapping, recomputed on a shorter interval than its own width. Rather than maintaining a second, separate streaming aggregation for this, it's computed on read, on top of the already-finalized one-minute tumbling rollups (summing the last five one-minute buckets) — cheap precisely because the expensive part, the actual per-click aggregation, already happened once in the tumbling layer.

The one detail that breaks if it's missed: summing works for counts, but not for HLL sketches. Rolling a per-minute count up into a per-hour count is a simple sum of 60 child values. Rolling a per-minute unique-reach estimate up into a per-hour estimate is not a sum — summing 60 minutes' distinct-user counts would badly overcount anyone who clicked in more than one of those minutes. HLL sketches instead merge (a register-wise max across the 60 sketches, equivalent to computing the union of the underlying sets), which is exactly the property that makes HLL sketches composable across time and across partitions in the first place (P) — a distinction worth stating explicitly, because it's the single most common mistake a candidate makes the first time they reach for "just roll it up the same way as the count."

P. Deep Dive 4 — Approximate Counting at Extreme Scale

Requirement this addresses: the unique-reach functional requirement (D) — and the second half of this design's central bet: some numbers this system reports are not worth computing exactly, because the exact version is prohibitively expensive and the approximate version is, for the actual business purpose, just as useful.

Why exact unique counting doesn't scale here: computing the true number of distinct users who clicked a given ad requires remembering every distinct user ID ever seen for that ad — a full hash set, growing linearly with true cardinality, per ad, per window. At tens of millions of ads and per-minute granularity (G), that's simply not a viable amount of memory to hold, and it doesn't compress: a hash set of a million distinct IDs is a million distinct IDs' worth of memory, no matter how it's stored.

HyperLogLog, the fix: a HyperLogLog sketch estimates the number of distinct elements added to it using a small, fixed amount of memory — a dense representation uses roughly 12KB regardless of whether the true distinct count is a hundred or a hundred million, with a standard error around 0.8-2% depending on configuration (AD). Adding an element hashes it and updates a small number of registers based on patterns in the hash's bits; estimating cardinality reads those registers back through a formula, never the raw elements themselves — which is exactly why the memory footprint never grows with the true count, only the accuracy's confidence interval narrows or widens with configuration choices made up front.

Why sketches can be merged, and why that's the whole point: a per-minute HLL sketch and a different per-minute HLL sketch for the same ad can be merged into a single sketch representing their union — a per-hour reach estimate is the merge of that hour's 60 per-minute sketches (O), and a platform-wide reach estimate for an ad is the merge of every shard's local sketch for it, all without ever needing to bring the underlying raw user IDs back together in one place.

How this differs from a Bloom filter — the two probabilistic structures answer genuinely different questions: a Bloom filter (Prerequisites M, and this design's own dedup layer, N) answers "have I seen this specific element before" — a membership question about one item, with no false negatives and a small false-positive rate, and it fundamentally cannot tell you how many distinct elements it has seen in total. HyperLogLog answers the opposite question: "how many distinct elements have I seen overall" — a cardinality question about the whole set, and it fundamentally cannot tell you whether any one specific element was among them; there's no way to ask an HLL sketch "was user_42's click already counted," the way a Bloom filter's entire purpose is answering exactly that. This design needs both, for two entirely different jobs: the Bloom filter cheapens the dedup check (N); the HLL sketch estimates reach (P) — and conflating the two, or reaching for one where the problem actually calls for the other, is a common, avoidable mistake.

Q. Deep Dive 5 — Handling Late-Arriving / Out-of-Order Events

Requirement this addresses: the near-real-time-visibility NFR (D), specifically the honest admission that "near-real-time" also means "not every event arrives on time," and the design needs a defined, bounded answer for the ones that don't.

Event time versus processing time: a click's event time is when it actually happened, according to a timestamp assigned as close to the user's action as possible; its processing time is whenever the stream processor actually gets around to handling it. These two can legitimately diverge — network delay, a mobile device that buffers clicks while offline and replays them once reconnected, or ordinary multi-hop pipeline lag can all mean an event's processing time trails its event time by anywhere from milliseconds to, occasionally, minutes.

Watermarks — the processor's own evolving estimate of "how caught up are we, really": a watermark is a continuously-advancing threshold, computed from the event times actually observed so far, representing the processor's belief that it has now seen every event with an event time earlier than the watermark (allowing for some configured maximum expected disorder). A given tumbling window (say, 14:32:00-14:33:00) closes and emits its finalized WindowedRollup not the instant its wall-clock end arrives, but once the watermark passes that window's end plus a configured allowed-lateness buffer (e.g., two extra minutes) — giving legitimately-delayed events a real, bounded chance to still land in the correct bucket before it's considered final.

What happens to an event that's later than even that: a click whose event time falls inside an already-closed, already-emitted window, but which only arrives after the allowed-lateness buffer has also passed, is genuinely too late for the window's live-emitted rollup. Rather than silently mutating that already-published WindowedRollup row in place, it's routed to a side stream and applied as an explicit LateCorrection record (H) — a small, attributable adjustment layered on top of the original, never a quiet rewrite of a number a dashboard already displayed. This is the same "never silently mutate a financial fact, write a new attributable adjustment instead" discipline Payment System's ledger uses for corrections (P15-N, P15-Q), reapplied here to a click count instead of a balance.

Why this bounded-lateness gap is exactly what the later reconciliation job exists to close systematically: the watermark and allowed-lateness window are deliberately finite — extending them indefinitely to catch every conceivable straggler would mean windows never close and dashboards never show a "final" number at all. The batch reconciliation job (V, cross-referencing P15-Q's identical role in a different domain) runs hours later against the full durable log, with no lateness bound at all, and is what ultimately catches anything the streaming layer's necessarily-finite patience for stragglers still missed.

R. Deep Dive 6 — The Hot Ad Problem

Requirement this addresses: the resilience-to-a-single-spike NFR (D) — a distinct scaling problem from the platform-wide peak G already accounts for: one specific ad's traffic becoming disproportionate to every other ad's, concentrated on whatever single partition that ad's ad_id happens to hash to.

Why the default partitioning scheme (M) breaks down for exactly this case: partitioning by ad_id keeps a given ad's clicks ordered and together, which is normally a feature — but it also means one viral ad's entire click volume lands on a single Kafka partition, read by a single consumer instance within its consumer group. This is structurally the identical shape as two problems already solved elsewhere in this document for different resources: Rate Limiter's hot-key/single-abusive-client problem (P2-Q), where one client's traffic can't be spread across shards without breaking per-client atomicity, and News Feed's celebrity problem (P3-N), where one account's follower count skews a fanout scheme built assuming a roughly uniform distribution. All three are the same underlying fact — a partitioning scheme chosen for the average case has no defense against one key's traffic being wildly non-average.

The fix — salt the hot key, merge the pieces back downstream: once an ad is detected as disproportionately hot (a lightweight streaming heavy-hitter estimate is enough — exact top-k tracking isn't needed to make this call), its clicks are salted with a random shard suffix before being keyed into Kafka (ad_id#0 through ad_id#N instead of a single ad_id), fanning that one ad's write volume across N partitions instead of one — the direct structural echo of Rate Limiter's own hot-key fan-out mitigation (P2-Q). The aggregation layer (O) then merges the N shards' partial results back into one logical rollup for that ad: click counts sum trivially across the salted shards, and — because HyperLogLog sketches merge rather than sum (O, P) — the reach estimate recombines just as cleanly, with no special-casing needed for the fact that the same ad's data briefly lived on multiple partitions.

Why this fix is applied selectively, not universally: salting every ad's key by default would spread ordinary, non-hot ads' small click volumes across more partitions than they need, adding merge overhead for no benefit. Detecting hotness first and only paying the fan-out cost for the small number of ads that actually need it — the same selective-cost reasoning News Feed's hybrid fanout applies to celebrity accounts specifically, rather than to every account (P3) — keeps the common case simple.

S. Deep Dive 7 — Fraud / Bot Click Filtering

Requirement this addresses: the fraud-resistance NFR (D) — protecting an advertiser's spend from clicks that were never a genuine expression of interest in their ad.

Representative heuristics: click velocity from a single IP or device exceeding a threshold within a short window; a click with no corresponding ad impression ever having been served to that user; traffic originating from known datacenter IP ranges or exhibiting headless-browser signatures; mechanically regular click-timing patterns inconsistent with normal human variance; a single device clicking on every ad it's ever shown, regardless of relevance. None of these individually proves fraud with certainty — each contributes a confidence signal that, combined, determines whether a click is flagged, suppressed from billing, or simply allowed through with a lower confidence tag for later review.

Why this must run close to ingestion time, not after billing has already run: an advertiser's campaign spend and daily budget are decremented against near-real-time click counts (D, V) — the whole point of the near-real-time dashboard is that an advertiser can watch their budget deplete as clicks happen. If fraud filtering only ran as a nightly batch pass, fraudulent clicks would already have both charged the advertiser and consumed real budget/inventory-pacing decisions for that day before anyone caught them — and refunding the charge afterward doesn't undo the fact that the campaign may have been paused early, or that a legitimate click was crowded out of that day's budget by a fraudulent one that arrived first. This is a mirror image of Payment System's own "slow correct answer beats a fast wrong one" reasoning (P15): there, the fix is to slow down and verify before acting; here, verification has to happen fast enough to still be before the money-affecting decision, because by the time a fully-certain, slow answer is available, the budget has already moved.

Where this lives in the pipeline: a dedicated fraud-scoring consumer reads the same Kafka topic (M) in parallel with the aggregation consumer (O) — not as a gate the click has to pass before ever being logged (that would risk losing a genuinely legitimate click to a slow or overzealous check), but as an independent stream that tags or suppresses clicks from rollups quickly enough to matter for same-day budget pacing, while the raw event itself remains durably logged either way for later audit.

T. Full Solution at Scale — Final Architecture

flowchart TB User["User's Browser"] --> Redirect["Click Redirect Endpoint
(minimal work, fast produce, M)"] Redirect --> Topic[("Kafka Topic
partitioned by ad_id, salted if hot, M/R")] Redirect --> User Topic --> AggConsumer["Aggregation Consumer Group"] AggConsumer --> BloomPre["Bloom Filter Pre-Filter (N)"] BloomPre --> DedupStore[("Exact Dedup Store
bounded TTL window, N")] DedupStore --> Windower["Tumbling Window Aggregator
(count + HLL sketch, O, P)"] Windower -->|"watermark passes window end + lateness, Q"| RollupStore[("Rollup Store
(ad_id, window_start), K")] Topic --> FraudConsumer["Fraud/Bot Scoring Consumer (S)"] FraudConsumer --> RollupStore Topic --> Archiver["Archival Consumer"] Archiver --> ColdStore[("Cold Object Storage
30-day+ raw retention, K")] RollupStore --> Dashboard["Advertiser Dashboard
(provisional counts, V)"] subgraph Recon [" Batch Reconciliation (V) "] direction LR Recompute["Recompute exact counts from ColdStore"] --> Diff["Diff vs RollupStore"] Diff --> Discrepancy["ReconciliationDiscrepancy queue"] end ColdStore --> Recompute RollupStore --> Diff

Two speeds, deliberately: the streaming path (top) trades a small, bounded error for near-real-time visibility; the batch path (bottom) trades latency for an eventual, fully-exact number.

U. Design Evolution

VersionWhat ChangedWhy
V1Direct synchronous per-click INSERT, reporting via ad hoc COUNT(*)/GROUP BY queries over raw rows (J)Simplest possible thing that satisfies the functional requirement in isolation
V2Click ingestion moves to a durable Kafka log; the redirect endpoint only produces an event and returns immediatelyCloses V1's write-throughput flaw — ingestion is decoupled from every downstream consumer's processing rate (M)
V3A stream-processing layer performs tumbling-window aggregation, emitting precomputed rollups instead of scanning raw events on readCloses V1's read-side flaw — a dashboard query now reads a small number of already-aggregated rows (O)
V4A click_id idempotency key, backed by a Bloom-filter-fronted exact dedup store, deduplicates events before they're countedAt-least-once delivery plus real-world duplicate clicks would otherwise silently inflate billable counts (N)
V5Unique-reach is computed via mergeable HyperLogLog sketches instead of a per-ad set of raw user IDsExact unique counting doesn't fit in memory at this cardinality; a small, known error rate is the correct trade (P)
V6Watermarks and a bounded allowed-lateness window govern when a window closes; events later than that become explicit correction records, not silent rewritesEvent time and processing time genuinely diverge; the design needs a defined, bounded answer for stragglers (Q)
V7Hot ads are detected and their key is salted across multiple partitions, merged back downstreamA single viral ad's traffic would otherwise overwhelm the one partition its ad_id happens to hash to (R)
V8A parallel fraud-scoring consumer tags/suppresses suspicious clicks close to ingestion time; a batch reconciliation job independently recomputes exact counts from the durable logFraud filtering after billing has already run is too late to protect budget pacing (S); "near-real-time and provisional" needs an eventual, exact counterpart for disputes and audits (V)

What stays stable throughout: the small, click-through-redirect contract from I — every later refinement changes how a click's data flows and gets aggregated downstream, never the basic shape of what a user's browser actually hits or how quickly it gets redirected.

V. Consistency Model

This design sits at almost the opposite end of this document's consistency spectrum from Payment System (P15-V) — worth stating that contrast explicitly, because both problems' central lesson only lands fully once compared against each other. Payment System scopes strong consistency down to the smallest possible island (a single ledger commit) precisely because a financial fact being briefly wrong is unacceptable, no matter how narrow the window. This system does the reverse: it deliberately, explicitly treats its headline, live-facing numbers as approximate and provisional, on the honest premise (Prerequisites L's PACELC framing, taken to its logical extreme) that a click dashboard which is 30 seconds stale, or off by a fraction of a percent on a reach estimate, has lost nothing a real advertiser actually needs from it in the moment.

The two-speed shape, made explicit: the live dashboard's numbers (V's provisional WindowedRollup counts, T) are eventually consistent in the ordinary sense — they converge toward correct as stragglers and corrections (Q) land — and are additionally, deliberately approximate in the statistical sense for reach specifically (P), which is a stronger and different kind of "not exact" than mere staleness. Underneath that live number, a slower batch reconciliation job (cross-referencing P15-Q's identical structural role for Payment System's ledger) independently recomputes an exact count from the full durable log, with no lateness bound and no statistical shortcuts, publishing it via the separate /stats/exact endpoint (I) once it's done — hours, not seconds, later. Billing ultimately settles against that slower, exact number, not the fast provisional one; the fast number's entire job is to be a good-enough, near-real-time signal for a human watching a dashboard, not the final financial word.

W. Failure Scenarios

X. Bottlenecks & Scaling Boundary

StageActual BottleneckFix Applied
V1's single relational tableWrite throughput ceiling reached well below even average platform load, before any peak multiplier (G, J)Kafka as the ingestion point; the database is removed from the write-hot-path entirely (M)
V1's COUNT(*)/GROUP BY reporting queriesQuery cost grows with total raw row count, which itself grows without bound over the course of a dayPrecomputed windowed rollups, read directly instead of recomputed on every query (O)
A naive per-ad hash set for unique-reachMemory grows linearly with true distinct-user count, per ad, per window — unbounded at this cardinalityHyperLogLog sketches, fixed-size regardless of true cardinality (P)
A single Kafka partition under one viral adAll of that ad's traffic funnels through one partition's single-consumer throughput ceiling (R)Detect hotness, salt the key across N partitions, merge results downstream (R)
Reconciliation re-scanning the entire retained log on every runCost grows without bound as retained history growsIncremental, windowed reconciliation — only the period since the last successful run is diffed each time, the same discipline Payment System's own reconciliation job applies (P15-X)

Y. Design Trade-Offs

DecisionOption AOption BTrade-off
Ingestion pathDirect synchronous DB writesDurable log (Kafka), chosenDirect writes are simpler to reason about for a small system, but hit a hard throughput ceiling far below this problem's actual load; a log adds an extra hop and eventual-consistency reasoning in exchange for effectively unbounded write-side scale (M)
Aggregation strategyOn-demand COUNT/GROUP BY queriesWindowed stream aggregation, chosenOn-demand queries need zero extra infrastructure but get slower as raw data grows; streaming aggregation needs a stateful processing layer, in exchange for query cost that stays flat regardless of total historical volume (O)
Unique-reach countingExact per-ad hash set of user IDsHyperLogLog sketch, chosenAn exact set is perfectly accurate but its memory cost scales with true cardinality, unboundedly; HLL trades a small, known error rate (P) for a fixed, tiny memory footprint per key, at any scale
Late-event handlingKeep windows open indefinitely until every straggler arrivesBounded watermark + allowed lateness, chosenIndefinitely-open windows guarantee no straggler is ever missed but mean no number is ever truly "final" in a useful timeframe; a bounded window closes on a predictable schedule, at the cost of needing explicit correction records for genuinely late outliers (Q)
Hot-key mitigationSalt every ad's partition key uniformlyDetect hotness, salt selectively, chosenUniform salting handles any future hot ad automatically but adds merge overhead to every ordinary ad's small volume for no benefit; selective salting keeps the common case simple, at the cost of a brief lag while a newly-hot ad is first detected (R)

Z. Common Bad Designs

AA. Interviewer Follow-Ups

Follow-UpDesign ChangeComponents ChangedComponents Unchanged
"How would you also track ad impressions, not just clicks, given impressions are 50-100x higher volume?"The identical log-ingest-plus-windowed-aggregation architecture, reused wholesale — impressions simply produce to their own Kafka topic and rollup store, at higher partition count for the higher volumeTopic/partition count, rollup store sizingEverything structural — dedup, windowing, HLL, watermarks — the pattern doesn't change, only the numbers
"An advertiser disputes a specific day's billed click count — how do you actually investigate it?"Pull the exact, reconciled count for that window (already computed by the batch job, V) alongside the original raw events for that ad from cold storage (K) — the full, replayable history is exactly what a log-based, non-destructive design (unlike V1) makes possibleNone structurally — this is querying data the design already retainsThe entire live pipeline — a dispute investigation never touches or slows down the streaming path
"What if two ads are run by the same advertiser and need a combined, deduplicated reach number across both?"Merge the two ads' HLL sketches for the requested window (P) — the union-merge property is exactly what makes a cross-ad, cross-key reach estimate possible without re-processing any raw eventsA query-time HLL merge stepThe underlying per-ad sketches themselves — nothing about how they're built changes
"How would you support real-time budget pacing that pauses a campaign the instant it hits its daily spend cap?"The streaming rollup's provisional spend figure (V) — already updated within roughly a minute of a click — is wired to a budget-check consumer that pauses ad serving on threshold breach; explicitly accepting that this decision is made against the provisional, not the exact, number, with the exact reconciled figure only ever adjusting the next day's ledger, never retroactively un-pausingA new budget-pacing consumer reading the rollup storeThe core ingestion, dedup, and windowing machinery — a pacing decision is just another reader of the same rollups

AB. Interviewer Evaluation Rubric

AC. Interview Simulation

Interviewer: Design the system behind an ad platform that records every click on an ad and shows advertisers a near-real-time dashboard of how many clicks each ad is getting.
Candidate: Before I sketch anything, I want to flag the number that's going to drive every decision here — how many clicks per second are we actually talking about at peak? If we're talking hundreds of thousands a second, that immediately rules out the naive answer of just inserting a row per click into a database and running aggregate queries on top of it. A single database node just can't sustain that write rate, and even if it could, a COUNT(*) GROUP BY query over a table that's growing by millions of rows a minute gets slower every time you run it.
Interviewer: Right, assume it's real — hundreds of thousands per second at peak. What do you do instead?
Candidate: I'd put a durable log — Kafka — directly in the ingestion path. The click-tracking endpoint's only job is to append the click event to a Kafka topic and redirect the user, as fast as possible — it doesn't wait on anything downstream. Then a stream-processing layer reads that log continuously and does the actual aggregation incrementally, grouping clicks into small time windows — say one-minute buckets per ad — and writing out a precomputed count for each window. The dashboard reads those precomputed rollups, never the raw click stream.
Interviewer: What about duplicate clicks — a user double-clicking, or a flaky connection retrying the same request?
Candidate: Every click carries an idempotency key — a click_id generated once, when the ad is actually rendered, embedded in the tracking URL itself. So a double-fire of the same click, whether it's a real double-click or a network retry, carries the identical key both times. The stream processor checks that key against a short-lived dedup store before counting it — and to make that check cheap at this volume, I'd put a Bloom filter in front of it, so the overwhelming majority of clicks, which are genuinely novel, get a fast "definitely not a duplicate" answer without ever touching the heavier exact store.
Interviewer: The advertiser also wants to know how many unique people clicked their ad, not just the raw click count. How would you compute that?
Candidate: Not with an exact set of user IDs per ad — that grows without bound in memory as the true number of distinct clickers grows, and at this scale, per ad, per minute, that's just not viable. I'd use a HyperLogLog sketch instead — a fixed, small amount of memory, something like 12 kilobytes, that gives you a distinct-count estimate within a percent or two of error, no matter how many actual distinct users there were. And the useful property here is that these sketches merge — I can combine an hour's worth of one-minute sketches, or two different ads' sketches, into one combined estimate, without ever needing the original user IDs back.
Interviewer: Is that the same kind of structure as a Bloom filter, then?
Candidate: No, and that's worth being precise about — they answer different questions. A Bloom filter tells you whether one specific item was seen before, with no false negatives; it can't tell you how many distinct items it's seen in total. HyperLogLog does the opposite — it estimates the total distinct count, but it can't tell you whether any one specific user was part of that count. I'm using a Bloom filter for the dedup check, and HyperLogLog for reach — they're solving two different problems that happen to both be probabilistic.
Interviewer: Last thing — what if events don't arrive in order, or arrive late?
Candidate: That's expected, not an edge case, at this scale — a mobile device might buffer clicks offline and replay them minutes later. I'd use a watermark — the processor's own estimate, based on event timestamps it's actually seen, of how caught up it believes it is — and only close and emit a window's final count once the watermark passes that window's end plus a bit of extra buffer for allowed lateness. Anything that arrives even later than that becomes an explicit correction record layered on top of the original number, rather than silently rewriting it — and then a slower batch job, hours later, recomputes the fully exact count from the durable log for billing and disputes. The live dashboard number is always explicitly provisional; the batch number is the one that's actually final.

AD. Online Solution Comparison

SourceApproachStrengthWeaknessWorth Learning
Nathan Marz — "How to Beat the CAP Theorem" (introducing the Lambda Architecture)Proposes a batch layer (recomputes exact views over the full immutable dataset) alongside a speed layer (fast, approximate, real-time views over only the most recent data), reconciled into a combined serving layerThe original, widely-cited articulation of exactly this design's two-speed shape — a fast, provisional streaming path and a slower, exact batch path serving different needs off the same underlying logPredates modern unified stream-processing frameworks; in practice, systems like Flink and Kafka Streams now blur the sharp batch/speed-layer split Marz originally proposedThe clearest historical grounding for why this design's provisional-versus-exact split (V) is a recognized architectural pattern, not an invented shortcut
Tyler Akidau — "Streaming 101: The World Beyond Batch" (O'Reilly Radar)Defines event time versus processing time precisely, and introduces watermarks as the mechanism a streaming system uses to reason about completeness in the face of unbounded, out-of-order dataThe standard, most-cited explanation of exactly the terminology and reasoning this design's Deep Dive 5 (Q) applies — written by one of the engineers behind Google's own internal streaming infrastructure (the basis for Cloud Dataflow/Apache Beam)Framework-agnostic by design — doesn't walk through a specific engine's API, so the concepts still need to be mapped onto whichever stream processor is actually chosenThe clearest available explanation of why "late data" is a fundamental property of distributed data collection, not an implementation bug
Redis — "HyperLogLog" (official documentation)Documents Redis's real, production HyperLogLog commands (PFADD, PFCOUNT, PFMERGE), including its actual standard error (~0.81%) and its sparse-to-dense representation switch as cardinality growsA direct, first-party confirmation that a fixed-size, mergeable probabilistic cardinality structure — not an interview simplification — is exactly how a real, widely-deployed system implements unique counting at scaleDocuments the client-facing command interface thoroughly but not the underlying register-update algorithm in depthConfirms the concrete ~12KB dense-sketch size and the sparse-representation detail this design's Capacity Estimation (G) and Deep Dive 4 (P) rely on
Apache Flink Documentation — "Windows"Formally documents tumbling, sliding, and session window assigners, alongside watermark generation and an explicit, configurable "allowed lateness" parameter per windowThe standard, widely-used real implementation of exactly the windowing and lateness-handling vocabulary this design's Deep Dives 3 and 5 (O, Q) use — confirms tumbling-vs-sliding and allowed-lateness are established, off-the-shelf concepts, not invented for this problemDocuments one specific framework's API surface — the underlying concepts transfer to Kafka Streams or Spark Structured Streaming, but the exact configuration knobs don'tConfirms the vocabulary (tumbling window, watermark, allowed lateness) this design's Deep Dives 3 and 5 use is the field's actual standard terminology, not an interview simplification

AE. Interview Cheat Sheet

ProblemAd Click Aggregation
DifficultyAdvanced — individually simple mechanisms, but the whole design only holds together once you accept that per-event DB writes and exact counting are both, deliberately, abandoned
Core requirementsRecord every click, deduplicated; near-real-time per-ad/per-campaign click and reach dashboards; an eventual, exact reconciled count for billing and disputes
Central decisionIngest through a durable log (Kafka), not a database; aggregate via windowed stream processing, not per-event writes with query-time COUNT; deduplicate via an idempotency key established at ad-serve time; estimate unique reach with HyperLogLog instead of exact counting; bound lateness with watermarks and explicit correction records
Why not per-event DB writesA single relational node's realistic write throughput is already short of this platform's average load before any peak multiplier — sharding helps the write side but doesn't fix ever-growing COUNT(*) query cost on the read side (G, J)
DeduplicationClient-side click_id generated at ad-serve time, checked via a Bloom-filter-fronted exact dedup store, TTL'd to a short window (P.O, Pl.E)
Approximate countingHyperLogLog for unique reach — fixed ~12KB memory regardless of true cardinality, mergeable across windows and partitions; distinct in purpose from a Bloom filter (membership) vs. HLL (cardinality) (P.M)
Late eventsWatermarks + a configured allowed-lateness buffer close windows on a predictable schedule; anything later becomes an explicit correction record, never a silent rewrite
ConsistencyDeliberately the opposite pole from Payment System (P15-V) — the live number is provisional and approximate by design; a slower batch reconciliation produces the eventual exact number billing actually settles against
Common mistakesPer-event INSERT plus COUNT(*)/GROUP BY; trusting a disabled button as dedup; exact per-ad hash sets for reach; conflating Bloom filters with cardinality estimators; running fraud detection only after billing has already run
What NOT to over-engineerAd serving/targeting/auction logic and the downstream billing/invoicing system itself — this design's scope is producing correct, timely click counts as an input to those systems, not replacing them