Build a video watch-time heatmap: interval tracker, beacon endpoint, canvas render
DEV Community

Build a video watch-time heatmap: interval tracker, beacon endpoint, canvas render

The interval tracker ๐ŸŽฏ

A viewing session is a list of spans: {start, end} in media time. We open a span when playback advances and close it on anything that breaks continuity (pause, seek, ended, tab hidden). A rewatch just produces overlapping spans, which is exactly the signal we want.

// public/heatmap-tracker.js
export class WatchTracker {
  constructor(video, { videoId, endpoint }) {
    this.video = video;
    this.videoId = videoId;
    this.endpoint = endpoint;
    this.sessionId = crypto.randomUUID();
    this.spans = [];
    this.openStart = null;

    const close = () => this.closeSpan();
    video.addEventListener("timeupdate", () => this.tick());
    video.addEventListener("pause", close);
    video.addEventListener("seeking", close);
    video.addEventListener("ended", close);

    document.addEventListener("visibilitychange", () => {
      if (document.visibilityState === "hidden") {
        this.closeSpan();
        this.flush();
      }
    });

    window.addEventListener("pagehide", () => {
      this.closeSpan();
      this.flush();
    });
  }

  tick() {
    const t = this.video.currentTime;
    if (this.video.paused || this.video.seeking) return;
    if (this.openStart === null) {
      this.openStart = t;
      return;
    }
    // guard: timeupdate cadence is load-dependent (spec: ~4Hz to 66Hz).
    // A jump far beyond one tick means we missed a seek; close defensively.
    if (t < this.openStart || t - this.lastTick > 5) {
      this.closeSpan();
      this.openStart = t;
    }
    this.lastTick = t;
  }

  closeSpan() {
    if (this.openStart === null) return;
    const end = this.video.currentTime;
    if (end - this.openStart > 0.5) {
      // ignore sub-500ms noise
      this.spans.push({ start: this.openStart, end });
    }
    this.openStart = null;
  }

  flush() {
    if (!this.spans.length) return;
    const payload = JSON.stringify({
      videoId: this.videoId,
      sessionId: this.sessionId,
      spans: this.spans,
    });
    navigator.sendBeacon(this.endpoint, payload);
    this.spans = [];
  }
}

Wire it to any <video> (works the same under hls.js or video.js, since they drive a media element):

// public/app.js
import { WatchTracker } from "./heatmap-tracker.js";

const video = document.querySelector("video");
new WatchTracker(video, {
  videoId: "onboarding-demo-v3",
  endpoint: "/collect",
});

๐Ÿ’ก Tip: sendBeacon is the whole reason this data survives. It's a fire-and-forget POST the browser completes even as the page unloads. fetch(..., { keepalive: true }) works too if you need headers.

The collect endpoint + per-second counters ๐Ÿ—„๏ธ

Server side, we slice each video into one-second bins and increment every bin a span covers. SQLite is plenty; one row per (video, second):

// server.js (node 20.x, better-sqlite3 ^11)
import express from "express";
import Database from "better-sqlite3";

const db = new Database("heatmap.db");
db.exec(`CREATE TABLE IF NOT EXISTS bins (
  video_id TEXT NOT NULL,
  second INTEGER NOT NULL,
  views INTEGER NOT NULL DEFAULT 0,
  PRIMARY KEY (video_id, second)
)`);

const bump = db.prepare(`
  INSERT INTO bins (video_id, second, views) VALUES (?, ?, 1)
  ON CONFLICT(video_id, second) DO UPDATE SET views = views + 1
`);

const app = express();
app.use(express.text({ type: "*/*" }));

app.post("/collect", (req, res) => {
  const { videoId, spans } = JSON.parse(req.body);
  const insertAll = db.transaction((spans) => {
    for (const { start, end } of spans) {
      const a = Math.max(0, Math.floor(start));
      const b = Math.ceil(end);
      for (let s = a; s < b; s++) bump.run(videoId, s);
    }
  });
  insertAll(spans);
  res.sendStatus(204);
});

app.get("/heatmap/:videoId", (req, res) => {
  const rows = db
    .prepare("SELECT second, views FROM bins WHERE video_id = ? ORDER BY second")
    .all(req.params.videoId);
  res.json(rows);
});

app.listen(3000, () => console.log("collector on :3000"));

Sanity-check it before touching the UI:

curl -X POST localhost:3000/collect \
  -d '{"videoId":"demo","sessionId":"t1","spans":[{"start":0,"end":12.4},{"start":8,"end":12.4}]}'

curl -s localhost:3000/heatmap/demo | jq -c '.[0:4]'
# [{"second":0,"views":1},{"second":1,"views":1},...]
# seconds 8-12 should show views=2 (the rewatch overlap)

โš ๏ธ Note: don't store PII. A random sessionId per playback is enough for de-duping later, and the aggregate table contains no user data at all.

Draw it ๐ŸŽจ

One canvas, one bar per second, color by intensity:

// public/render.js
export async function drawHeatmap(canvas, videoId, duration) {
  const bins = await (await fetch(`/heatmap/${videoId}`)).json();
  const ctx = canvas.getContext("2d");
  const max = Math.max(...bins.map(b => b.views), 1);
  const w = canvas.width / duration;

  ctx.clearRect(0, 0, canvas.width, canvas.height);

  for (const { second, views } of bins) {
    const heat = views / max; // 0..1
    ctx.fillStyle = `hsl(${220 - heat * 190}, 85%, ${35 + heat * 20}%)`;
    const h = canvas.height * (0.15 + 0.85 * heat);
    ctx.fillRect(second * w, canvas.height - h, Math.ceil(w), h);
  }
}

Drop it under the player and you'll immediately see the three signatures every heatmap shows: the intro cliff (cold bars after second ~10), the slow bleed, and (on tutorial content) hot rewatch spikes. A spike is ambiguous by nature: it's either your best moment or your most confusing one, and only watching that section tells you which.

Production notes ๐Ÿ“‹

  • Bin size: per-second is fine up to feature length. For hour-plus content, 2 to 5 second bins keep the table and the chart sane.
  • Cohorts: add a source column (email, landing page, organic) and keep separate counters. Blended cohorts produce a heatmap of nobody.
  • Playback rate: spans are in media time, so 2x watchers are counted correctly by construction. That's the quiet advantage of intervals over "ping every N wall-clock seconds".
  • Total plays vs unique viewers: the views counter counts coverage, so one viewer looping a section three times adds three. That's the right default for a "most replayed" signal. If you also want "how many distinct sessions reached this second", keep a second counter and increment it at most once per sessionId per bin (dedupe the bins per beacon batch before writing).
  • Volume: one row write per viewed second per session. For most product videos that's nothing; if you're at real scale, buffer beacons into a queue and batch the upserts.
  • Live streams: this design is VOD-shaped. For live you'd bin by stream clock instead; different article.

What's next ๐Ÿš€

Two upgrades fall out of owning this data. First, "most replayed": you already have the array, so rendering the peak like the big platforms do is pure UI. Second, feed the peaks into your pipeline: auto-pick thumbnails from the hottest second, or place chapter markers at attention spikes. And if you want the player-side signals to go deeper (startup time, rebuffering, bitrate switches), that's the QoE half of analytics; the interval model here composes cleanly with those event streams too.

Comments

No comments yet. Start the discussion.