DEV Community

Mason K
Mason K

Posted on

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

TL;DR

We're building the "which parts of this video do people actually watch" chart from scratch: a client-side interval tracker (~80 lines, zero dependencies), a beacon endpoint with SQLite per-second counters, and a canvas renderer. No analytics vendor, no SDK, data stays yours.

Completion rate tells you how much of a video people watched. The heatmap tells you where: the intro everyone skips, the section a quarter of viewers rewatch, the cliff where they leave. Hosted platforms sell this chart as a premium analytics feature, but the mechanics are simple: watched intervals in, per-second counters out.

The one rule that makes it work: track segments of media time, not events. Events lie the moment someone seeks or rewatches. Intervals don't.

1. 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);   // close at departure point
    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);  // survives tab close
    this.spans = [];
  }
}
Enter fullscreen mode Exit fullscreen mode

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

💡 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.

2. 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: "*/*" }));       // beacons arrive as text

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

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

⚠️ 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.

3. 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); // floor so gaps stay visible
    ctx.fillRect(second * w, canvas.height - h, Math.ceil(w), h);
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

4. 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.

Top comments (1)

Collapse
 
frank_signorini profile image
Frank

How do you handle cases where users seek or skip parts of the video, does the interval tracker account for that? I'd love to swap ideas on this, following for more content on video analytics.