The Blueprint
Money & markets advanced

Design an ad-serving platform

Real-time bidding in under 100 ms — a matching problem with a hard deadline, running an auction across many bidders for each impression.

asked at Google · Meta · TikTok Sometimes asked ~40 min walkthrough ✎ Practice on canvas →
01

Scope the requirements

Two hard problems: pick the right ad from millions of candidates in <100 ms, and count every impression + click accurately for billing. Split them — serving is a stateless prediction path; accounting is a durable event pipeline.

Functional — what it must do

  • Show a targeted ad in response to an ad slot request within 100 ms.
  • Support targeting rules (geo, age, interests, custom audiences).
  • Budget pacing — spend advertiser budgets across the day, not front-loaded.
  • Click + impression tracking with dedup, feeding billing.
  • Explicitly cut: creative uploads UI, fraud arbitration, attribution — offer them back.

Non-functional — the numbers that shape the design

  • Ad-serve latency: p95 under 100 ms, p99 under 200 ms.
  • 1M ad requests/s peak; 10B impressions/day; 100M clicks/day.
  • Budget accuracy: overspend under 0.1% per campaign per day.
  • Click and impression events land in billing within seconds.
  • 99.99% on ad-serve; degrade to a default ad rather than empty slot.
Say this out loud

The 100 ms deadline dominates the design. Auction complexity has to fit under it. Everything expensive (targeting features, ranker) is precomputed.

02

Back-of-envelope estimates

Ad-serve QPS is huge; auction complexity is bounded per request. Click volume is small but must be exact for billing.

QuantityEstimateHow you got there
Ad requests/s~1M peakpublisher pages + apps × ad slots per page. Each slot = one auction.
Candidates per auction~1,000narrowed from millions of campaigns by targeting index.
Ranker inference~10 ms for top 100GBM or shallow DNN, batched on GPU or vectorised on CPU.
Impressions/day~10Bnot every request wins — fill rate ~30%. 1M QPS × 86,400 × 0.3.
Click events/day~100M1% CTR average, higher on some placements.
Budget updates~10k/s decrementsper-campaign counters; Redis INCR then batch to Postgres.
Say this out loud

One million auctions a second, 30 ms budget per auction. That's a hardware + algorithms constraint, not a normal web app.

03

Sketch the API

One hot request path (bid), one write path (events). Advertiser side runs on a separate cluster — different SLA, different scale.

POST/bidbody: { userId, context, slot }. → { adId, creative, bidPrice } or 204 no-bid. Hard 100 ms deadline.
POST/track/impressionBeacon fired by client after render. Feeds counters + billing.
POST/track/clickClick redirect that logs + 302s to advertiser.
POST/api/v1/campaignsAdvertiser CRUD. Writes are eventual to serving layer via CDC.
GET/api/v1/reports/spend?campaignId=Advertiser dashboard, reads from batch-reconciled billing table.
  • Bid path is a hard-deadline stateless call — no database round trips allowed. Everything preloaded into memory or a nearby Redis.
  • Tracking beacons are fire-and-forget over HTTP; the client doesn't wait for downstream durability.
04

Data model

Three stores by function: campaign config (small, RAM-first), user features (per-user vector), event log (append-only, huge).

campaigns
id PK · advertiser_id · targeting (json) · creatives[] · budget · pacing_rule
Postgres source of truth. Snapshotted into ad-server memory every ~1 min.
targeting_index
trait → [campaign_id]
in-memory inverted index per ad-server. Rebuilt on campaign changes.
user_features
user_id → embedding + demographic vector
Redis. Precomputed nightly from behaviour + refreshed continuously from clicks.
budget_counters
campaign_id → spent_today
Redis INCR on impression cost. Written back to Postgres every ~1 min.
events
impression / click log
Kafka firehose, S3 archive for billing + fraud + training.
The database call

Campaigns in Postgres for durability; snapshot into ad-server RAM for zero-database-hop serving. Budget counters in Redis because Postgres cannot handle 10k updates/s per campaign. Events log follows the standard [[ad-click-aggregator]] pattern — stream + batch.

05

High-level design

One tier serves bids fast; another tier ingests events; a third tier runs the advertiser UI + billing. Zero coupling on the hot path.

user side edge ad server hot state async Publisher page User browser Edge LB Track LB Ad server in-memory targeting Ranker 10 ms Event ingest Feature Redis Budget Redis Campaigns PG Event bus Batch billing Warehouse impr/click bid req user vec spend++ pace hot reload
bid path never touches postgres. campaigns hot-reload into memory. events feed budget + billing async.
  1. Bid request arrives → ad server looks up user's feature vector in Redis (~1 ms).
  2. Targeting index (in-memory inverted index) narrows to ~1,000 candidate campaigns matching the request context.
  3. Ranker scores top 100 candidates, applies pacing multiplier from budget Redis, picks the winner.
  4. Server returns creative + tracking URL. Total budget spent so far this second on this campaign is a Redis INCR — bounded to campaign's per-second cap.
  5. User's browser fires impression beacon → event ingest → Kafka. Downstream: batch billing computes daily spend; stream job updates budget counters for pacing.
06

Deep dives — where the interview is won

Phase 01

The 100 ms budget: what fits and what doesn't

100 ms total; ~30 ms is network round trip (edge to server to edge); leaves ~70 ms for compute. Feature fetch ~5 ms (Redis). Targeting filter ~5 ms (in-memory inverted index on tags). Ranker inference ~10 ms (batched GBM on top 100 candidates). Auction logic + pacing check ~5 ms. Response serialisation ~5 ms. Total ~30 ms, leaving headroom for slow tail.

The corollary: no Postgres call on the bid path, ever. Campaigns are snapshotted into ad-server memory and hot-reloaded on change. If a campaign was updated 10 seconds ago and hasn't hot-reloaded yet, we serve the old version — better than a slow query. Consistency is intentionally weak on the serving side.

request stages ms bid arrives Feature fetch Targeting index Rank top 100 Pace + auction ~30 ms total
hard budget divided into stages. no database call, no cross-region hop, no cold cache path.
Trade-offCampaign updates lag serving by ~1 min. Advertisers who change budgets can overspend for that minute — a bounded loss that's easier to accept than the alternative (Postgres in the hot path).
Phase 02

Budget pacing without hot-row contention

One campaign spends across the day. Naively increment a Postgres row per impression — 10k/s hot-row contention destroys the DB. The pattern: Redis INCR per (campaign_id, minute). Ad server checks the running counter against the campaign's per-minute pacing cap; if over, skip this campaign in the auction. Batch writer flushes minute counters to Postgres every ~1 min.

Pacing itself is a rate limiter: 'spend $X across the day' becomes 'spend up to $X/1440 per minute'. Fluctuating traffic means some minutes underspend and others get relaxed caps — a smoothing algorithm run continuously per campaign. This is where the ranker gets a multiplier: campaigns behind pace bid higher, campaigns ahead of pace bid less.

auction redis postgres Ad server spend:campaign:minute INCR + read Batch writer 1 min flush campaign spend row check + INCR flush
redis absorbs the write rate. postgres owns daily totals for billing.
Trade-offRedis failure loses the last minute of spend — bounded loss, and batch billing reconstructs from event stream anyway. Never rely on Redis alone for financial numbers.
Phase 03

Click tracking with exactly-once billing

Click volume is small (~100M/day) but every click is billable. The path: user clicks ad → GET /click?id=... → server logs click event (id, campaign_id, user_id, ts, clickId) to Kafka → 302 redirects to advertiser URL. The event carries a clickId; downstream dedupes on it.

Billing runs batch daily against Kafka's durable archive: SELECT COUNT(DISTINCT clickId) FROM clicks WHERE date = X GROUP BY campaign_id. The DISTINCT is the exactly-once guarantee — even if the click endpoint retried, the DB dedups. Stream-side aggregation for the ad server's pacing counters may double-count during outages; batch always corrects it.

user click svc billing Click Click endpoint log + 302 Kafka events Batch DISTINCT Billing table
clickId flows end-to-end. distinct-count at billing time makes duplicates safe.
Trade-offBilling lag is a day. Advertiser dashboards show a fresh (approximate) number from stream + the audited daily total from batch. When they disagree, batch is truth.
07

Follow-ups you should expect

How do you rank ads?
Ranker predicts pCTR (probability of click) × bid price → expected revenue per impression. Top expected-value ad wins. Features: user history embedding, ad creative embedding, contextual signals (page, time, device). Model is a boosted tree or shallow DNN; heavier deep models run offline for candidate generation.
Ad fraud (bots clicking)?
Two layers. Real-time: light heuristics at the click endpoint drop obvious bots (no-JS, known bad IP). Offline: heavy ML model runs on the event stream, retroactively invalidates fraudulent clicks. The batch billing pipeline excludes invalidated clicks; advertisers see credits on their next statement.
How do you personalise without a user id (privacy)?
Context-only ranking for anonymous or opted-out users: page content, device, region. Modern trend is federated / cohort-based (Google Topics API): the ad server never sees precise interests, only a bucket. Ranker performance drops noticeably but complies with privacy rules.
Publishers want to guarantee premium ads for premium slots — how?
Waterfall: run a direct-sold campaign auction first (premium contracts), if no fill run programmatic. Modernised as unified auction (header bidding) where direct campaigns compete on price with programmatic in one auction; direct campaigns get a floor multiplier.
Global campaigns across regions — how do you avoid overspending?
Budget counters are per-region with reconciliation every minute against a global spend view. Small overshoot possible (last-minute in one region while another region is unaware); daily total corrected in batch. For strict-budget campaigns, pace conservatively — start at 90% of budget target and slowly relax as day progresses.

Where candidates lose the room

  • Postgres call on the bid path — 100 ms budget blown by one slow query.
  • Per-impression Postgres update for budget — hot-row contention on any active campaign.
  • Stream-only billing — a Kafka lag turns into a support ticket about missing revenue.
  • Trying to update campaigns synchronously to all ad servers — a global fanout in the middle of the day; use hot-reload snapshots with 1-min lag instead.
  • No dedup on clicks — client retries during network flakiness create phantom revenue.

Concepts this leans on

Found a bug or want more?

Tell me what's broken, missing, or wrong. Every message lands in my inbox.