Ads Frequency Cap
An Ads Frequency Cap system limits how many times a user sees a specific ad or ad category within a given time window. This is crucial for advertisers—showing the same ad too many times leads to user fatigue and wasted impressions, while showing it too few times means missed opportunities.
Unlike a standard Rate Limiter, ads frequency capping has a unique characteristic: the read and write paths are separated. During ad bidding, you check if an ad is eligible (read), but a successful bid doesn't mean the ad gets shown. The actual impression is recorded later through event tracking (write). This separation fundamentally changes the design.
Why Ads Frequency Capping Matters
Without frequency capping:
- User fatigue — Seeing the same ad 50 times in a day annoys users and hurts brand perception
- Wasted spend — Advertisers pay for impressions that provide diminishing returns
- Poor allocation — Budget could be better spent reaching new users
Frequency caps are typically set by advertisers as part of their campaign configuration:
- "Show this ad at most 3 times per user per day"
- "Show ads from this campaign at most 10 times per user per week"
- "Show this ad category at most 5 times per user per hour"
In interviews, connect this to the broader ad-serving ecosystem. Frequency capping is one of many signals in the ad selection process—alongside targeting, bidding, and relevance scoring. Understanding where it fits shows depth.
Phase 1: Requirements
Functional Requirements
- Advertisers can configure frequency caps — Set limits per ad, campaign, or category with customizable time windows
- Ad server can check eligibility — During bidding, quickly determine if an ad should be considered for a user
- System tracks impressions — When an ad is actually shown, update the frequency count
- Support multiple cap types — Per-ad, per-campaign, per-category, with different time windows
Non-Functional Requirements
- Low latency reads — Frequency checks happen during ad bidding, which has strict latency budgets (< 10ms)
- High throughput — Millions of ad requests per second
- Eventual consistency — Small inaccuracies in counts are acceptable; slightly over-showing is better than blocking valid ads
- High availability — Frequency cap failures shouldn't block ad serving entirely
Capacity Estimation
Typical ad platform scale:
- 100M daily active users
- 10B ad impressions per day
- 100K active ads with frequency caps
- Peak QPS: 500K reads/second (during bidding), 200K writes/second (impressions)
Storage per user (assuming we track per-ad counts):
- Average user sees 100 distinct ads/day
- Per entry: user_id (8B) + ad_id (8B) + count (4B) + timestamp (8B) = ~28 bytes
- 100M users × 100 ads × 28 bytes = 280 GB active data
This fits in a distributed cache like Redis Cluster.
Phase 2: Data Model
Core Entities
FrequencyCapConfig — Advertiser-defined rules
{
cap_id: string,
entity_type: "ad" | "campaign" | "category",
entity_id: string,
limit: int,
window_seconds: int,
created_at: timestamp
}
ImpressionCount — User-specific frequency tracking
{
user_id: string,
entity_type: string,
entity_id: string,
count: int,
window_start: timestamp
}
ImpressionEvent — Raw event for durability
{
event_id: string,
user_id: string,
ad_id: string,
campaign_id: string,
category_id: string,
timestamp: timestamp
}
Redis Key Design
A critical interview discussion point. Two common approaches:
Option A: User-centric key (fast, approximate windows)
Key: freq:{user_id}:{window}
Value: Hash { "ad:123": 5, "ad:456": 2, "campaign:789": 10 }
Option B: Entity-centric bucketed key (accurate sliding windows)
Key: freq:{user_id}:{entity_type}:{entity_id}
Value: Hash { bucket_ts: count, ... }
Interview insight: Option A is more efficient for users who see many ads (one key fetch) but only approximates sliding windows. Use one hash per window size (e.g., 1h, 24h). Option B supports accurate sliding windows but increases key count and per-entity work. For most ad systems, Option A is preferred because you typically need to check multiple ads at once during bidding.
Phase 3: API Design
Config Management APIs
Advertisers configure caps through an admin interface:
POST /api/v1/frequency-caps
Request: {
"entity_type": "ad",
"entity_id": "ad_123",
"limit": 3,
"window": "24h"
}
Response: { "cap_id": "cap_456", "status": "active" }
GET /api/v1/frequency-caps?entity_id=ad_123
Response: {
"caps": [
{ "cap_id": "cap_456", "limit": 3, "window": "24h" }
]
}
DELETE /api/v1/frequency-caps/{cap_id}
Internal Ad Server APIs
These are service-to-service calls, often gRPC for performance:
// Check eligibility for multiple ads at once (batch for efficiency)
rpc CheckEligibility(CheckRequest) returns (CheckResponse);
message CheckRequest {
string user_id = 1;
repeated string ad_ids = 2;
}
message CheckResponse {
map<string, bool> eligible = 1; // ad_id -> is_eligible
map<string, int32> remaining = 2; // ad_id -> remaining_impressions
}
Event Tracking API
Impressions flow through event tracking (not synchronous API calls):
// Kafka message schema
{
"event_type": "ad_impression",
"user_id": "user_123",
"ad_id": "ad_456",
"campaign_id": "camp_789",
"timestamp": 1706400000,
"metadata": { ... }
}
Phase 4: High-Level Design
Architecture Overview
Key Design Insight: Separated Read/Write Paths
This is the crucial differentiator from standard rate limiting:
Read Path (During Bidding)
- Ad server receives request for user
- Identifies candidate ads
- Queries Frequency Service: "Which of these ads is user X eligible for?"
- Frequency Service checks Redis + config cache
- Returns eligible ads (sub-10ms)
Write Path (After Impression)
- Ad is shown to user
- Client fires impression event to tracker
- Event published to Kafka
- Consumer updates Redis counts
Why separate paths? In real-time bidding (RTB), checking an ad's eligibility happens during auction—but winning the auction doesn't guarantee the ad is shown. Network failures, user scrolling away, or ad blockers can prevent display. Recording the impression only when the ad is actually viewed ensures accurate counting.
Config Flow
Read Path Detail
Batch by window size; if ads use 1h and 24h caps, issue one HMGET per window hash.
Write Path Detail
Phase 5: Scaling & Trade-offs
Handling Write Delay
Since writes are async, there's a gap between impression and count update. During this gap:
- User might see the same ad one extra time
- This is typically acceptable—slightly over-showing is better than the complexity of synchronous writes
Mitigation strategies:
- Optimistic increment during read — When checking eligibility, temporarily increment in a local cache
- Lower caps slightly — If cap is 3, set internal limit to 2.8 to absorb delay
- Accept imprecision — Document that caps are "soft" limits (most advertisers accept this)
Redis Key Expiration Strategy
TTL-based expiration (rolling window approximation, one key per window size):
def record_impression(user_id: str, ad_id: str, window_seconds: int):
key = f"freq:{user_id}:{window_seconds}"
field = f"ad:{ad_id}"
# Atomic increment
pipe = redis.pipeline()
pipe.hincrby(key, field, 1)
pipe.expire(key, window_seconds) # Rolling TTL for this window hash
pipe.execute()
Common pitfall: TTL-based hashes are approximations. Use one key per window size (e.g., 1h, 24h), and reach for bucketed counters or ZSETs if you need true sliding windows.
Scaling the Read Path
The read path is latency-critical. Optimizations:
- Batch requests — Check multiple ads in one Redis call
- Local caching — Cache config rules locally with short TTL (30s)
- Redis Cluster — Shard by user_id for horizontal scaling
- Circuit breaker — If Redis is slow, degrade gracefully (allow all ads)
Scaling the Write Path
The write path can handle higher latency:
- Kafka buffering — Absorb traffic spikes
- Consumer scaling — Add consumers per partition
- Batch updates — Aggregate counts before Redis write
- Idempotency — Use event_id to prevent double-counting on retries
Multi-Level Capping
Real systems have hierarchical caps:
Campaign Cap: 10/day
└── Ad Cap: 3/day (per ad within campaign)
└── Creative Cap: 1/day (per creative variant)
Implementation:
def get_count(user_id: str, entity_key: str, window_seconds: int) -> int:
key = f"freq:{user_id}:{window_seconds}"
value = redis.hget(key, entity_key)
return int(value or 0)
def is_eligible(user_id: str, ad: Ad) -> bool:
ad_cap = get_cap("ad", ad.id)
campaign_cap = get_cap("campaign", ad.campaign_id)
category_cap = get_cap("category", ad.category_id)
ad_count = get_count(user_id, f"ad:{ad.id}", ad_cap.window_seconds)
campaign_count = get_count(user_id, f"campaign:{ad.campaign_id}", campaign_cap.window_seconds)
category_count = get_count(user_id, f"category:{ad.category_id}", category_cap.window_seconds)
return (
ad_count < ad_cap.limit and
campaign_count < campaign_cap.limit and
category_count < category_cap.limit
)
Failure Modes
Redis unavailable:
- Fail open (allow ads) — Maintains ad revenue
- Log failures for later reconciliation
- Alert operations team
Kafka lag:
- Counts become stale
- Set consumer lag alerts
- Auto-scale consumers on high lag
Config service down:
- Use cached configs (stale but functional)
- Default to no cap (fail open for revenue)
Deep Dives
Per-Ad Impression Caps (Follow-up)
Beyond per-user caps, advertisers may set global impression caps per ad:
"This ad should only be shown 1 million times total"
This requires:
- Centralized counter — Can't shard by user
- Approximate counting — Exact count adds too much latency
- Pre-allocation — Distribute quota to ad servers
# Each ad server gets a local quota
class AdServer:
def __init__(self, ad_id, quota_size=1000):
self.local_quota = quota_size
self.used = 0
def can_show(self) -> bool:
if self.used >= self.local_quota:
# Request more quota from central service
self.local_quota += request_quota(self.ad_id)
return self.used < self.local_quota
def record_impression(self):
self.used += 1
This pattern is similar to token bucket distribution or Google's Chubby lock service quota allocation. Mention this connection in interviews to show breadth.
Comparison: Ads Frequency Cap vs. Standard Rate Limiter
| Aspect | Rate Limiter | Ads Frequency Cap |
|---|---|---|
| Read/Write | Same path (synchronous) | Separated (async writes) |
| Entity | Per user/IP/API key | Per user × per ad/campaign |
| Config source | Static rules | Dynamic advertiser config |
| Failure mode | Fail closed (protect system) | Fail open (protect revenue) |
| Accuracy | Must be exact | Approximate OK |
| Time window | Fixed or sliding windows (implementation choice) | Campaign-defined, fixed or sliding |
Redis Implementation Details
Key structure for accurate sliding windows (bucketed counters):
freq:{user_id}:{entity_type}:{entity_id} # Hash: bucket_ts -> count
1706400000 # bucket start (e.g., 5m)
1706400300 # next bucket
Lua script for bucketed eligibility check (bounded buckets):
-- KEYS[1] = freq:{user_id}:{entity}
-- ARGV[1] = window_start_bucket
-- ARGV[2] = bucket_seconds
-- ARGV[3] = num_buckets
-- ARGV[4] = limit
local total = 0
for i = 0, tonumber(ARGV[3]) - 1 do
local bucket = tonumber(ARGV[1]) + (i * tonumber(ARGV[2]))
local count = redis.call('HGET', KEYS[1], tostring(bucket))
if count then total = total + tonumber(count) end
end
if total >= tonumber(ARGV[4]) then
return 0
end
return 1
Common Pitfalls
Synchronous writes during bidding — Adding latency to the bidding path to record impressions hurts ad revenue. Always use async event tracking.
One key per impression/event — Creates key explosion. Prefer hash- or bucket-based counters.
Ignoring the config layer — Interviewers expect you to discuss how advertisers configure caps. Don't skip the admin/config component.
Failing closed on cache miss — In ad systems, revenue is king. If you can't check the cap, show the ad and log the failure.
Interview Checklist
- Explained why frequency capping matters (user fatigue, advertiser value)
- Distinguished from standard rate limiting (separated read/write paths)
- Designed config management layer (advertiser controls)
- Detailed the read path (low latency during bidding)
- Detailed the write path (async event tracking via Kafka)
- Chose appropriate Redis key design (user-centric hash)
- Addressed multi-level capping (ad, campaign, category)
- Discussed failure modes (fail open for revenue)
- Handled scale (Redis Cluster, Kafka partitioning)
Summary
| Component | Technology | Purpose |
|---|---|---|
| Config Store | PostgreSQL | Durable cap configurations |
| Config Cache | Redis/In-memory | Fast config lookups |
| Frequency Store | Redis Cluster | User × entity impression counts |
| Event Pipeline | Kafka | Async impression recording |
| Read Service | gRPC | Low-latency eligibility checks |
Ads Frequency Cap is a specialized rate limiting system optimized for the ad-serving domain. The key insight is the separation of read and write paths—frequency checks happen synchronously during bidding, but impression recording happens asynchronously through event tracking. This design enables sub-10ms read latency while handling millions of impressions per second. When discussing this in interviews, emphasize the advertiser config layer, the async write path, and the fail-open philosophy that prioritizes revenue.