View counts tell you someone opened your video. They don't tell you if anyone actually watched it.
A video with 200 views sounds impressive until you learn that 180 of those viewers bounced in the first 10 seconds. The view count is the same whether someone watched the whole demo or clicked away after the intro.
We added viewer engagement analytics to SendRec. A completion funnel shows how many viewers reached 25%, 50%, 75%, and 100% of your video. No external tracking SDK, no third-party analytics service — just a timeupdate listener, a single POST per milestone, and one database table.
The milestone approach
Continuous engagement tracking (sending a heartbeat every few seconds) generates a lot of data and a lot of requests. For a five-minute video with 100 viewers, that's thousands of pings. You'd need batching, deduplication, and probably a time-series database.
We went with four fixed milestones: 25%, 50%, 75%, 100%. Each viewer generates at most four requests over the entire session. The data is sparse but answers the questions that matter: How many people finished the video? Where do most people drop off?
This is the same approach YouTube uses in its audience retention reports — the difference is we're doing it with 10 lines of JavaScript and a single table.
Client-side tracking
The watch page listens for the browser's timeupdate event, which fires roughly every 250 milliseconds during playback:
var player = document.getElementById('player');
if (!player) return;
var milestones = [25, 50, 75, 100];
var reached = {};
player.addEventListener('timeupdate', function() {
if (!player.duration) return;
var pct = (player.currentTime / player.duration) * 100;
for (var i = 0; i < milestones.length; i++) {
var m = milestones[i];
if (pct >= m && !reached[m]) {
reached[m] = true;
fetch('/api/watch/' + shareToken + '/milestone', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ milestone: m })
}).catch(function() {});
}
}
});
The reached object is the key to keeping it lightweight. Once a milestone fires, it's marked as sent and never fires again — even if the viewer scrubs backward and replays that section. Each milestone produces exactly one network request.
The .catch(function() {}) swallows errors silently. If the request fails, the viewer's playback isn't affected. Analytics is best-effort — missing one data point doesn't corrupt the funnel.
The same script runs on the embeddable player at /embed/:token, so engagement data is captured whether the video is watched on the watch page or embedded in an iframe.
Server-side recording
The endpoint is public (viewers aren't logged in) and returns immediately:
func (h *Handler) RecordMilestone(w http.ResponseWriter, r *http.Request) {
var req milestoneRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httputil.WriteError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Milestone != 25 && req.Milestone != 50 &&
req.Milestone != 75 && req.Milestone != 100 {
httputil.WriteError(w, http.StatusBadRequest,
"milestone must be 25, 50, 75, or 100")
return
}
shareToken := chi.URLParam(r, "shareToken")
var videoID string
err := h.db.QueryRow(r.Context(),
`SELECT id FROM videos WHERE share_token = $1
AND status IN ('ready', 'processing')`,
shareToken,
).Scan(&videoID)
if err != nil {
httputil.WriteError(w, http.StatusNotFound, "video not found")
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ip := clientIP(r)
hash := viewerHash(ip, r.UserAgent())
h.db.Exec(ctx,
`INSERT INTO view_milestones (video_id, viewer_hash, milestone)
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
videoID, hash, req.Milestone,
)
}()
w.WriteHeader(http.StatusNoContent)
}
The handler validates the milestone value (must be 25, 50, 75, or 100), resolves the video from the share token, then fires a goroutine for the database insert. The response returns a 204 before the write completes — the viewer's playback doesn't wait on our database.
The ON CONFLICT DO NOTHING is critical. If the same viewer (same IP + User-Agent hash) hits the same milestone twice — maybe they refreshed the page and rewatched — the duplicate is silently dropped. No error, no extra row.
The data model
One table, one index:
CREATE TABLE view_milestones (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
video_id UUID NOT NULL REFERENCES videos(id),
viewer_hash TEXT NOT NULL,
milestone INTEGER NOT NULL CHECK (milestone IN (25, 50, 75, 100)),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (video_id, viewer_hash, milestone)
);
CREATE INDEX idx_view_milestones_video_id ON view_milestones(video_id);
The UNIQUE constraint on (video_id, viewer_hash, milestone) is what makes ON CONFLICT DO NOTHING work. A viewer can record at most four rows per video — one for each milestone. The CHECK constraint ensures only valid milestone values are stored, even if the validation in the handler is bypassed.
The viewer_hash is a truncated SHA-256 of the IP address and User-Agent string — the same approach we use for view tracking and CTA click tracking. It's not a unique identifier (multiple people behind the same NAT with the same browser will hash identically), but it provides useful deduplication without storing personally identifiable information.
Querying the funnel
The analytics endpoint aggregates milestones with a single query:
SELECT milestone, COUNT(DISTINCT viewer_hash)
FROM view_milestones
WHERE video_id = $1 AND created_at >= $2
GROUP BY milestone
The result is four rows (or fewer, if no one has reached a milestone yet). The handler maps them into a response object:
var milestones milestoneCounts
rows, err := h.db.Query(ctx,
`SELECT milestone, COUNT(DISTINCT viewer_hash)
FROM view_milestones
WHERE video_id = $1 AND created_at >= $2
GROUP BY milestone`,
videoID, since,
)
if err == nil {
defer rows.Close()
for rows.Next() {
var m int
var count int64
if err := rows.Scan(&m, &count); err == nil {
switch m {
case 25:
milestones.Reached25 = count
case 50:
milestones.Reached50 = count
case 75:
milestones.Reached75 = count
case 100:
milestones.Reached100 = count
}
}
}
}
The COUNT(DISTINCT viewer_hash) means the funnel shows unique viewers, not total milestone hits. If one person watches your video three times and reaches 50% each time, they count once in the 50% bar.
The completion funnel
The frontend renders the milestones as a horizontal bar chart in the per-video analytics page. Each bar's width is proportional to total views — if 80 out of 100 viewers reached 25%, the bar fills 80% of its track.
The funnel only appears when there are views. No data, no empty chart taking up space.
Reading the funnel tells you different things about different types of content:
Gradual drop-off (80 → 60 → 45 → 30) — Normal for longer videos. People get what they need and leave. Not a problem.
Sharp early drop-off (80 → 20 → 15 → 12) — Most viewers leave before the halfway mark. The intro might be too slow, or the title set wrong expectations.
Flat funnel (80 → 75 → 72 → 70) — Almost everyone who starts finishes. The content is the right length for its audience.
Spike at 100% relative to 75% — If 100% is close to 75%, viewers who make it three-quarters through tend to finish. Your ending isn't losing people.
What this doesn't track
Individual viewers. We don't show a list of who watched or how far each person got. The viewer hash is for deduplication, not identification. This is a deliberate privacy-first choice.
Scrub position or heatmaps. A heatmap would require sending the current playback position every few seconds — a heartbeat approach that's more complex to build and more expensive to store. The four milestones give you the shape of the drop-off curve without the infrastructure cost.
Re-watch patterns. If someone watches your video twice, the UNIQUE constraint means their milestones are only counted once. We can't distinguish "watched once thoroughly" from "watched three times, skipping around." For aggregate analytics this is fine — you want to know how many people reached each point, not how many times.
Try it
SendRec is open source (AGPL-3.0) and self-hostable. Engagement analytics are live at app.sendrec.eu — upload a video, share the link, watch it to the end, then check the analytics page to see the completion funnel populate.
Top comments (0)