A play event tells you a video started. It does not tell you whether anyone watched it, and on an autoplay-on-scroll page the two numbers diverge wildly. Watched seconds is the metric that means something, and it requires combining two signals neither of which is sufficient alone.
Problem / Scenario Context
A product page reports 40,000 video plays a month and a completion rate near zero. Both numbers are true and neither is useful. The plays are almost all autoplay triggered by scrolling; the completion rate is low because readers scroll past mid-loop.
What the team actually wants to know is how many seconds of the video were on screen while it was playing — because that is the quantity that correlates with anything downstream. Getting it means tracking an interval that opens when playback and visibility are both true, and closes when either becomes false.
Mechanics Explanation
Three sources of truth have to be combined.
Playback state, from the element's own play and pause events plus ended. Reading video.paused at intervals is not enough, because a pause and a resume between samples is invisible.
Visibility, from an IntersectionObserver with a threshold high enough to mean "on screen" rather than "technically overlapping". This is the same distinction that separates a visible element from a seen one.
Document visibility, from visibilitychange, because a backgrounded tab keeps both of the above reporting positively.
Watched time is the total duration of the intervals in which all three are true. The implementation is an accumulator with an open-interval timestamp: when the combined condition becomes true, record performance.now(); when it becomes false, add the elapsed time and clear the timestamp.
Using performance.now() rather than Date.now() matters, because the former is monotonic and unaffected by clock adjustments — a system clock change mid-session would otherwise produce negative or wildly inflated durations.
Comparison Table: What Each Metric Answers
| Metric | Question it answers | Weakness on an autoplay page |
|---|---|---|
| Play events | how often playback started | dominated by scroll, not interest |
| Completion rate | how many reached the end | near zero for looping background media |
| Time in viewport | how long it was on screen | counts paused and hidden time |
| Playback time | how long it ran | counts off-screen and background time |
| Watched seconds | how long it was seen and playing | none — this is the quantity you want |
| Unmute rate | how many chose to hear it | a strong intent signal, worth pairing |
Minimal Reproducible Example
// Over-counts badly: no visibility involved at all
let watched = 0;
video.addEventListener('play', () => { const t0 = performance.now();
video.addEventListener('pause', () => { watched += performance.now() - t0; }, { once: true });
});
// Scroll away mid-playback and this keeps accruing until the video is paused — which may be never.
Production-Safe Solution
class WatchTimer {
private playing = false;
private inView = false;
private openedAt: number | null = null;
private totalMs = 0;
constructor(private readonly video: HTMLVideoElement) {
for (const type of ['play', 'playing'] as const) {
video.addEventListener(type, () => this.set('playing', true));
}
for (const type of ['pause', 'ended', 'stalled', 'waiting'] as const) {
video.addEventListener(type, () => this.set('playing', false));
}
}
set(signal: 'playing' | 'inView', value: boolean): void {
if (signal === 'playing') this.playing = value; else this.inView = value;
this.reconcile();
}
reconcile(): void {
const open = this.playing && this.inView && document.visibilityState === 'visible';
if (open && this.openedAt === null) {
this.openedAt = performance.now(); // monotonic — immune to clock changes
} else if (!open && this.openedAt !== null) {
this.totalMs += performance.now() - this.openedAt;
this.openedAt = null;
}
}
/** Close any open interval and return whole seconds. */
seconds(): number {
this.reconcile();
const open = this.openedAt !== null ? performance.now() - this.openedAt : 0;
return Math.round((this.totalMs + open) / 1000);
}
}
const timers = new WeakMap<Element, WatchTimer>();
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
timers.get(entry.target)?.set('inView', entry.intersectionRatio >= 0.5);
}
}, { threshold: [0.5] });
document.querySelectorAll<HTMLVideoElement>('video[data-track-watch]').forEach((v) => {
timers.set(v, new WatchTimer(v));
observer.observe(v);
});
// tab switches close the interval for every tracked video
document.addEventListener('visibilitychange', () => {
document.querySelectorAll<HTMLVideoElement>('video[data-track-watch]')
.forEach((v) => timers.get(v)?.reconcile());
});
// report once, on the way out — the same reasoning as an impression beacon
addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'hidden') return;
const payload = [...document.querySelectorAll<HTMLVideoElement>('video[data-track-watch]')]
.map((v) => ({ id: v.dataset.videoId, watched: timers.get(v)?.seconds() ?? 0 }))
.filter((r) => r.watched > 0);
if (payload.length) navigator.sendBeacon('/video-watch', JSON.stringify(payload));
}, { once: true });
Including stalled and waiting in the pause set is a small but meaningful detail: a video buffering on a slow connection is not being watched, and counting that time inflates the metric exactly for the readers having the worst experience.
Choosing a Reporting Granularity
How the accumulated seconds are reported matters almost as much as how they are counted, because the shape of the payload decides what questions the data can later answer.
Reporting a single total per video per session is the cheapest option and answers "was this watched at all". It cannot distinguish one reader watching for thirty seconds from six readers watching for five each, which is usually fine for a background loop and useless for a product demo.
Reporting bucketed thresholds — a beacon at three, ten and thirty seconds — costs a few more requests and gives a retention curve rather than a single number. This is the option worth reaching for when the video is content in its own right, because the drop-off between buckets is the finding.
Reporting continuously, on a timer, is almost never right. It multiplies beacon volume by the session length, and it answers no question the bucketed version does not. If a live figure is genuinely needed, keep it client-side and send only the final total.
Whichever shape is chosen, send the video's identifier rather than its URL, round to whole seconds rather than milliseconds, and drop entries with a total of zero. Those three habits keep the payload small enough that the beacon fits comfortably within the size limit browsers impose on sendBeacon, which is the constraint that quietly breaks reporting on pages carrying many tracked videos.
Verification Steps
- Play with the tab hidden and confirm the accumulator does not advance.
- Scroll away mid-playback and confirm it stops, then scroll back and confirm it resumes rather than restarting.
- Throttle the network to force buffering and confirm stalled time is excluded.
- Compare against wall-clock time for a deliberate 10-second watch: the reported value should be 10, not 10 plus scroll time.
- Confirm the beacon fires on a mobile task-switch, not only on tab close.
Common Mistakes to Avoid
- Using
Date.now(). A clock adjustment mid-session produces impossible durations;performance.now()is monotonic. - Sampling
video.pausedon a timer. A pause and resume between samples is invisible, and the sample interval quantises every measurement. - Counting buffering as watch time. It inflates the metric precisely for the sessions having the worst experience.
- Reporting on
unload. It does not fire reliably on mobile; usevisibilitychangewithsendBeacon, as with any impression beacon.
FAQ
Why not just use the play event count?
On a page where videos autoplay as they scroll into view, the play count measures scrolling rather than interest. Watched seconds — the time the video was both running and on screen — is the quantity that actually varies with whether anyone paid attention.
Should buffering count as watch time?
No. A video that is stalled is not being watched, and counting that time inflates the metric exactly for readers on slow connections, who are having the worst experience. Include stalled and waiting in the set of events that close the interval.
Why performance.now() rather than Date.now()?
performance.now is monotonic: it cannot jump backwards or forwards when the system clock is adjusted, whether by the user or by network time synchronisation. Date.now can, and a mid-session adjustment produces negative or absurdly large durations in the accumulated total.
How do I make sure the last interval is not lost?
Close any open interval before reporting. The seconds method above adds the currently-open span rather than discarding it, and reporting from a visibilitychange handler with sendBeacon means the payload survives the document being discarded.
Related
- Tracking Ad Visibility for Analytics Compliance — the same dwell-and-beacon discipline for impressions
- Autoplaying Video Only When in Viewport — the playback control this measurement observes
- Pausing Background Video to Save Battery — why the tab-visibility signal is needed in both places
↑ Back to Media Playback Visibility Control