How We Built a Viewer Engagement Heatmap
We already had view counts and a completion funnel showing how many viewers reached 25%, 50%, 75%, and 100%. That tells you whether people finish your video, but not where they lose interest. A five-minute product walkthrough might have great completion rates while everyone skips the two-minute intro.
The engagement heatmap fixes that. It splits the video timeline into 50 segments and shows how many viewers watched each one. Bright segments mean high engagement. Faint segments mean drop-off.
The data model
Each video gets up to 50 rows in a new segment_engagement table:
CREATE TABLE segment_engagement (
video_id UUID NOT NULL REFERENCES videos(id),
segment_index SMALLINT NOT NULL CHECK (segment_index >= 0 AND segment_index < 50),
watch_count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (video_id, segment_index)
);
The composite primary key means no separate ID column and no index to maintain — the primary key is the lookup path. Each segment covers roughly 2% of the video duration.
We considered adding a day column for time-series breakdowns but decided against it. The heatmap answers “which parts of my video work?” — that question doesn’t change much day to day. Keeping it aggregate means at most 50 rows per video, and queries stay simple.
Client-side tracking
The tracking runs as a self-contained IIFE on both watch and embed pages. The core logic hooks into the player’s timeupdate event:
player.addEventListener('timeupdate', function() {
if (!player.duration || player.duration <= 0) return;
var seg = Math.min(
Math.floor((player.currentTime / player.duration) * SEGMENTS),
SEGMENTS - 1
);
if (reported[seg]) return;
if (lastSeg >= 0 && Math.abs(seg - lastSeg) > 1) {
lastSeg = seg;
return;
}
reported[seg] = true;
pending.push(seg);
lastSeg = seg;
});
Three things happen here:
Client-side dedup. The reported object tracks which segments this viewer has already counted. Each segment fires at most once per page load. If someone rewinds and watches segment 12 again, it doesn’t inflate the count.
Seek detection. If the current segment jumps by more than 1 from the last segment, the viewer seeked. We skip that segment — it wasn’t watched through natural playback, so counting it would distort the heatmap. The next naturally-played segment will be counted normally.
Batch collection. Segments accumulate in the pending array instead of firing a request per segment. A flush function sends the batch every 5 seconds:
function flush() {
if (pending.length === 0) return;
var data = JSON.stringify({ segments: pending });
pending = [];
if (navigator.sendBeacon) {
navigator.sendBeacon(
'/api/watch/' + shareToken + '/segments',
new Blob([data], { type: 'application/json' })
);
} else {
fetch('/api/watch/' + shareToken + '/segments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: data
}).catch(function() {});
}
}
The flush runs on an interval, but also fires on pause, ended, visibilitychange, and beforeunload. The sendBeacon API is important here — regular fetch requests get canceled when the page unloads, but sendBeacon is designed for exactly this use case. It queues the request for delivery even after the page closes.
Server-side upsert
The endpoint validates the segments, looks up the video, then fires a goroutine to upsert the counts:
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
for _, seg := range req.Segments {
if seg < 0 || seg >= 50 {
continue
}
h.db.Exec(ctx,
`INSERT INTO segment_engagement (video_id, segment_index, watch_count)
VALUES ($1, $2, 1)
ON CONFLICT (video_id, segment_index)
DO UPDATE SET watch_count = segment_engagement.watch_count + 1`,
videoID, seg,
)
}
}()
The handler returns 204 No Content immediately. The upsert runs in the background so tracking never slows down video playback. The ON CONFLICT DO UPDATE pattern means the first view creates the row, and every subsequent view increments the counter — no read-before-write, no race conditions.
Normalization in the analytics API
Raw watch counts aren’t useful for visualization. Segment 0 might have 200 views while segment 49 has 15. The analytics endpoint normalizes counts to a 0.0–1.0 intensity scale:
var maxCount int64
for _, sd := range segments {
if sd.WatchCount > maxCount {
maxCount = sd.WatchCount
}
}
for i := range segments {
if maxCount > 0 {
segments[i].Intensity = float64(segments[i].WatchCount) / float64(maxCount)
}
}
The highest segment always gets intensity 1.0. Everything else is relative. This means the heatmap always shows useful contrast regardless of whether the video has 5 views or 5,000.
The visualization
The frontend renders 50 equal-width div elements, each using the brand accent color with opacity mapped to intensity:
{Array.from({ length: 50 }, (_, i) => {
const seg = data.heatmap.find((s) => s.segment === i);
const intensity = seg ? seg.intensity : 0;
return (
<div
key={i}
title={`${i * 2}%-${(i + 1) * 2}%: ${seg ? seg.watchCount : 0} views`}
style={{
flex: 1,
background: "var(--color-accent)",
opacity: Math.max(intensity, 0.08),
}}
/>
);
})}
No chart library, no canvas, no SVG. Just 50 divs in a flex container with varying opacity. The minimum opacity of 0.08 keeps empty segments visible so you can see the full timeline shape. Hovering any segment shows the exact percentage range and view count.
What it tells you
The heatmap answers questions that view counts and completion rates can’t:
- Where do viewers drop off? A steep drop after the intro means your hook isn’t working.
- What gets rewatched? Segments with counts higher than the start segment mean people sought back to rewatch them.
- Does the ending matter? If the last few segments are faint, viewers stop before your call to action.
Combined with the existing completion funnel and per-viewer tracking, you can now see both the what (which parts get watched) and the who (which viewers watched how much).
Try it
SendRec is open source (AGPL-3.0) and self-hostable. Share a video, check the analytics page, and see where your viewers pay attention. Pull the image from Docker Hub, check the Self-Hosting Guide, or try it at app.sendrec.eu.