Mario Ruiz Díaz
← All articles

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.

Mario Ruiz Díaz16 min read

Frequency Capping on Top of FreeWheel

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

FreeWheel answers the requests it receives and caps what was sold, counted on the ID in each request. The record of every ad each viewer actually watched, from every source, is built by the platform from its own impression beacons, so the cap that uses it runs on the platform.STREAMING PLATFORMViewer devicesTV · mobile · webAd pod servicebuilds every breakFrequency capfilters candidatesImpression beaconsevery ad that playedViewer ad historyall sources · 24 hFreeWheelpicks ads · caps what was soldOther demand sourcesprogrammatic · direct · promosad breakrequest · candidatestheir candidatesad playedreadsallowed ads

Scroll sideways to see the full diagram →

FreeWheel answers the requests it receives and caps what was sold, counted on the ID in each request. The record of every ad each viewer actually watched, from every source, is built by the platform from its own impression beacons, so the cap that uses it runs on the platform.

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 noticeFreeWheel publisher ad server (public docs)Streaming platform
Same campaign or placement, X times per periodNative cap, on the request's IDCan enforce
Same brandGlobal brand cap, a network function (documented 2024)Can enforce
Same product category over timeExclusivity inside a break onlyCan enforce
Same creative, across campaigns and sourcesNo creative-level capCan enforce
Ads from other demand sourcesNot countedEvery impression beacon
Window evaluated when the break playsNot documentedDecided 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:

InputValue
Impression events at the beacon~6,000 per second (worst case)
Messages on the event bus~12,000 per second
Impression history kept24 hours per device, ~120 GB
Required headroom10x 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

Query on read: at every ad break the decision service loads the viewer's impression history and evaluates every rule for every candidate creative.DECISION AT AD-BREAK TIMEadPod servicefills the breakadSelectionper viewer · per breakImpression history24 h per deviceRulesgeneric · specificEvaluatecreatives × rules × windowAllowed creativesback to adPodcandidatesload

Scroll sideways to see the full diagram →

Query on read: at every ad break the decision service loads the viewer's impression history and evaluates every rule for every candidate creative.

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

Counters on write: each impression increments a counter keyed by viewer and creative, with a TTL as the window; the ad break only reads the counter.WRITE PATH · EVERY IMPRESSIONREAD PATH · EVERY AD BREAKImpression beaconwatched adINCR cap:viewer:creative+ EXPIRE 60 minCounter storeone key per pairAd breakcandidatesGET countercompare with capFixed window · retriesresets early · counts twicereaddrift

Scroll sideways to see the full diagram →

Counters on write: each impression increments a counter keyed by viewer and creative, with a TTL as the window; the ad break only reads the counter.

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 GET per 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

Decide at the ad break's absolute time: the same history blocks an ad at 09:00 (three impressions in the last 60 minutes) and allows it at 09:12, once 08:12 has left the window.WINDOW FOR THE 09:00 BREAK · CAP 3 PER 60 MINWINDOW FOR THE 09:12 BREAK07:5508:1208:4008:5509:00 · 3 of 3blocked08:1208:4008:5509:12 · 2 of 3allowed

Scroll sideways to see the full diagram →

Decide at the ad break's absolute time: the same history blocks an ad at 09:00 (three impressions in the last 60 minutes) and allows it at 09:12, once 08:12 has left the window.

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

Precomputed quotas at Pluto TV: impressions flow through Kafka into history and sliding-window counters; at break time adSelection reads the remaining quotas and falls back to evaluating the rules when a device has no counters yet.VIEWER APPSREAD PATHSTORAGEWRITE PATHViewer appsmobile · web · TVStitcherVOD · linear streamsadPod serviceFreeWheel pods · dedupe · filteradSelectionremaining quotas at break timeRulesgeneric · specific · prevalenceQuota counterssliding window per ruleImpression historyRedis · 24 h · ~120 GBad-beacon APIwatched-ad events · ~6k/sKafkaNEW_IMPRESSION · ~12k/sHistory writerinsert per deviceQuotas managerupdate counters per ruleClean-up jobsexpire old windowsstreamwatched adfilter by quotaentry inserted

Scroll sideways to see the full diagram →

Precomputed quotas at Pluto TV: impressions flow through Kafka into history and sliding-window counters; at break time adSelection reads the remaining quotas and falls back to evaluating the rules when a device has no counters yet.

Split the system into two paths that only share storage.

  • Write path. The beacon API publishes a NEW_IMPRESSION event 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 readCounters on writePrecomputed quotas
Work happensAt every ad breakAt every impressionAt every impression, off the critical path
Read costHistory read + hundreds of checksOne GET per candidateRead remaining quotas
WindowExact, slidingFixed (TTL)Sliding, per rule
Decision instantBreak timeNowBreak time
Layered rulesEasyKey explosionModeled as data
DuplicatesVisible in the historyCounted twiceDeduplicated by impression fingerprint
Main riskLatency at peakSilent driftConsumer lag, cold start

Rules as data, with explicit precedence

Caps only stay predictable if the override order is explicit:

TypeDimensionExamplePrecedence
SpecificCreative"This spot: max 2 per hour"Highest
SpecificBrand"Brand X: OFF" (sponsor)Overrides generic
GenericCreative"Any creative: max 3 per hour"Default
GenericBrand / 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

  1. Duplicate events. Beacons retry. Give each impression a fingerprint and make history inserts and counter updates idempotent on it.
  2. 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.
  3. Clock skew. Device clocks lie. Use server-side timestamps for impressions and the scheduled time for breaks.
  4. 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.
  5. Cold start. No counters for a new device or rule. Fall back to evaluating rules from the history rather than allowing or blocking blindly.
  6. Rule misconfiguration. A generic cap set to 0 blocks every ad. Validate rules on write and keep an OFF switch per dimension.
  7. 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

LayerCommon choicesNote
Event busKafka, Kinesis, Pub/SubKey by device to keep a viewer's impressions ordered
Sliding windowsRedis sorted sets (ZADD, ZCOUNT, ZREMRANGEBYSCORE)Score = impression time; count any window at any instant
Stream processingPlain consumers, Kafka Streams, FlinkStart with consumers; add a framework when windows get complex
HistoryRedis with TTL, DynamoDB with TTL24 h hot data, sized from worst-case metrics
AnalyticsClickHouse, BigQueryMeasure 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_IMPRESSION events 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

  1. Map who sees what: which demand sources fill your breaks, which IDs reach the ad server, and where every played impression is recorded.
  2. Write the rules down: dimensions, windows, caps and the override order. The data model follows from them.
  3. Define the decision instant: break time, not request time.
  4. Start with query on read if traffic is low; measure the decision latency at peak.
  5. Move to precomputed quotas when the read path gets expensive: events on a stream, consumers off the critical path.
  6. Make every write idempotent on an impression fingerprint.
  7. Keep a fallback for devices and rules without counters.
  8. 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