Back to KB
Difficulty
Intermediate
Read Time
7 min

Add real video QoE telemetry to your player in an afternoon

By Codcompass TeamΒ·Β·7 min read

πŸ“¦ Code: github.com/USER/video-qoe-starter β€” replace before publishing.

TL;DR

We'll instrument an HLS.js player to emit the three QoE metrics that actually correlate with viewer abandonment β€” startup time, rebuffering ratio, and playback failure rate β€” ship them to a tiny Fastify endpoint, store them in SQLite, and render a dashboard. End to end in one file each: a React component, a Node endpoint, and a SQL view.

What we're building

A minimum-viable telemetry rig that answers the three questions every video team gets asked the day after launch:

  1. How long is it taking for a video to start playing?
  2. How often is the player stalling mid-playback?
  3. How often is playback failing outright?

We'll use HLS.js 1.6.x (the current stable line, with LL-HLS support), a Node 22 + Fastify backend, and SQLite for storage. No queue, no warehouse, no Grafana. The point is to ship a working baseline; you can swap parts later.

1. Set up the project πŸ› οΈ

mkdir video-qoe-starter && cd video-qoe-starter
npm init -y
npm install hls.js fastify better-sqlite3
npm install -D typescript @types/node @types/better-sqlite3
mkdir client server

Enter fullscreen mode Exit fullscreen mode

Your package.json scripts block:

{
  "scripts": {
    "server": "node --experimental-strip-types server/index.ts",
    "build": "tsc"
  }
}

Enter fullscreen mode Exit fullscreen mode

πŸ’‘ Tip: Node 22 ships with --experimental-strip-types for TypeScript files, so you can run .ts directly without ts-node for prototypes like this.

2. Instrument the player 🎯

The HLS.js event surface is the same shape across every modern player β€” MANIFEST_PARSED, MEDIA_ATTACHED, FRAG_BUFFERED, ERROR, plus the standard HTML video events (play, waiting, playing, ended).

// client/qoe.ts
import Hls, { Events, ErrorData } from 'hls.js';

type QoeSession = {
  session_id: string;
  video_id: string;
  player: string;
  user_agent: string;
  startup_time_ms: number | null;
  total_watch_time_ms: number;
  total_stall_time_ms: number;
  rebuffer_count: number;
  error_codes: string[];
};

export function instrument(video: HTMLVideoElement, hls: Hls, videoId: string) {
  const session: QoeSession = {
    session_id: crypto.randomUUID(),
    video_id: videoId,
    player: `hls.js@${Hls.version}`,
    user_agent: navigator.userAgent,
    startup_time_ms: null,
    total_watch_time_ms: 0,
    total_stall_time_ms: 0,
    rebuffer_count: 0,
    error_codes: [],
  };

  let playRequestedAt: number | null = null;
  let lastPlayingAt: number | null = null;
  let stallStartedAt: number | null = null;
  let firstPlayingFired = false;

  video.addEventListener('play', () => {
    if (playRequestedAt === null) playRequestedAt = performance.now();
  });

  video.addEventListener('playing', () => {
    const now = performance.now();
    if (!firstPlayingFired && playRe

πŸŽ‰ Mid-Year Sale β€” Unlock Full Article

Base plan from just $4.99/mo or $49/yr

Sign in to read the full article and unlock all 635+ tutorials.

Sign In / Register β€” Start Free Trial

7-day free trial Β· Cancel anytime Β· 30-day money-back