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
sourcecolumn (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
viewscounter 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 persessionIdper 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.