How to design an ad frequency capping system

Ad-supported tiers completely changed the game for streaming giants. If you're sitting down for a Netflix system design interview right now, you aren't just sketching out a video delivery network anymore. You're actually building a massive, ultra-low latency ad serving engine. One of the trickiest and most common components you'll need to architect is the ad frequency capping system. This specific service makes sure viewers don't get bombarded by the exact same commercial repeatedly within a set time frame. It prevents serious ad fatigue while still hitting the strict targets laid out in complex advertiser contracts.
Frequency capping sounds incredibly simple on the surface. You just count how many times a user has seen a video, and if that number exceeds a threshold, you stop showing it to them. But in a distributed, high-concurrency environment, this simple counting exercise becomes a brutal distributed systems problem. You have to account for race conditions, massive data volume, strict latency budgets, and the reality that network requests fail all the time.
Defining the requirements for a streaming ad server
Before drawing boxes on a whiteboard, you have to lock down exactly what the system needs to do. A senior candidate doesn't just jump into the database schema; they define the boundaries of the problem. For an ad frequency capping system, we need to separate our functional requirements from our non-functional constraints.
- Enforce caps across multiple dimensions: The system must limit views per user based on the specific creative (the video file), the campaign (the overall ad buy), and the brand (e.g., no more than 5 Ford ads per day).
- Support sliding time windows: Advertisers don't buy fixed calendar days. A cap of '3 times per 24 hours' means a rolling 24-hour window from the exact millisecond of the user's last view.
- Handle ad pods: Streaming services don't show one ad at a time; they show pods of 3-4 ads in a single commercial break. The system must evaluate caps for an entire pod simultaneously.
- Track impressions, not just decisions: An ad is only counted against a frequency cap if the user actually watches it. If they close the app before the ad break starts, the cap should not increment.
Those are the functional rules. Now let's look at the non-functional requirements, which are what actually dictate the architecture:
- Ultra-low latency: The ad decision system has about 50 milliseconds total to return a video URL. The frequency capping check is just one step in that funnel and gets a latency budget of roughly 5-10 milliseconds.
- High availability: If the frequency capping system goes down, the primary ad server cannot crash. It must fail open.
- Massive scale: We need to support millions of concurrent streams, especially during live events or highly anticipated season premieres.
The scale and constraints of Netflix ad serving
A standard web environment can easily tolerate a few hundred milliseconds of latency for ad serving. Premium video streaming? Not so much. The constraints here are brutally strict. The second a user hits play, or right as a mid-roll ad break approaches, your ad decision system has to do a lot of heavy lifting. It needs to resolve the auction, check pacing, validate those frequency caps, and return the media URL all in under 50 milliseconds. If your system runs even a little too slow, the video starts buffering and user engagement instantly drops.
Let's do some back-of-the-envelope math to understand the read/write volume. Assume a platform has 50 million concurrent viewers during peak hours. Each viewer hits an ad break roughly every 15 minutes. That gives us about 3.3 million ad breaks per minute, or 55,000 ad break requests per second globally. However, an ad decision engine doesn't just check one ad per break. It filters a massive candidate pool. The engine might select 100 potential ads that match the user's demographic, and it needs to check the frequency cap for all 100 of them to find the best 3 for the pod.
That means our frequency capping service isn't handling 55,000 requests per second; it's handling 5.5 million read operations per second. Meanwhile, write operations (incrementing the cap when a user actually views an ad) happen at the rate of actual ad delivery, which is roughly 165,000 writes per second. This extreme read-heavy ratio completely dictates our storage strategy.
The Ad Pod Problem: Evaluating multiple slots at once
One of the most common mistakes candidates make is designing a system for single-slot web ads. In video streaming, ads are delivered in pods. A mid-roll break might have four 15-second slots. The Ad Decision System (ADS) needs to fill all four slots in a single network request to avoid client-side buffering.
This introduces a massive race condition if you aren't careful. Imagine an ad campaign has a frequency cap of 3 views per day. The user has already seen the ad 2 times. The ADS evaluates the candidate pool for the 4-slot pod. When it checks the frequency capping service for slot 1, the service reports the user has 1 view remaining. The ADS places the ad in slot 1. Without a reservation mechanism, the ADS might also place the exact same ad in slot 2, 3, and 4, because the database hasn't been updated yet (the user hasn't actually watched the pod).
To solve this, your frequency capping service must support a 'reserve' or 'shadow bid' operation. When the ADS requests cap statuses, it passes the size of the pod. As it selects an ad for slot 1, it temporarily decrements the remaining cap in a fast-access cache. If the ad is placed in slot 1, its remaining cap drops to zero for the duration of that single pod evaluation, preventing it from being illegally placed in the subsequent slots.
Choosing the right algorithm: Why sliding windows win
When building a rate limiter or frequency capper, you generally have two choices: a fixed window counter or a sliding window log. A fixed window counter simply increments an integer for a given calendar day. It's incredibly memory efficient, but it creates terrible user experiences due to boundary conditions. If a user watches 5 ads at 11:50 PM, and the counter resets at midnight, they could watch another 5 ads at 12:10 AM. That's 10 identical ads in 20 minutes, which violates the spirit of the advertiser's contract.
To provide accurate pacing, we must use a sliding window log. In this model, we don't store a single integer. We store the exact timestamp of every single ad impression. When we want to know how many times a user has seen an ad in the last 24 hours, we look at the current time, subtract 24 hours to find our boundary, drop any timestamps older than that boundary, and count what's left.
Relational databases like PostgreSQL are far too slow for 5.5 million reads per second. Document stores like MongoDB add unnecessary overhead. Memcached is fast, but it only stores dumb strings, meaning you'd have to pull the entire list of timestamps across the network to your application server, filter them, and push them back. This causes severe network bottlenecks and race conditions.
The industry standard solution for a sliding window is Redis, specifically using its Sorted Set (ZSET) data structure. A ZSET stores unique members, each associated with a floating-point score. By using the impression timestamp as both the score and the member, Redis can natively sort and filter the data entirely in memory.

The Execution: Lua Scripts for Atomicity
To execute a sliding window check and update in Redis, you need to perform three distinct operations. First, you remove old timestamps that fall outside the time window using ZREMRANGEBYSCORE. Second, you count the remaining valid timestamps using ZCARD to see if the cap has been reached. Third, if the cap isn't reached, you add the new timestamp using ZADD.
If your application server sends these three commands sequentially over the network, you introduce three round-trips of latency. Worse, you introduce a race condition. If two concurrent requests hit the same user-campaign key between the ZCARD and ZADD steps, both might read a count of '2', both might add a timestamp, and your cap of '3' is suddenly breached.
The solution is to bundle these operations into a Redis Lua script. Redis executes Lua scripts atomically. While the script is running, no other operations can modify the keys being accessed. This guarantees thread safety without needing distributed locks, and it reduces the network overhead to a single round-trip.
- Step 1: The script receives the Redis key (e.g., user:123:campaign:456), the current timestamp, and the window duration.
- Step 2: It calculates the cutoff time (current timestamp - window duration).
- Step 3: It calls redis.call('ZREMRANGEBYSCORE', key, '-inf', cutoff_time) to prune stale data.
- Step 4: It calls redis.call('ZCARD', key) to get the current impression count.
- Step 5: If the count is below the cap, it optionally calls redis.call('ZADD', key, current_timestamp, current_timestamp) and returns true. Otherwise, it returns false.
Memory profiling and cluster sizing
When designing an in-memory system, you must prove to the interviewer that you won't bankrupt the company on RAM costs. Let's calculate the exact memory footprint of our Redis sliding window architecture.
Assume we have 100 million active users. During a given week, a user might interact with 50 different active ad campaigns. This means we need 50 Redis keys per user. The key string itself (e.g., `u:123456789:c:987654321`) takes up about 25 bytes. The Redis ZSET data structure has an overhead of roughly 100 bytes per key. Inside the ZSET, we store timestamps. If the frequency cap is low (e.g., 5 views per week), we store a maximum of 5 members. Each member and score pair in a ZSET takes about 16 bytes. That gives us 80 bytes of data per key.
Totaling this up: 25 bytes (key) + 100 bytes (overhead) + 80 bytes (data) = 205 bytes per key. With 50 keys per user, that's roughly 10 Kilobytes per user. Multiply that by 100 million users, and your entire global frequency capping dataset fits into 1 Terabyte of RAM. This is an incredibly manageable number. You can easily distribute 1TB across a 20-node Redis cluster, giving each node a highly performant 50GB payload.
The Impression vs. Decision Race Condition
We've established how to count, but when do we actually increment the counter? This is the most critical architectural decision in the entire system. You have two options: increment when the Ad Decision System selects the ad (Decision Time), or increment when the video player actually renders the ad on the user's screen (Impression Time).
If you increment at Decision Time, your system is perfectly consistent. The ADS knows immediately that the cap has increased. However, clients are unreliable. A user might close their laptop lid right as the ad break starts. The video buffer might stall. If you increment the cap at decision time, but the user never sees the ad, you are under-delivering. Advertisers will not pay for decisions; they only pay for verified impressions.
Therefore, you must increment at Impression Time. The client device (the smart TV, the mobile app, the browser) fires a telemetry beacon back to your tracking servers when the ad reaches a specific playback milestone—usually the 1-second or 3-second mark. This beacon is ingested by a stream processing pipeline (like Apache Kafka), validated for fraud, and then applied as a write to the Redis frequency capping cluster.
This creates eventual consistency. There is a delay between when the ADS decides to show an ad and when the impression beacon updates Redis. If a user triggers two ad breaks in very quick succession, the ADS might over-deliver an ad because the impression beacon from the first break hasn't been processed yet. In ad-tech, this slight over-delivery is considered an acceptable business trade-off in exchange for accurate, verified billing.
Reliability and the Fail-Open philosophy
Distributed systems fail. Network partitions happen, Redis nodes crash, and Kafka queues back up. When designing a frequency capping system, you must explicitly define the failure mode. Do we fail open or fail closed?
Failing closed means that if the Ad Decision System cannot reach the Redis cluster, it assumes the frequency cap has been met and refuses to serve the ad. This is catastrophic. If your Redis cluster experiences a 5-minute outage, your entire platform stops serving ads. You lose millions of dollars in revenue, and users might experience broken video players waiting for an ad that will never arrive.
You must fail open. Wrap your Redis calls in strict timeouts (e.g., 10 milliseconds). If the call times out, or if the connection drops, the application code should catch the exception, log a metric, and return 'True'—allowing the ad to be served. Yes, a localized outage might cause a user to see the same car commercial four times in a row, violating the cap. But temporarily annoying a fraction of your user base is vastly preferable to taking down the company's primary revenue stream.
To protect the system during recovery, implement exponential backoff and jitter on your client telemetry retries. If the Redis cluster goes down, millions of impression beacons will fail to write. If all clients retry simultaneously when the cluster comes back up, they will immediately DDoS the database. Jitter ensures the retry requests are smoothed out over time, allowing the cluster to recover gracefully.
Frequently asked questions
Why use Redis Sorted Sets instead of Memcached for frequency capping?
Memcached is great for basic key-value caching, but it completely lacks native data structures like Sorted Sets. If you try to build a sliding window in Memcached, you're forced to read the entire list of timestamps into the application layer, modify it, and write it back. That round trip introduces severe race conditions and unnecessary network overhead.
How do you handle multi-dimensional caps, like capping by campaign and by creative?
You'll want to structure your Redis keys to actually reflect those dimensions. Think about maintaining separate keys, like user:123:campaign:456 and user:123:creative:789. Your Lua script then checks all the relevant keys tied to a specific ad. If any single dimensional cap gets exceeded, the system immediately marks that ad as ineligible.
What happens if the Redis cluster runs out of memory?
Since we only store timestamps for active windows, we can aggressively set a Time-To-Live (TTL) on our Redis keys. You should set the TTL to equal your longest frequency cap window, like 24 hours. If a user doesn't watch an ad for 24 hours, the key naturally expires and frees up that memory automatically.
Is it better to fail open or fail closed in an ad serving system?
For almost all consumer media products, you definitely want to fail open. Failing closed means returning zero ads, which leads to blank screens, broken players, and lost revenue. Temporarily over-delivering an ad because of a database outage is simply the lesser of two evils when compared to completely degrading the user experience.
Related comparisons
See AcePrompt in action
Watch how AcePrompt supports a real technical round - structured answers, tuned to your resume, in real time.
Ace your next system design interview with real-time AI guidance from AcePrompt.
Get started