CDN Manifest Manipulation: Personalizing Stream Playback at the Edge

During the 2022 World Cup semi-final, we had 1.8 million viewers hitting the same live HLS origin at once — and every single one needed a different ad break, a different bitrate ladder, and a different content blackout rule based on region. The origin didn't melt because we never asked it to personalize anything. We let the edge do it. That night taught me that if you're still doing stream personalization on the client or at the origin, you're building a bottleneck you don't need.

Why Origin-Level Personalization Falls Apart Under Load

For years, most teams I worked with tried to handle personalization upstream. The packager would generate a unique manifest per user, or the player would be responsible for stitching ads and switching renditions. It works fine at 5,000 concurrent viewers. At 500,000, it collapses. I saw this firsthand at a sports broadcaster in 2019 where we tried session-based manifest generation at the origin — the packager's CPU hit 95% and we started seeing 2-3 second delays in playlist delivery, which immediately blew up rebuffer rates.

The core problem is cacheability. A CDN manifest that is identical for everyone caches beautifully. A CMAF Packaging for CDN Delivery: Unified Streaming Format for HLS and DASH pipeline built correctly will produce one set of segments and one master playlist that can serve an entire region from cache. The moment you introduce manifest manipulation at the origin, you create per-user variants that fragment your cache and force every request back to a small cluster of origins. You lose the whole point of a CDN. Playlist editing has to happen after the cache lookup, not before it, and it has to be stateless enough to run on a hundred edge PoPs without coordination.

That's where edge-based stream personalization changes the model. Instead of storing personalized manifests, we store one canonical manifest and rewrite it per request at the edge using data attached to the request — a JWT with entitlements, a cookie with ad targeting, GeoIP headers, or a query param from the ad decision server. The segments themselves stay untouched and fully cacheable. Only the text playlist is mutated, which is tiny, fast, and doesn't require re-encoding anything. The pattern is well supported by the HTTP semantics described in the HTTP Working Group Specifications, where intermediaries can transform representation metadata as long as cache keys and validation are handled correctly.

# Canonical master.m3u8 stored at origin / cache
#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=4500000,RESOLUTION=1920x1080,CODECS="avc1.640028,mp4a.40.2"
1080p/chunklist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=1280x720
720p/chunklist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=800000,AUDIO="audio-en"
480p/chunklist.m3u8

# What the edge returns for a mobile user in Germany (blackout + bitrate cap)
#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=1280x720
720p/chunklist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=800000,AUDIO="audio-en"
480p/chunklist.m3u8
#EXT-X-SESSION-DATA:DATA-ID="com.example.blackout",VALUE="DE-BL-042"

Rewriting HLS and DASH Manifests Live at the Edge

Implementing CDN manifest manipulation isn't just string replacement. You have to parse the playlist properly, respect protocol spec differences, and keep timing intact. HLS and DASH behave very differently here, which is why I always point teams to a solid DASH vs HLS for CDN Streaming: Protocol Comparison and Selection Guide before they write a single line of worker code. HLS is a rolling text file — you insert or remove #EXTINF segments, inject #EXT-X-DISCONTINUITY tags for ads, and filter variants. DASH MPDs are XML with period-based structure, so you're adding or splicing elements and rewriting SegmentTemplate timelines. If your parser isn't spec-compliant, you'll create manifests that play fine on Safari but stall on ExoPlayer.

In practice, I've had the best results running a lightweight JavaScript worker (Cloudflare Workers, Fastly Compute, or Lambda@Edge) that intercepts the manifest request, fetches the canonical version from cache with a separate cache key, mutates it in memory, and streams it back with a Cache-Control: private, no-store header while keeping the source object cached. For ad insertion, we call the ad decision server at the edge using the viewer's session ID, get back a VAST response, translate it into ad segment URLs, and splice them directly into the playlist. For entitlement filtering, we don't make an external call at all — we just read a signed token the player sent and strip out disallowed renditions or apply blackout slates. The trick is to keep that worker under 5ms of compute; if you're doing XML DOM parsing for DASH, use a SAX-style parser, not a full DOM.

One mistake I don't make anymore is manipulating LL-HLS manifests without honoring partial segments. If you're running Low-Latency HLS Configuration for CDN: Sub-3-Second Live Streaming, your worker must preserve #EXT-X-PART and #EXT-X-SERVER-CONTROL tags and avoid reordering parts inside a segment. I learned this the hard way when a naive regex replacement collapsed a Low-Latency playlist and added 800ms of end-to-end latency because the player had to refetch the entire segment. Guidance on streaming-optimized caching from web.dev Articles was useful for tuning the cache-control interplay between the canonical asset and the personalized response.

// Edge Worker: HLS playlist editing for per-user ad insertion & bitrate filtering
export async function handleManifest(request, env) {
  const url = new URL(request.url);
  const userCtx = decodeJwt(request.headers.get('x-playback-token')); // { region, tier, adId }
  
  // Fetch canonical manifest with shared cache key (bypass personalization)
  const canonicalReq = new Request(url.origin + '/canonical' + url.pathname, request);
  const res = await fetch(canonicalReq, { cf: { cacheTtl: 2, cacheKey: url.pathname } });
  let manifest = await res.text();

  // 1. Entitlement: cap bitrate for mobile tier
  if (userCtx.tier === 'mobile') {
    manifest = manifest.split('\n')
      .filter(line => !line.includes('BANDWIDTH=4500000'))
      .join('\n');
  }

  // 2. Ad splice: fetch from ADS at edge (timeout 40ms)
  if (url.pathname.endsWith('chunklist.m3u8')) {
    const ads = await getAds(userCtx.adId, { timeout: 40 }).catch(() => []);
    if (ads.length) {
      manifest = manifest.replace(
        '#EXT-X-DISCONTINUITY\n#EXTINF:6.000,',
        `#EXT-X-DISCONTINUITY\n#EXT-X-CUE-OUT:30.000\n` +
        ads.map(a => `#EXTINF:6.000,\n${a.url}`).join('\n') +
        `\n#EXT-X-CUE-IN\n#EXT-X-DISCONTINUITY\n#EXTINF:6.000,`
      );
    }
  }
  
  return new Response(manifest, {
    headers: {
      'Content-Type': 'application/vnd.apple.mpegurl',
      'Cache-Control': 'private, no-cache, no-store',
      'Access-Control-Allow-Origin': '*'
    }
  });
}

What Playlist Editing Really Costs at Scale

There's no free lunch with manifest manipulation. Every personalized playlist you generate at the edge trades cache efficiency for flexibility, and you have to measure that trade-off honestly. When we first rolled this out for a FAST channel provider with 35 ad breaks per hour, we saw our origin offload drop from 98% to 94% because we were suddenly doing per-request ADS lookups that occasionally missed their SLA and forced a retry. The fix wasn't to stop personalizing — it was to add a 150ms edge KV cache for ad decisions and to serve the un-personalized playlist if the ad call timed out, rather than failing the whole request.

Compute cost is the other hidden factor. A simple bitrate filter is almost free — around 1-2ms of CPU in a V8 isolate. Full DASH MPD period insertion with BaseURL rewriting can take 8-12ms if you're not careful with your parser. Multiply that by millions of manifest requests per minute and you're looking at real money. We've found that streaming the response and mutating it line-by-line instead of buffering the whole file keeps P95 latency under 25ms, even for 90-second DVR windows with 150 segments. You also need to isolate your cache keys correctly: the canonical manifest uses a shared key, the personalized response must never be cached in a shared cache. Misconfigure that and you'll serve one user's ad break to everyone else.

Personalization Type Edge Compute (p50) Added TTFB (p95) Cache Efficiency Impact Origin Offload
Bitrate / Rendition Filtering 1.2 ms 6 ms None (segments unchanged) 98.5%
HLS Ad Splice (VAST, 30s break) 4.8 ms 22 ms (incl. 15ms ADS call) -2.1% (ADS cache miss) 96.2%
DASH Period Insertion (VMAP) 9.5 ms 38 ms -3.4% (MPD variant) 94.8%
Geo Blackout / Slate Insertion 1.5 ms 7 ms None 98.1%
LL-HLS Part-Level Rewrite 3.1 ms 18 ms -1.2% 97.3%

Putting It Into Practice: Edge Workers, Stale-While-Revalidate, and Smart Cache Keys

Alright, let's get into how you'd actually wire this up without blowing up your cache hit ratio. The pattern that holds up best in production is a dedicated Edge Worker (Cloudflare Workers, Fastly Compute, Akamai EdgeWorkers — they all work the same way here) that only intercepts manifest requests: *.m3u8 and *.mpd. You don't touch the segments. Your segments stay cacheable for hours; your manifests stay personal and short-lived.

The flow's pretty simple when you diagram it, but the devil's in the cache key. On every manifest GET, the worker fetches a single, canonical manifest from origin or mid-tier cache — think of it as your "template" — then personalizes it per viewer. That origin fetch should be collapsed and cached with stale-while-revalidate for ~2-3 seconds, so even at 25k requests per second for a finale, you're only hitting origin once per PoP. The personalization itself happens from data already at the edge: a KV or Edge Dictionary lookup for entitlements, ad decisioning, or watch history. We aim for that lookup to stay under 5ms p95. If you're calling an external ad server synchronously from the worker, you've already lost — you'll add 60-120ms and timeout under load.

The trick is to never create a cache key per user. You generate the personalized manifest on the fly, send it with Cache-Control: private, max-age=1, s-maxage=0, and let the edge compute cache do the heavy lifting for the template. For HLS, that means parsing #EXT-X-STREAM-INF and #EXTINF lines and splicing; for DASH, you're manipulating the <Period> and <SegmentTemplate> nodes. One pitfall we see constantly: forgetting to rewrite the segment URLs to include a token or to keep the same CDN hostname. Do that and the player will stall after the first personalized ad pod. Here's a stripped-down version of what we run in production for HLS ad insertion:

// Edge Worker - HLS Manifest Personalization (simplified)
export async function handleManifest(request, ctx) {
  const url = new URL(request.url);
  const viewerId = request.headers.get('X-Viewer-Id');
  
  // 1. Fetch canonical manifest - cached, coalesced at the edge
  const originManifest = await fetch(`https://origin.internal${url.pathname}`, {
    cf: { cacheTtl: 2, cacheEverything: true }
  }).then(r => r.text());

  // 2. Fast KV lookup (must be <5ms) - no external fetch here
  const profile = await PERSONALIZATION_KV.get(viewerId, { type: 'json' });
  // profile = { ads: [{uri: '/ads/pod1/seg1.ts', duration: 6}, ...], blackout: false }

  // 3. Rewrite live - splice ad pod and enforce bitrate entitlements
  let personalized = originManifest
    .split('\n')
    .filter(line => !line.includes('1080p') || profile.entitlement === 'premium')
    .join('\n');

  if (profile?.ads?.length) {
    const adBlock = profile.ads.map(a => `#EXTINF:${a.duration},\n${a.uri}`).join('\n');
    personalized = personalized.replace('#EXT-X-DISCONTINUITY', `${adBlock}\n#EXT-X-DISCONTINUITY`);
  }

  return new Response(personalized, {
    headers: {
      'Content-Type': 'application/vnd.apple.mpegurl',
      'Cache-Control': 'private, max-age=1, s-maxage=0',
      'Access-Control-Allow-Origin': '*'
    }
  });
}

Advanced Patterns and Why Your Personalized Playback Just Broke

Once you've got basic splicing working, the real use cases kick in. Server-Guided Ad Insertion (SGAI) is the big one — you're not just stitching VOD ads, you're dynamically building ad pods based on the viewer's segment history and filling them with transcoded, frame-aligned 6-second segments. Blackout enforcement is another: if a viewer in a restricted ZIP requests a live MLB feed, you don't 403 them, you rewrite the manifest to point to a slate Period or an alternate stream. That's way cleaner for players than an error. And for time-shifted workflows, you'll want to look at how CDN DVR Functionality at the Edge: Time-Shifted Viewing for Live Streams handles sliding window manifests — you're essentially doing the same math, but per-user, trimming the DVR window to their pause point.

Troubleshooting this stuff at 2 AM is never fun, so here’s where it usually fails. First, cache key pollution. If you accidentally add a cookie or Authorization header to your cache key for the canonical manifest, your hit ratio drops from 98% to 12% and your origin melts. Isolate it. Second, DASH MPD@availabilityStartTime drift. If your edge clock is 300ms off and you regenerate an MPD without preserving the origin's timeline, players will request segments from the future and throw 404s. Always pass through timing attributes verbatim. Third, Low-Latency nightmares: if you're doing this for LL-HLS or Low-Latency DASH with CMAF Chunks: Achieving Near-Real-Time CDN Delivery, your worker budget shrinks to under 15ms. You can't parse XML with a heavy DOM library — use regex or streaming string replacement. We've seen teams try to use a full DASH parser at the edge and add 40ms of latency, which completely kills your 1.5-second glass-to-glass target.

Finally, don't build this in a vacuum from your ingest pipeline. Manifest manipulation is the last mile. If your overall CDN Live Streaming Architecture: Ingest, Transcode, Package, and Deliver at Scale isn't already tagging segments with consistent durations and aligned GOPs, your splices will cause audio pops and player rebuffering no matter how perfect your edge logic is. Test with real players — hls.js, Shaka, ExoPlayer — not just curl. We run a synthetic fleet of 200 real players per PoP that validates continuity counters and #EXT-X-DISCONTINUITY-SEQUENCE after every worker deploy. It catches 90% of issues before viewers do.

Frequently Asked Questions

Won't per-user manifests destroy my CDN cache efficiency?

Not if you do it right. You never cache personalized manifests in the shared cache. You cache one canonical template per rendition per PoP for 2-5 seconds with request collapsing, then personalize at compute. Your segment cache — which is 99% of your bytes — stays perfectly cacheable with a 95%+ hit ratio. We've measured the overhead at about 8-12% more compute CPU per PoP versus serving static manifests, but it saves you a 10x origin spike during peak.

How do you handle DRM and tokenized segments with rewritten manifests?

You have to rewrite the license acquisition URLs and segment tokens in lockstep. For Widevine/FairPlay, we inject the personalized token as a query param on every segment URI during the rewrite: /video/seg_001.m4s?token=eyJ.... The token is validated at the edge on segment delivery, not just on the manifest. If you're using key rotation, make sure your worker also rewrites the #EXT-X-KEY:URI="..." or DASH <ContentProtection> tag to point to the correct key for that viewer’s entitlement tier.

Does this work with LL-HLS / LL-DASH and CMAF chunks?

Yes, but your performance budget gets brutal. For LL-HLS you’re manipulating partials (#EXT-X-PART) and you must deliver the personalized manifest in under 20ms, or you’ll add a full part duration of latency. Keep your worker lightweight — no JSON parsing of large payloads, use edge KV only, and avoid blocking fetches. For LL-DASH, manipulate the MPD patch (MPD Delta) instead of the full MPD when possible to keep payloads under 3KB.

What’s the biggest mistake teams make when they first implement this?

Trying to do personalization at the origin. They spin up an origin service that renders manifests per user, put a CDN in front of it, and then realize they can’t cache anything because every URL has a ?user_id=123. Origin CPU hits 100% at ~3k rps and latency spikes to 800ms. Move that logic to the edge, keep the origin dumb and cacheable, and let the edge be smart. It’s the only way we’ve seen it scale past 50k concurrent viewers without a dedicated manifest fleet.

Related Articles

References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker