TL;DR
CMCD (Common Media Client Data, CTA-5004) lets your player attach its state (buffer length, bitrate, "I just rebuffered") to every segment request, so your CDN's own logs can explain QoE. We'll enable it in hls.js and Shaka, choose header vs query mode, and write a tiny Node parser that turns the payload into a per-session story.
What we're building
- CMCD turned on in the player (hls.js + Shaka).
- A decision on transmission mode (header vs query) and why it matters.
- A server-side parser that reads the CMCD payload and groups by session.
Versions: hls.js 1.x, shaka-player 4.x, node 20.x.
The keys you'll actually use
CMCD sends short keys on each request. The important ones:
| Key | Meaning | Why you care |
|---|---|---|
sid |
session id | the join key between player and CDN |
cid |
content id | group requests by asset |
bl |
buffer length (ms) | how close to starving |
bs |
buffer starvation flag | true = a rebuffer just happened |
su |
startup flag | request is part of initial buffering |
mtp |
measured throughput (kbps) | client's bandwidth estimate |
br |
requested encoded bitrate | which rendition |
ot |
object type (v/a/m/i) | video, audio, manifest, init |
💡 Boolean keys like
bsandsuare sent as just the key name when true (no=value). Your parser has to handle that.
1. Enable CMCD in hls.js
Enabling the cmcd config object turns it on. Start with header mode:
// app/playerHls.js
import Hls from "hls.js";
const hls = new Hls({
cmcd: {
sessionId: crypto.randomUUID(), // your sid
contentId: "asset-12345", // your cid
useHeaders: true, // header mode (vs query string)
},
});
hls.loadSource("https://cdn.example.com/video/master.m3u8");
hls.attachMedia(document.querySelector("video"));
With useHeaders: false, hls.js appends a CMCD query argument instead. Same data, different transport (see the trade-off below).
2. Enable CMCD in Shaka
Shaka's CmcdManager serializes per CTA-5004 in header or query mode:
// app/playerShaka.js
import shaka from "shaka-player";
const player = new shaka.Player(document.querySelector("video"));
player.configure({
cmcd: {
enabled: true,
sessionId: crypto.randomUUID(),
contentId: "asset-12345",
useHeaders: true,
},
});
await player.load("https://cdn.example.com/video/master.mpd");
3. The transmission-mode decision
This is the part that actually needs thought.
| Mode | Pros | Cons |
|---|---|---|
Header (CMCD-Request: ...) |
clean URLs, cache key untouched | needs CORS Access-Control-Allow-Headers for CMCD-*; CDN must log custom headers |
Query (?CMCD=...) |
works everywhere, easy to log |
changes the URL = changes the cache key unless the CDN ignores the CMCD arg |
⚠️ Query mode can wreck your cache hit ratio: every unique CMCD payload looks like a different object. Configure the CDN to exclude the
CMCDquery arg from the cache key before you ship query mode.
For cross-origin segment hosts in header mode, you need this on the segment server:
Access-Control-Allow-Headers: CMCD-Request, CMCD-Object, CMCD-Session, CMCD-Status
4. Parse it server-side
CMCD values are a comma-separated key=value list, with quoted strings and bare keys for booleans. Here's a minimal parser:
// server/parseCmcd.js - node 20+
export function parseCmcd(raw) {
// raw: e.g. bl=21300,br=2500,bs,cid="asset-12345",mtp=48200,ot=v,sid="abc-123",su
const out = {};
if (!raw) return out;
// split on commas that aren't inside quotes
const parts = raw.match(/(?:[^,"]+|"[^"]*")+/g) || [];
for (const part of parts) {
const eq = part.indexOf("=");
if (eq === -1) {
out[part.trim()] = true; // bare key = boolean true (e.g. bs, su)
continue;
}
const key = part.slice(0, eq).trim();
let val = part.slice(eq + 1).trim();
if (val.startsWith('"') && val.endsWith('"')) {
val = val.slice(1, -1); // quoted string
} else if (!Number.isNaN(Number(val))) {
val = Number(val); // numeric
}
out[key] = val;
}
return out;
}
Read it from whichever transport you chose:
// server/ingest.js (Express)
import { parseCmcd } from "./parseCmcd.js";
app.use((req, _res, next) => {
const fromHeader = req.headers["cmcd-request"]; // header mode
const fromQuery = req.query.CMCD; // query mode
req.cmcd = parseCmcd(fromHeader || fromQuery);
next();
});
5. Turn it into a QoE signal
Now the payoff. Group your request logs by sid and the starvation flags light up the exact requests around a stall:
// server/sessionReport.js (sketch)
function sessionReport(logLines) {
const bySession = {};
for (const line of logLines) {
const c = line.cmcd;
if (!c?.sid) continue;
(bySession[c.sid] ??= []).push({
url: line.url,
edgeCache: line.cacheStatus, // HIT / MISS from your CDN log
ttfb: line.ttfbMs, // edge latency from your CDN log
bufferMs: c.bl,
starved: c.bs === true, // the gold signal
startup: c.su === true,
bitrate: c.br,
});
}
return bySession;
}
A starved: true row sitting next to a MISS with a high ttfb is a root cause you can act on, not a guess. That's the whole point: the player labeled the bad request, in the CDN's own log.
6. Confirm it in the browser before you touch the CDN
Before you go configuring edge logging, verify the player is actually emitting CMCD. Open DevTools, go to the Network tab, filter to your .ts or .m4s segment requests, and look at one.
In header mode, the request headers include a CMCD-Request (and possibly CMCD-Object, CMCD-Session, CMCD-Status) entry:
CMCD-Request: bl=21300,dl=21300,mtp=48200,su
CMCD-Object: br=2500,d=6000,ot=v,tb=4500
CMCD-Session: cid="asset-12345",sid="abc-123"
In query mode, the same data is URL-encoded on the segment URL:
GET /video/seg-042.m4s?CMCD=bl%3D21300%2Cbr%3D2500%2Ccid%3D%22asset-12345%22%2Cot%3Dv%2Csid%3D%22abc-123%22
Two things to sanity-check right here, before any server work:
- [ ]
sidis stable across every segment in one playback (same value), and different across page reloads. If it's changing mid-session, your join key is broken. - [ ]
bsappears on requests right after a forced stall. Throttle your network to "Slow 3G" in DevTools, let it rebuffer, and watch forbsshowing up. If it never appears, the player isn't tracking starvation the way you think it is.
💡 Catch these in the browser and you save yourself an afternoon of staring at CDN logs wondering why nothing groups correctly. The bug is almost always client-side config, not the log pipeline.
Version note
CMCD v1 is stable and supported across hls.js, Shaka, dash.js, ExoPlayer/Media3, and JWPlayer. CTA-5004 v2 (2026) adds new keys, an event-reporting mode, and stricter value encoding; as of early 2026 hls.js was adding optional v2 support. Pin a version so a v2 client doesn't send keys your v1 parser rejects.
What's next
- Configure your CDN to log the CMCD header (or parse the query arg) and exclude
CMCDfrom the cache key. - Start with three keys:
sid,bl,bs. Trace one rebuffer end to end before adding the rest. - Add a CMCD validator in CI (the video-dev community maintains one) so malformed payloads fail fast.
Top comments (0)