TL;DR
We'll add real multi-CDN failover to an HLS stream without any client-side URL-rewriting hacks. You'll author a multivariant playlist with two pathways, build a ~40-line Express steering server that returns a JSON steering manifest, point hls.js at it, then flip CDN priority live and watch the player reroute with no reload and no lost buffer.
📦 Code: github.com/USER/hls-content-steering-demo (replace before publishing)
The usual multi-CDN "failover" is a catch block that rewrites segment URLs from CDN A to CDN B and reloads the stream. It loses the buffer, resets the ABR decision, and shows a spinner. Content steering moves the routing decision into the manifest layer, where the player already lives. It's standardized in HLS and DASH, and hls.js supports it. Let's build it.
1. Tag your playlist with pathways
A pathway is a named route to your content, usually one per CDN. In the multivariant (master) playlist you point the player at a steering server and give every variant a PATHWAY-ID.
#EXTM3U
#EXT-X-CONTENT-STEERING:SERVER-URI="http://localhost:3000/steering",PATHWAY-ID="cdn-a"
# --- CDN A renditions ---
#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360,PATHWAY-ID="cdn-a"
https://cdn-a.example.com/360p.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2400000,RESOLUTION=1280x720,PATHWAY-ID="cdn-a"
https://cdn-a.example.com/720p.m3u8
# --- CDN B renditions (same ladder, different host) ---
#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360,PATHWAY-ID="cdn-b"
https://cdn-b.example.com/360p.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2400000,RESOLUTION=1280x720,PATHWAY-ID="cdn-b"
https://cdn-b.example.com/720p.m3u8
Same ladder, listed once per CDN, distinguished by PATHWAY-ID. The SERVER-URI tells the player where to fetch its routing instructions.
2. The steering server
The steering server answers one question: in what order should the player prefer the pathways right now? That's a JSON document.
// server.js
import express from "express";
const app = express();
// In-memory priority. Flip this to simulate a CDN going bad.
let priority = ["cdn-a", "cdn-b"];
app.get("/steering", (req, res) => {
res.json({
VERSION: 1,
TTL: 5, // seconds; keep short for the demo, use ~300 in prod
"RELOAD-URI": "http://localhost:3000/steering",
"PATHWAY-PRIORITY": priority,
});
});
// Ops endpoint: reorder pathways with no deploy, no player reload
app.post("/failover/:winner", (req, res) => {
const winner = req.params.winner;
priority = [winner, ...priority.filter((p) => p !== winner)];
res.json({ ok: true, priority });
});
app.listen(3000, () => console.log("steering server on :3000"));
💡 Tip:
TTLcontrols how often the player re-fetches the manifest. Short values react fast but add requests. In production, something like 300 seconds is normal, with your monitoring flipping priority the instant a CDN degrades.
The fields are the whole spec surface you need:
| Field | Meaning |
|---|---|
VERSION |
Steering manifest version (1) |
TTL |
Seconds until the player re-fetches |
RELOAD-URI |
Where to fetch the next manifest (optional) |
PATHWAY-PRIORITY |
Ordered list of pathway IDs, most preferred first |
3. Wire up hls.js 🎥
Content steering landed in hls.js 1.5; use a current 1.6.x. The key part is that you write no failover code. hls.js reads the EXT-X-CONTENT-STEERING tag, fetches your manifest, and orders pathways itself.
<!-- index.html -->
<video id="video" controls width="640"></video>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.6"></script>
<script>
const video = document.getElementById("video");
if (Hls.isSupported()) {
const hls = new Hls({ debug: false });
// Fires each time the steering manifest is (re)loaded
hls.on(Hls.Events.STEERING_MANIFEST_LOADED, (_e, data) => {
console.log("steering priority:", data.steeringManifest["PATHWAY-PRIORITY"]);
});
hls.loadSource("http://localhost:8080/master.m3u8");
hls.attachMedia(video);
}
</script>
Open the console and you'll see the priority list logged every TTL:
steering priority: ["cdn-a","cdn-b"]
steering priority: ["cdn-a","cdn-b"]
4. Fail over live and watch nothing break
With the stream playing, tell the steering server CDN B wins:
curl -X POST http://localhost:3000/failover/cdn-b
# {"ok":true,"priority":["cdn-b","cdn-a"]}
Within one TTL, the console updates and the network tab shows segment requests move to cdn-b.example.com:
steering priority: ["cdn-b","cdn-a"]
The buffer is intact. The current bitrate is intact. No reload, no spinner. When CDN A recovers, put it back:
curl -X POST http://localhost:3000/failover/cdn-a
Traffic drifts home on the next poll. This is the difference from URL rewriting: you changed a preference, not an address, so the player adjusted future requests instead of tearing down the session.
5. What to harden before production
- Don't trust a static list. A global priority order can't know CDN A is degraded in one region only. Feed the server real signals (error rates, latency, per-region health) and reorder from those.
- Cache correctly. Serve the steering manifest with a short cache lifetime so a reorder actually reaches players.
-
Mind CORS. The steering endpoint is fetched by the player; send the right
Access-Control-Allow-Origin. - DASH too. dash.js supports the same concept via service locations and CDN priority, so one steering strategy can cover both stacks.
⚠️ Note: The protocol is solved; the decision is not. Recent 2026 research (CADENCE, on dash.js) is all about making steering quality-aware with live per-CDN telemetry instead of static heuristics. Start static to get clean failover, then make it smart.
What's next
- Add a third pathway and a region-aware priority function.
- Swap the in-memory
priorityfor something driven by your monitoring or a health-check loop. - Read the Apple HLS Content Steering spec and the DASH-IF spec (published as ETSI TS 103 998) for the full attribute set.
You just deleted the failover code you were going to maintain and replaced it with a JSON endpoint. That's a good trade. #webdev
Top comments (0)