DEV Community

Mason K
Mason K

Posted on

Ship your first HLS interstitial: one DATERANGE tag, an asset list, and hls.js 1.6

TL;DR

We're going to insert a bumper into an existing VOD stream without touching a single media segment. One EXT-X-DATERANGE tag 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 .m3u8 you control)
  • hls.js 1.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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Three attributes do the work:

  • CLASS="com.apple.hls.interstitial" marks this DATERANGE as an interstitial.
  • X-ASSET-LIST points at a JSON endpoint (we build it next). For a fixed slate you could use X-ASSET-URI="https://cdn.example.com/bumper/index.m3u8" directly instead.
  • X-RESUME-OFFSET=0 means "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"));
Enter fullscreen mode Exit fullscreen mode
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 }
#   ]
# }
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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)