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.

Watched Time Is the Overlap of Three SignalsThree parallel timelines over twenty seconds: playback state, element visibility in the viewport, and tab visibility. Each is on for different stretches. A fourth track shows the accumulated watched time, which advances only where all three overlap, producing two intervals totalling nine seconds out of twenty elapsed.20 seconds elapsed, 9 seconds watchedplayingin viewporttab visiblewatched3.5s5.5sAny one signal alone over-counts by a factor of two or more. Only the intersection of all three is watch time.

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

TypeScript
// 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

TypeScript
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.

The Open-Interval AccumulatorA state machine with two states, closed and open. The transition to open requires playback, viewport visibility and tab visibility to all be true, and records a monotonic timestamp. Any of the three becoming false transitions back to closed, adding the elapsed span to a running total. A note lists the events that feed each signal.closedall three trueopenedAt = now()openany one false → total += now() - openedAtsignal sourcesplay/playing · pause/endedstalled/waitingobserver · visibilitychangeOne accumulator, one open timestamp, three inputsCounting stalled and waiting as not-watched keeps the metric honest for readers on slow connections.

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.

Watched Seconds Against Elapsed SecondsA ratio chart for four sessions comparing elapsed page time with accumulated watched seconds. Sessions where the reader scrolled past quickly show a small ratio, while a session where the reader stopped to watch shows most of the elapsed time counted. The spread is what makes the metric useful, and a metric that reported elapsed time would show no spread at all.Four sessions: elapsed on the page, versus seconds actually watchedsession A3s of 42ssession B29s of 42ssession C1s of 42ssession D15s of 42sA play-count metric would report all four sessions identically.

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.paused on 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; use visibilitychange with sendBeacon, 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.


↑ Back to Media Playback Visibility Control