DEV Community

Mason K
Mason K

Posted on

Turn on CMCD: make your CDN logs explain rebuffering

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

  1. CMCD turned on in the player (hls.js + Shaka).
  2. A decision on transmission mode (header vs query) and why it matters.
  3. 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 bs and su are 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"));
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

Two things to sanity-check right here, before any server work:

  • [ ] sid is 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.
  • [ ] bs appears on requests right after a forced stall. Throttle your network to "Slow 3G" in DevTools, let it rebuffer, and watch for bs showing 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 CMCD from 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)