TL;DR
We're going to insert a bumper into an existing VOD stream without touching a single media segment. One
EXT-X-DATERANGEtag in the playlist, one JSON endpoint for the asset list, and hls.js 1.6+ handles scheduling, playback, and resume. You'll also get the event wiring for a real "Ad playing" UI.
The old way to put an ad or slate inside an HLS stream was to splice its segments into the media playlist behind an EXT-X-DISCONTINUITY tag. It works, but your playlist stops being cacheable, your timeline math gets weird, and every player handles the seam slightly differently.
HLS interstitials flip the model: the primary playlist stays untouched, and a date-range tag tells the player "at this point, go play this other thing, then come back." Apple players have supported this natively for a while. On the open web it became practical when hls.js shipped interstitials support in v1.6.0. Let's wire it up end to end.
1. What you need ๐ ๏ธ
- Any working VOD HLS stream (a
.m3u8you control) -
hls.js1.6.0 or newer (we'll use the latest 1.6.x) - Node 20.x for the tiny asset-list server
- A short bumper clip, already packaged as HLS (5 to 15 seconds is perfect)
Check your hls.js version first; interstitials do nothing on 1.5:
npm ls hls.js
# โโโ hls.js@1.6.7
2. Schedule the interstitial in the playlist ๐ผ
Interstitials are scheduled with EXT-X-DATERANGE and keyed to wall-clock time, so the playlist needs a PROGRAM-DATE-TIME anchor. For VOD, you pick an arbitrary epoch and offset from it. Here's a primary media playlist with a bumper scheduled 10 seconds in:
#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:6
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-PROGRAM-DATE-TIME:2026-01-01T00:00:00.000Z
#EXT-X-DATERANGE:ID="mid-1",CLASS="com.apple.hls.interstitial",START-DATE="2026-01-01T00:00:10.000Z",X-ASSET-LIST="https://localhost:3000/asset-list?break=mid-1",X-RESUME-OFFSET=0,X-RESTRICT="SKIP,JUMP"
#EXTINF:6.0,
segment0.ts
#EXTINF:6.0,
segment1.ts
... rest of your segments unchanged ...
#EXT-X-ENDLIST
Three attributes do the work:
-
CLASS="com.apple.hls.interstitial"marks this DATERANGE as an interstitial. -
X-ASSET-LISTpoints at a JSON endpoint (we build it next). For a fixed slate you could useX-ASSET-URI="https://cdn.example.com/bumper/index.m3u8"directly instead. -
X-RESUME-OFFSET=0means "resume the primary exactly where it paused", which is what a VOD ad break wants. Leave it out entirely and the player advances the primary by the interstitial's duration instead; that default is designed for live (keeps you at a constant delay from the edge) and for content replacement.
X-RESTRICT="SKIP,JUMP" stops viewers from seeking past the break. Skip it while debugging, add it back for ads.
๐ก Tip: the primary playlist never changes per viewer. Ad decisioning happens at the asset-list URL, so the playlist stays a static, cacheable file.
3. Serve the asset list ๐งพ
The asset list is a JSON document with an ASSETS array. Each asset has a URI and a DURATION (seconds). This is where you'd call your ad decision server; we'll return a fixed bumper:
// server.js
import express from "express";
const app = express();
app.get("/asset-list", (req, res) => {
// real life: pick assets per user/break here
res.json({
ASSETS: [
{
URI: "https://cdn.example.com/bumper/index.m3u8",
DURATION: 6.0,
},
],
});
});
app.listen(3000, () => console.log("asset list on :3000"));
node server.js
# asset list on :3000
curl -s "http://localhost:3000/asset-list?break=mid-1" | jq .
# {
# "ASSETS": [
# { "URI": "https://cdn.example.com/bumper/index.m3u8", "DURATION": 6 }
# ]
# }
Return two objects in ASSETS and you have an ad pod; the player plays them back to back before resuming.
Pre-rolls and post-rolls don't need a timeline position at all. Add CUE="PRE" (or "POST") to the DATERANGE and the break anchors to the start or end of the presentation:
#EXT-X-DATERANGE:ID="preroll",CLASS="com.apple.hls.interstitial",START-DATE="2026-01-01T00:00:00.000Z",CUE="PRE",X-ASSET-URI="https://cdn.example.com/preroll/index.m3u8"
CUE="ONCE" marks a break that plays a single time per session, which is also where the subtler player edge cases live (more on that in section 5).
โ ๏ธ Note: serve this endpoint with CORS headers the player can use (
Access-Control-Allow-Origin), same as your playlists, or the asset list fetch will fail silently in dev.
4. Wire up hls.js ๐ฌ
Playback needs no special code; interstitials are on by default in 1.6 when the manifest contains them. The events are where you build UI:
// player.js
import Hls from "hls.js";
const video = document.querySelector("video");
const hls = new Hls();
hls.loadSource("https://localhost:8080/primary/index.m3u8");
hls.attachMedia(video);
// the full interstitial schedule (fires on manifest parse + updates)
hls.on(Hls.Events.INTERSTITIALS_UPDATED, (_, data) => {
console.log("breaks:", data.schedule.map(item => item.start));
});
// asset list fetched for a break
hls.on(Hls.Events.ASSET_LIST_LOADED, (_, data) => {
console.log("pod for", data.event.identifier, data.event.assetList);
});
// an interstitial asset player was created (asset about to preload/play)
hls.on(Hls.Events.INTERSTITIAL_ASSET_PLAYER_CREATED, () => {
showAdBadge(true); // "Ad" chip, disable your seek bar
});
// primary content resumed after the break
hls.on(Hls.Events.INTERSTITIALS_PRIMARY_RESUMED, () => {
showAdBadge(false); // restore controls
});
Two debugging affordances worth knowing. The config exposes enableInterstitialPlayback; set it to false and the player ignores interstitial DATERANGEs entirely, which gives you a clean A/B while you bring the feature up (and a kill switch in production). There's also hls.interstitialsManager, which exposes the schedule and playback state so you can render "Ad 1 of 2" and a countdown. Asset list failures surface as non-fatal Hls.Events.ERROR with ASSET_LIST_LOAD_ERROR / ASSET_LIST_PARSING_ERROR details; handle them by letting content continue, which is exactly what the player does by default.
5. Test the seams, not the happy path โ
The happy path will work on the first try, which is a trap. Interstitials moved the complexity into player state around the break boundaries, and that's where hls.js has been landing fixes through late 2025 and early 2026 (resume offsets with CUE="ONCE", seeking across a scheduled break, and similar edge cases in the issue tracker). Budget a real QA pass:
- [ ] Pause during the interstitial, wait 30 seconds, resume
- [ ] Seek from before the break to after it (should the break play? check your
X-RESTRICT) - [ ] Background the tab mid-break, return after it should have ended
- [ ] Kill the asset-list endpoint and confirm content plays through
- [ ] Watch the break twice in one session with
CUE="ONCE"set (it shouldn't replay) - [ ] Compare on Safari/AVPlayer if you ship to Apple devices; behavior is native there
Run through that list on desktop Chrome, one mobile browser, and Safari, and you've covered the paths that actually break.
What's next ๐
Two follow-ups worth your time. First, integrated timelines: Apple's spec includes X-TIMELINE-OCCUPIES so scrubbers can render ad blocks as visible slots; if you build custom controls, that's your next feature. Second, live: schedule the DATERANGE ahead of the live edge, drop X-RESUME-OFFSET so viewers stay at a constant delay, and you have ad breaks in a live channel with the same three pieces you built today. The Apple "Getting Started with HLS Interstitials" PDF and the hls.js API docs cover both in depth.

Top comments (0)