Design an Ad Click Aggregator
Problem
Design a system that collects every click on ads served by an advertising platform and aggregates them for reporting and billing. When a user clicks an ad, the system records the click and redirects the user to the advertiser's landing page. Advertisers query click counts for their ads and campaigns over time, filtered by dimensions such as country and device, with data no more than a minute old. Counts are used for billing, so each click must be counted exactly once and fraudulent clicks must be excluded. The platform handles 10 billion clicks per day across 2 million active ads.
- What is the finest time granularity advertisers need: per minute, per hour?
- Which dimensions must be filterable: country, device, placement, publisher?
- How long must raw click data and aggregates be retained?
- Is near-real-time data used for billing, or only the final daily numbers?
- What latency is acceptable for the click redirect itself?
- Do we need to support top-N queries, such as most clicked ads in the last hour?
Items in bold are existing requirements. The rest are suggestions. Confirm with the interviewer whether they are relevant first.
- Record each ad click and redirect the user to the landing page.
- Aggregate clicks per ad per minute, sliced by dimensions such as country and device.
- Let advertisers query counts for arbitrary time ranges with results at most a minute stale.
- Count each click exactly once and exclude fraudulent clicks from billable totals.
- Provide top-N ads by clicks over recent windows for internal monitoring.
- Produce final daily billing totals that match raw logs.
Items in bold are existing requirements. The rest are suggestions. Confirm with the interviewer whether they are relevant first.
- 10 billion clicks per day: about 115,000 per second average and 500,000 per second at peak.
- Aggregates visible to advertisers within 1 minute of the click.
- Redirect latency under 50 ms at p99.
- Raw events at about 500 bytes each are 5 TB per day, retained for 90 days in a data lake.
- Minute-level aggregates: 2 million ads times 1,440 minutes times a few dimension combinations is a few billion rows per day before rollup, so older data is rolled up to hourly and daily.
- No click is lost once accepted; the pipeline must recover from failures without double counting.
A lightweight click service logs each click to Kafka and immediately redirects the user, keeping the hot path free of aggregation work. A stream processor deduplicates clicks by click id, filters fraud, and aggregates counts in one-minute tumbling windows using event time, with checkpoints that give exactly-once results into an OLAP database. Raw events are also archived to a data lake, where a daily batch job recomputes the totals that drive billing and corrects any drift in the streaming numbers.
Loading diagram...
Click. The ad markup links to GET /click?ad=&imp=&sig=. The click service validates the signature (which proves the impression was served by us), generates a unique click id, appends the event with timestamp, ad id, campaign id, user and device info to Kafka, and returns 302 to the landing page. The Kafka write is acknowledged before the redirect so no click is lost.
Streaming aggregation. The stream aggregator reads the topic partitioned by ad id. It drops duplicates by checking each click id against keyed state with a 24 hour TTL, applies fraud rules (for example, many clicks from one IP on one ad within seconds), and adds valid clicks to one-minute tumbling windows keyed by ad id and dimensions. Windows close on event time with a watermark that allows a few minutes of lateness. Closed windows are written to the OLAP store as upserts keyed by (ad_id, minute, dimensions).
Query. The advertiser dashboard calls the query service, which runs a grouped sum over minute, hourly, or daily tables depending on the requested range.
Daily reconciliation. A batch job reads the full day of raw events from the lake, applies the same dedup and fraud logic with the benefit of complete data, and writes authoritative daily totals. Billing uses these numbers, and any difference from the streaming totals overwrites the corresponding rows.
Receives the click, records it durably, and redirects. It is the only synchronous component users touch.
- Go service behind a global load balancer, with an async Kafka producer configured with
acks=all.
API
GET /click?ad=&imp=&sig=returns302with the landing page inLocation.
Scalability
The service is stateless; 500,000 clicks per second spread across a few hundred instances. Producer batching keeps per-click overhead low while staying within the 50 ms latency budget.
Tradeoffs
The click id is generated server side rather than trusting a client id, so a replayed request becomes a new id with the same impression id, and fraud rules use the impression id to catch it. Writing to Kafka before redirecting adds a few milliseconds but guarantees the click is recorded, which matters because it is money.
Deduplicates, filters, and aggregates clicks into one-minute windows with exactly-once output.
- Apache Flink with RocksDB state backend and periodic checkpoints to object storage.
Diagram
Loading diagram...
Scalability
Kafka has hundreds of partitions keyed by ad id, and Flink runs one task per partition. A viral ad can make one key hot; this is handled with a two-stage aggregation that first pre-aggregates by (ad_id, random salt) and then merges the partial counts per ad.
Tradeoffs
Exactly-once comes from Flink checkpoints aligned with Kafka offsets plus idempotent upserts into the sink, which is simpler and more robust than transactional sinks across systems. Event time windows with watermarks are chosen over processing time so that a delayed click is counted in the minute it happened, at the cost of results for a minute updating when late events arrive.
Columnar database that holds aggregated counts and answers dashboard queries.
- Apache Druid, Apache Pinot, or ClickHouse.
Data Model
clicks_minute(ad_id, campaign_id, advertiser_id, minute, country, device, clicks, fraud_clicks)clicks_hourandclicks_daywith the same columns at coarser grain.
Scalability
Tables are partitioned by time and sorted by advertiser id, so a dashboard query scans only that advertiser's rows for the requested range. Minute data is kept for 7 days, hourly data for 90 days, and daily data for years.
Tradeoffs
Pre-aggregated rollups trade flexibility for speed: advertisers cannot slice by a dimension that was not part of the aggregation key. Querying raw events for every dashboard request would give full flexibility but cost far more and miss the latency target. New dimensions are added by backfilling from the lake.
Durably store raw clicks and recompute authoritative billing totals.
- Kafka Connect writing Parquet files to S3, partitioned by hour.
- Spark job run once per day after the late data window closes.
Scalability
Processing 5 TB per day is a routine Spark job that finishes in well under an hour on a moderate cluster.
Tradeoffs
Running a batch path next to the stream (a lambda style architecture) duplicates logic, but billing needs numbers that are provably derived from complete raw data. The shared dedup and fraud logic lives in one library used by both jobs to keep results consistent.
API for advertiser dashboards and internal reports.
API
GET /reports/clicks?advertiserId=&from=&to=&granularity=&groupBy=country,devicereturns time series rows.GET /reports/top-ads?window=1h&limit=10returns the most clicked ads.
Scalability
Responses for completed time buckets are immutable once reconciled, so they are cached aggressively; only the current hour is computed fresh.
Tradeoffs
The service picks the coarsest table that satisfies the request (for example daily rows for a 90 day chart), which keeps queries fast without asking callers to know the storage layout.