Back to Notes

Design Ad-Click Aggregation (Stream Processing)

1. Problem & Real-World Context

Design a system that ingests a massive stream of ad-click events and aggregates them (clicks per ad per minute, for billing and reporting) accurately, at high throughput, in near-real-time. This is the canonical stream processing HLD problem — it tests whether a candidate understands the difference between batch and stream processing, and specifically the hard sub-problem within streaming: event-time vs. processing-time, and what happens when events arrive late or out of order (which they always do, at scale, over a real network).

2. Requirements

Functional: ingest ad-click events (ad_id, user_id, timestamp, campaign_id); aggregate click counts per ad per fixed time window (e.g., per minute); support querying aggregated counts (for billing, real-time dashboards); prevent click fraud / duplicate counting (a bot or malicious actor clicking repeatedly shouldn't inflate billing).

Non-functional: high throughput (millions of events/sec at real ad-network scale); accuracy matters more than latency here — this is unusual among stream-processing systems, since ad billing directly depends on these numbers being correct, not just fast; must handle late-arriving and out-of-order events correctly (network delays, client-side batching before send).

3. Capacity Estimation

  • At, say, 10K ad impressions/sec platform-wide with a 2% click-through rate → ~200 clicks/sec sustained is a modest number in isolation, but at real ad-network scale (multiple orders of magnitude larger) this becomes millions of events/sec — the architecture needs to be built for that scale from the start, since ad-tech systems are a canonical high-volume streaming use case.
  • Aggregation windows: 1-minute windows across, say, 1M distinct (ad_id, minute) combinations at any given time — this is bounded, tractable in-memory state per processing node, not an unbounded growth problem, since old windows are finalized and flushed.

4. High-Level Architecture

flowchart LR
    Ad[Ad Servers] -->|click events| MQ[Kafka<br/>partitioned by ad_id]
    MQ --> SP[Stream Processor<br/>windowed aggregation]
    SP --> DDB[(Dedup Store<br/>Redis/Bloom filter)]
    SP --> AggDB[(Aggregated Counts DB)]
    SP --> DLQ[Late-event handling]
    AggDB --> Dash[Billing / Dashboards]

Walking an event: an ad server emits a click event the moment a click happens → published to Kafka, partitioned by ad_id (so all events for one ad land in one partition, giving per-ad ordering — the same key-based-partitioning technique as [[Message Queues & Kafka]]) → a stream processor consumes the partition, checks the event against a dedup store (§7) to filter fraud/duplicate clicks, and adds valid clicks into the correct time window's running count → once a window's time has fully passed (plus a grace period for late events, §6), the processor finalizes and writes that window's count to the aggregated-counts store, which billing and dashboards read from.

5. Windowing

Aggregating "clicks per ad per minute" requires grouping events into fixed time buckets — but which timestamp defines the bucket is the crux of the problem.

  • Tumbling windows (used here): fixed, non-overlapping windows (e.g., [00:00–00:01), [00:01–00:02)) — each event belongs to exactly one window, based on its timestamp. The natural fit for "clicks per ad per minute" billing aggregation.
  • Sliding windows: overlapping windows (e.g., a 5-minute window recomputed every 1 minute) — useful for smoothed/rolling metrics ("clicks in the trailing 5 minutes"), not the discrete per-minute billing figure this problem asks for.
  • Session windows: grouped by activity gaps rather than fixed time (a user's session ends after N minutes of inactivity) — not relevant to this specific problem, but worth naming to show awareness of the windowing taxonomy if asked to generalize.

6. Event-Time vs. Processing-Time — the Core Problem

Processing-time is when the stream processor happens to receive/handle an event. Event-time is when the click actually occurred on the user's device. These are not the same, and the gap between them is exactly what makes this problem hard: network delays, client-side event batching, and retries mean events for a given minute can keep trickling in for some time after that minute has passed in processing-time.

from collections import defaultdict

class WindowedAggregator:
    def __init__(self, window_seconds: int = 60, grace_period_seconds: int = 30):
        self.window_seconds = window_seconds
        self.grace_period = grace_period_seconds
        self.window_counts: dict[int, int] = defaultdict(int)
        self.finalized_windows: set[int] = set()

    def _window_start(self, event_time: float) -> int:
        return int(event_time // self.window_seconds) * self.window_seconds

    def process_event(self, ad_id: str, event_time: float, current_processing_time: float) -> None:
        window = self._window_start(event_time)
        if window in self.finalized_windows:
            record_late_event_metric(ad_id, window)   # arrived after grace period — track, don't silently drop
            return
        self.window_counts[window] += 1

        # finalize any window whose grace period has fully elapsed
        cutoff = current_processing_time - self.window_seconds - self.grace_period
        for w in list(self.window_counts):
            if w < cutoff and w not in self.finalized_windows:
                flush_to_aggregated_store(ad_id, w, self.window_counts[w])
                self.finalized_windows.add(w)

Why bucket by event_time, not the time the processor happens to see the event: billing must reflect when clicks actually happened, not when the network happened to deliver them — bucketing by processing-time would make counts depend on network jitter, which is both inaccurate and unfair (an ad that got clicks right at a minute boundary could have some clicks counted in the wrong minute purely due to delivery timing, not user behavior). This is the single most important design decision in the whole system, and stating it explicitly (rather than defaulting to "just process events as they arrive") is the actual interview signal.

The grace period is the accuracy/latency trade-off, made explicit: a longer grace period means fewer legitimately late events get missed (better accuracy) but delays when a window's final count is available (worse latency for dashboards/billing). This directly mirrors [[Latency vs Throughput]]'s general framing — here it's latency vs. accuracy specifically, and the "requirements" section (§2) already told you accuracy wins by default for a billing system.

7. Deduplication / Fraud Prevention

A single real click shouldn't be counted twice (a client retrying a request that actually succeeded, a network duplicate) — and deliberate click fraud (a bot rapidly re-clicking) shouldn't inflate billed counts either.

  • Exact dedup: each click event carries a unique click_id (client-generated, the same idempotency-key pattern as [[API Design Principles]] and the [[Payment System]] page); check a dedup store (Redis set, or a [[Web Crawler]]-style Bloom filter at very large scale) before counting an event, exactly the same "have I seen this before" check used for URL dedup in that problem.
  • Fraud heuristics (a real production concern, not just exact-duplicate filtering): rate-limiting clicks per (user_id, ad_id) pair within a short window (see [[Rate Limiter]]) to catch rapid repeat-clicking; this is a policy layer on top of the core aggregation pipeline, not a replacement for it.

8. Deep Dives / Follow-ups

  • Exactly-once aggregation semantics: Kafka's consumer offset commits combined with idempotent writes to the aggregated-counts store (checking "have I already applied this event to this window's count" via the same dedup mechanism as §7) gets effectively-exactly-once results even though the underlying delivery guarantee is at-least-once — see [[Message Queues & Kafka]]'s general discussion of this pattern.
  • Backpressure: if the stream processor falls behind the incoming event rate, Kafka's retention buys time (events aren't lost while the processor catches up), but sustained backpressure needs horizontal scaling of the processor (more partitions, more consumer instances) — see [[Message Queues & Kafka]]'s back-pressure section for the general options.
  • Re-processing/backfill: since raw events are retained in Kafka (not just the aggregated output), a bug in the aggregation logic can be fixed and the affected time range reprocessed from the raw event log — a major practical advantage of keeping the raw stream durable rather than only ever storing pre-aggregated results.

9. Interview Drill Questions

  1. Why bucket events by their own timestamp (event-time) instead of when the server received them (processing-time)? — Billing must reflect when a click actually happened for the user, not network delivery timing; bucketing by processing-time would make which minute a click counts toward depend on arbitrary network jitter rather than actual user behavior — an inaccuracy a billing-adjacent system can't tolerate.
  2. An event for 00:04:58 arrives at the processor at 00:06:10, after the 00:04 window would normally have been finalized. What happens? — If it arrives within the configured grace period, it's still added to the correct (00:04) window before finalization; if it arrives after the grace period has elapsed and the window was already finalized and flushed, it's recorded as a late/dropped event for monitoring rather than silently discarded without a trace — the grace period is a deliberate, tunable accuracy/latency trade-off, not an accident.
  3. How do you prevent a bot rapidly clicking the same ad from inflating the advertiser's bill? — Two layers: exact-duplicate detection via a unique click_id (catches retries/network duplicates) and a separate rate-limiting/fraud-heuristic layer capping clicks per (user_id, ad_id) in a short window (catches genuine repeat-click abuse) — the aggregation pipeline itself only counts what passes both checks.
  4. Why partition the Kafka topic by ad_id? — All events for a given ad land in the same partition, guaranteeing per-ad ordering and letting one stream-processing task own the complete windowed state for that ad without needing cross-partition coordination — the same key-based-partitioning rationale as [[Message Queues & Kafka]]'s general ordering guarantee.
  5. How would you backfill corrected counts if a bug in the aggregation logic under-counted clicks for the past 3 days? — Since raw click events remain in Kafka (not discarded after first processing, retention permitting) or in a durable raw-event log, replay that historical range through the fixed aggregation logic and overwrite the affected windows in the aggregated-counts store — this is exactly why keeping the raw stream, not just pre-aggregated output, is a design decision worth defending explicitly.

Cross-links

[[Message Queues & Kafka]] · [[Latency vs Throughput]] · [[Rate Limiter]] · [[API Design Principles]] · [[Payment System]] · [[Web Crawler]]