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:
sendBeaconis 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
sessionIdper 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
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.

답글 남기기