Frequency capping on top of FreeWheel: why streaming platforms build their own
FreeWheel picks the ad for every break, but it does not see every ad a viewer actually watched. Why platforms like Pluto TV keep their own impression history and cap repetition on top of the ad server, three ways to build that cap, and the design that runs in production.

A viewer is watching a free streaming channel. It is 09:00, the third ad break of the hour is about to play, and the ad server has picked the same sneaker ad the viewer already saw at 08:12, 08:40 and 08:55.
The ad server is FreeWheel, and FreeWheel has frequency capping. So why does the same ad keep coming back?
Because the ad server answers the requests it receives, and caps the campaigns it serves against the identifiers it is given. What it does not have is the complete record of what this viewer actually watched. That record lives in the streaming platform, and so does the only cap that can use it. This article explains where that line falls, then compares three ways to build the cap on the platform side, with the design that runs in production at Pluto TV.
What FreeWheel caps, and what it cannot see
Scroll sideways to see the full diagram →
In connected-TV streaming, ads are usually stitched into the stream on the server (server-side ad insertion, SSAI). The platform's ad service asks the ad server for each break, receives candidates, and builds the pod. FreeWheel decides which ad wins each request, and it has frequency capping. What matters is what that cap is attached to, and what reaches it.
How FreeWheel's cap works
From FreeWheel's own publisher API reference (Placement API):
- The cap belongs to what was sold. It is set on a campaign, an insertion order, a placement or a programmatic deal, and limits how many times "the ad units in a placement can be served to a viewer" in a period. Three caps per placement by default, up to ten with an optional feature.
- Periods are a fixed menu: ten minutes, hour, day, week, 30 days, stream, asset, site section, campaign, or a number of minutes. The long periods were already documented in May 2022 (archived copy). The public reference does not say whether a period slides with time or resets, nor at which instant it is evaluated.
- The viewer is the "Identifier from Request": whatever ID arrives in each ad request. Counting by household is an optional feature the account team has to turn on.
- Brand capping arrived late. A global brand frequency cap exists today as a network-level function that placements and deals can opt out of. It is absent from the complete 2022 copy of the reference and documented by late 2024; in 2023 a FreeWheel executive said the company had spent the previous two years building advertiser and brand capping (Beet.TV).
- Industry is for exclusivity, not frequency. Industries and industry groups keep competitors apart within the ads of a break or next to each other. Nothing counts how often a viewer saw a product category over time.
- There is no cap on a creative. In the publisher API, caps appear only on sales objects; creatives and ad units have none.
What reaches it
- The viewer is often anonymous. SSAI has no cookies, so capping relies on the identifiers the publisher passes along (IAB Australia). When a Roku viewer limits ad tracking, the advertising ID becomes a temporary one that "expires after 30 days" (Roku); on Apple devices it arrives as zeros. A cap keyed on an ID that resets cannot count across sessions, and two devices are two viewers.
- It only counts what it served. Breaks are also filled by programmatic demand, direct deals and house promos, and each seller caps its own delivery. Digiday's example: four exposures a day on each of three services "could still lead to 12 views" (Digiday).
- The same creative carries different IDs. Recognizing one ad across sources needs a shared creative ID, and adoption is partial: NBCUniversal found fewer than 20% of digital ads carried one (IAB Tech Lab).
The result is visible on screen. In 2023 AdExchanger watched Pluto TV's on-demand titles and found them "rife with ad repetition", with the same ad up to four times in one episode; Paramount confirmed it uses SSAI across its streaming footprint (AdExchanger).
| Rule a viewer would notice | FreeWheel publisher ad server (public docs) | Streaming platform |
|---|---|---|
| Same campaign or placement, X times per period | Native cap, on the request's ID | Can enforce |
| Same brand | Global brand cap, a network function (documented 2024) | Can enforce |
| Same product category over time | Exclusivity inside a break only | Can enforce |
| Same creative, across campaigns and sources | No creative-level cap | Can enforce |
| Ads from other demand sources | Not counted | Every impression beacon |
| Window evaluated when the break plays | Not documented | Decided at break time |
The conclusion is not that FreeWheel is a bad ad server. It is a question of ownership: the ad server owns the sale and the auction; the platform owns the viewer's history. If the product requirement is "no viewer sees the same ad, brand or category more than X times per Y minutes", the platform has to remember what each viewer watched and filter what the ad server returns, before the break plays.
The problem
A streaming platform usually removes duplicates inside a single ad break (an ad pod) while it builds the pod. The hard part is across breaks: the viewer's last 24 hours of ads, from every source, under rules like:
- the same creative at most 3 times per 60 minutes;
- the same brand at most 5 times per 2 hours;
- the same industry (say, car insurance) at most 2 times per 30 minutes;
- a specific rule for one brand that overrides the generic one, or turns capping off for a sponsor.
Requirements
- Decide on the delivery path. The ad pod service must know which of the ad server's candidates are allowed while it fills the break; the cap cannot add noticeable latency.
- Correct at the break's absolute time. What matters is which impressions are inside the window when the break plays, not when the request was made.
- Rules as data. Ad operations change caps without deploys, with a predictable override order.
- Event-driven input. Impressions arrive as events from mobile, web and TV apps, with duplicates, retries and delays.
- Headroom. Live events and premieres multiply traffic; the design must hold at 10x.
Scale estimate
From the Pluto TV design (worst-case production metrics over six months), plus illustrative assumptions where marked:
| Input | Value |
|---|---|
| Impression events at the beacon | ~6,000 per second (worst case) |
| Messages on the event bus | ~12,000 per second |
| Impression history kept | 24 hours per device, ~120 GB |
| Required headroom | 10x all of the above |
| Ads per break (illustrative) | ~4 |
| Candidate creatives per break (illustrative) | ~20 |
| Impressions per device in 24 h (illustrative) | ~30 |
That gives:
- ~1,500 cap decisions per second at peak (6,000 impressions ÷ 4 ads per break), and 15,000 per second at 10x.
- ~600 comparisons per decision if done naively: 20 candidate creatives × 30 impressions in the history, before even applying brand and industry rules.
- ~240 bytes per stored impression (120 GB over roughly 500 million impressions a day at peak rate): fingerprint, creative, brand, industry and timestamp. Small per record, large in aggregate, and all of it hot for 24 hours.
The arithmetic already hints at the answer: the read path must not recompute everything, and the write path must absorb thousands of events per second without coupling to it.
Approach 1: Query on read
Scroll sideways to see the full diagram →
At every ad break, the decision service loads the viewer's impression history and evaluates every rule for every candidate creative: count the impressions of this creative, this brand and this industry inside each rule's window, compare with the cap, return the allowed list.
Where it shines
- Exact by construction. The decision is computed from the raw history at the break's time, so windows, precedence and new rules apply immediately.
- Simple to reason about. One store, one function, no derived state to keep in sync.
- Rule changes are free. Nothing is precomputed, so editing a rule never requires a backfill.
Where it breaks
- Cost lands on the critical path. A history read plus hundreds of comparisons per break, thousands of times per second, inside the ad break's latency budget.
- It scales with history length. Heavy viewers have the longest histories, and they are exactly the ones you most need to cap.
- Every break pays for the full computation, even when nothing changed since the last one.
Approach 2: Counters on write
Scroll sideways to see the full diagram →
Every impression increments a counter keyed by viewer and creative (INCR cap:viewer:creative) with a TTL equal to the window (EXPIRE 60 min). The ad break only reads the counter and compares it with the cap. This is the textbook fixed-window rate limiter.
Where it shines
- Fast reads. One
GETper candidate, constant time. - Cheap to build. Redis, two commands, no extra services.
- Fine for one cap. A single rule, a single dimension, a single region.
Where it breaks
- Fixed windows are not sliding windows. The TTL starts at the first impression and resets the whole count at once. A viewer can see the ad three times at 08:55 and three more at 09:01.
- Decided at the wrong time. The counter answers "how many right now", but the question is "how many inside the window when the break plays". Ads are requested ahead of playback; the same history can allow an ad at 09:00 and block it at 08:30.
- Double counting. A beacon retry or a duplicate event increments twice. Without an idempotency key per impression, counters drift upward and block ads that should play.
- Rules multiply keys. Creative, brand and industry caps, generic and specific rules, and their precedence turn one counter into a family of counters that must stay consistent.
Why the decision instant matters
Scroll sideways to see the full diagram →
With a cap of 3 per 60 minutes, the 09:00 break counts impressions from 08:00 to 09:00: 08:12, 08:40 and 08:55. That is 3 of 3, so the ad is blocked. At 09:12, 08:12 has left the window: 2 of 3, so the same ad is allowed. A design that evaluates "now" instead of "the break's time" blocks ads that should play, or plays ads that should not.
Approach 3: Precomputed quotas on an event stream
Scroll sideways to see the full diagram →
Split the system into two paths that only share storage.
- Write path. The beacon API publishes a
NEW_IMPRESSIONevent to Kafka for every watched ad. Independent consumers insert it into a 24-hour history per device, update sliding-window quota counters for every rule and dimension it matches, and clean up windows that have expired. - Read path. When the ad pod service fills a break, it asks a decision service (adSelection) for the viewer's remaining quotas at the break's absolute time, and filters the candidate creatives. If a device has no counters yet, the decision service falls back to evaluating the rules on the fly instead of blocking the break.
Where it shines
- Fast, cheap reads. The expensive work happens once per impression, off the critical path; the break reads precomputed state.
- Decoupled scaling. Ingestion, history, quotas and clean-up scale and fail independently; services only know the event stream, not each other.
- Rules stay data. Rule type (generic, specific), dimension (creative, brand, industry) and a clear override order keep behavior predictable while ad operations tune it.
Where it breaks
- Eventual consistency. An impression still in the stream is not counted yet; at the edge, a viewer can see one extra repetition. Consumer lag becomes a product metric.
- Cold start. A new device or a new rule has no counters; without the fallback, decisions would be wrong exactly when traffic spikes.
- More moving parts. Kafka, consumers, storage and clean-up jobs to operate, monitor and size.
Side by side
| Query on read | Counters on write | Precomputed quotas | |
|---|---|---|---|
| Work happens | At every ad break | At every impression | At every impression, off the critical path |
| Read cost | History read + hundreds of checks | One GET per candidate | Read remaining quotas |
| Window | Exact, sliding | Fixed (TTL) | Sliding, per rule |
| Decision instant | Break time | Now | Break time |
| Layered rules | Easy | Key explosion | Modeled as data |
| Duplicates | Visible in the history | Counted twice | Deduplicated by impression fingerprint |
| Main risk | Latency at peak | Silent drift | Consumer lag, cold start |
Rules as data, with explicit precedence
Caps only stay predictable if the override order is explicit:
| Type | Dimension | Example | Precedence |
|---|---|---|---|
| Specific | Creative | "This spot: max 2 per hour" | Highest |
| Specific | Brand | "Brand X: OFF" (sponsor) | Overrides generic |
| Generic | Creative | "Any creative: max 3 per hour" | Default |
| Generic | Brand / Industry | "Any brand: max 5 per 2 hours" | Default |
Specific rules override generic ones, and creative beats brand, which beats industry. Encoding that once, as data, is what lets ad operations change caps without a deploy and without surprises.
Failure modes
- Duplicate events. Beacons retry. Give each impression a fingerprint and make history inserts and counter updates idempotent on it.
- Late events. An impression arrives after a later break was decided. Count by impression time, not arrival time, and accept that the cap is eventually consistent at the edge.
- Clock skew. Device clocks lie. Use server-side timestamps for impressions and the scheduled time for breaks.
- Consumer lag at peak. A live event spikes traffic and quotas fall behind. Alert on lag and size for 10x before the event, not during it.
- Cold start. No counters for a new device or rule. Fall back to evaluating rules from the history rather than allowing or blocking blindly.
- Rule misconfiguration. A generic cap set to 0 blocks every ad. Validate rules on write and keep an OFF switch per dimension.
- Unbounded state. Expired windows that are never cleaned up grow storage forever. Clean-up is a first-class consumer, not a cron afterthought.
Choosing a stack
| Layer | Common choices | Note |
|---|---|---|
| Event bus | Kafka, Kinesis, Pub/Sub | Key by device to keep a viewer's impressions ordered |
| Sliding windows | Redis sorted sets (ZADD, ZCOUNT, ZREMRANGEBYSCORE) | Score = impression time; count any window at any instant |
| Stream processing | Plain consumers, Kafka Streams, Flink | Start with consumers; add a framework when windows get complex |
| History | Redis with TTL, DynamoDB with TTL | 24 h hot data, sized from worst-case metrics |
| Analytics | ClickHouse, BigQuery | Measure cap hits and lost fill, not just latency |
Real-world case: frequency capping at Pluto TV
Situation. Pluto TV is a free, ad-supported streaming service whose ad pod service fills every break from FreeWheel. It could already remove duplicate ads inside a single ad pod, but not across pods: a viewer could see the same ad again and again across the breaks of an hour, which hurts the viewing experience. The FreeWheel setup Pluto TV had at the time could not express the rules the product needed: caps per brand, per product category or per specific ad, generic and specific rules with precedence, OFF for a sponsor, or the same viewer across sessions. And the history those rules needed, every ad each device had actually watched, lived in Pluto TV's own impression events.
Task. Extend deduplication across ad pods with a platform-side frequency cap (the same ad at most X times per Y minutes per viewer), configurable by business rules, decided at ad-break time on the delivery path, and fed by impression events from every viewer device at production scale.
Action. As Tech Lead and solution designer:
- I designed the rule model: generic rules (creative, brand and industry repetition) and specific ones per brand or creative, including OFF, with an explicit override order.
- I designed the decision at the ad break's absolute time over sliding-window quotas, so the same history can allow an ad at one instant and block it at another.
- I designed the architecture as two decoupled paths around shared storage: an ad-beacon API publishing
NEW_IMPRESSIONevents to Kafka, independent consumers for history, quotas and clean-up, a 24-hour impression history in Redis, and an adSelection service that returns remaining quotas to the ad pod service so it can filter FreeWheel's candidates, falling back to evaluating the rules when a device has no counters yet. - I sized the system from six months of worst-case production metrics: roughly 6,000 impression requests per second at the beacon, 12,000 messages per second on Kafka and about 120 GB of history, with the implementation required to support ten times those values.
- I led the implementation from design review to production.
Result. Frequency caps are enforced in production across every ad break a viewer sees. Deduplication went from a single ad pod to 24 hours of impressions per device, the write path is built for the measured peak with 10x headroom, and ad operations change repetition rules without deploys.
The lesson I keep from it: put the cap where the history lives, decide at the instant that matters, and move the work off the path that cannot wait. Most of the design follows from those three choices.
Read the full case: how Pluto TV capped ad repetition across ad breaks →
Where to start
- Map who sees what: which demand sources fill your breaks, which IDs reach the ad server, and where every played impression is recorded.
- Write the rules down: dimensions, windows, caps and the override order. The data model follows from them.
- Define the decision instant: break time, not request time.
- Start with query on read if traffic is low; measure the decision latency at peak.
- Move to precomputed quotas when the read path gets expensive: events on a stream, consumers off the critical path.
- Make every write idempotent on an impression fingerprint.
- Keep a fallback for devices and rules without counters.
- Size from worst-case metrics and keep 10x headroom for live events.
The takeaway
The ad server owns the auction; the platform owns the viewer's history, so the cap that protects the viewer belongs on the platform, filtering what FreeWheel returns. Built there, a frequency cap is a rate limiter whose clock is the ad break. Query on read is exact but pays at the worst moment; counters on write are fast but drift; precomputed quotas move the work to the event stream and keep the decision fast and correct at the instant that matters. The trade is eventual consistency at the edge, and a fallback for when the counters are not there yet.
References
- FreeWheel, Placement API v3 (frequency caps, exclusivity), and the May 2022 archived copy.
- Beet.TV, Frequency-Capping of Ads Is Key Milestone: FreeWheel's Alex Strickland (2023).
- Roku, Integrating the Roku Advertising Framework.
- IAB Tech Lab, Guidelines for the identifier for advertising (IFA) on OTT platforms.
- IAB Australia, CTV quality: frequency capping, content object and ad formats.
- IAB Tech Lab, How ACIF is transforming creative management in CTV.
- Digiday, Streaming services can't stop showing the same ad repeatedly, putting growth at risk (2019).
- AdExchanger, Pluto TV's ad insertion tech has a frequency problem (2023).
- Stripe, Scaling your API with rate limiters.
- Redis, Sorted sets.
- Apache Kafka, Idempotent producer (
enable.idempotence).