The HTML5 video element fires timeupdate about four times a second and reports currentTime, which is enough to build a watch-time counter in an afternoon. The counter will be wrong. It keeps counting through a rebuffer and treats a seek forward as watched. A viewer who stared at a spinner for thirty seconds and closed the tab is recorded as thirty seconds of engagement. Every number it produces is technically accurate and answers no question anyone asked.
TL;DR
timeupdate reports position, not experience. It can't tell a viewer watching from a viewer staring at a spinner, so a hand-rolled counter reports watch time that never happened. Playback quality needs startup time, rebuffering, bitrate switches and error rates, which means instrumenting the player rather than the element. On the FastPix web player that's one attribute, metadata-workspace-key, plus whatever dimensions your product segments on. Add those at integration time, because metadata attaches at playback and can't be backfilled later.
What the video element tells you, and what it hides
The element exposes playback position, duration, volume, buffered ranges and a handful of events. What it doesn't expose is any judgement about whether the experience was good.
Three things are missing, and all three are what you actually want to know. How long the viewer waited before the first frame appeared. How often playback stopped to rebuffer, and for how long in total. And whether the viewer who never pressed play was uninterested or was looking at a player that failed to load.
Each one is derivable from raw events, and deriving them correctly across browsers, connection types and playback engines is the work. That work is the same for every app, which is why it's usually a library rather than something you write.
What you need before you start
- A React 18 or later app with a working player. This assumes hls.js or the FastPix web player.
- A FastPix Workspace Key, found under Workspaces in the dashboard. This one is safe in the client.
npm install @fastpix/video-data-coreif you're instrumenting your own player. Version 1.0.9 is current.
The four metrics worth instrumenting first
Start with these and ignore the rest until they're stable. Each one maps to a decision somebody will actually make.
| Metric | What it answers | Where it usually breaks |
|---|---|---|
| Video startup time | How long until the first frame | A heavy top rendition, or a cold CDN edge |
| Rebuffering ratio | What share of the session was spent stalled | A ladder with too big a gap between rungs |
| Exits before video start | How many viewers never saw a frame | Autoplay policy, a failed manifest request, a bad token |
| Playback failure rate | How often the player gave up | Expired tokens, unsupported codecs, network errors |
Startup time and rebuffering ratio are the pair that correlate with people leaving. Exits before video start catches the failures your error tracker never sees. A player that renders a black rectangle throws no JavaScript exception. The QoE metrics reference has the precise definitions, and video startup metrics breaks the first one into its components.
Step 1: Instrument the engine, not the component
Attach collection to the playback engine rather than to your React component, because the engine is what knows about renditions, fragments and buffer state. For hls.js that means passing both the instance and the constructor:
import { useEffect, useRef } from "react";
import Hls from "hls.js";
import fastpixMetrix from "@fastpix/video-data-core";
export default function Player({ src, videoId, title }) {
const videoRef = useRef(null);
useEffect(() => {
const video = videoRef.current;
if (!video || !Hls.isSupported()) return;
const playerInitTime = fastpixMetrix.utilityMethods.now();
const hls = new Hls();
hls.loadSource(src);
hls.attachMedia(video);
fastpixMetrix.tracker(video, {
hlsjs: hls,
Hls,
data: {
workspace_id: import.meta.env.VITE_FASTPIX_WORKSPACE_KEY,
player_name: "Main Player",
player_init_time: playerInitTime,
video_id: videoId,
video_title: title,
},
});
return () => {
video.fp?.destroy();
hls.destroy();
};
}, [src, videoId, title]);
return <video ref={videoRef} controls playsInline />;
}Capture player_init_time before you create the instance. Startup time is measured from that moment. Record it after the engine has begun fetching the manifest and you understate the number you're trying to improve.
The cleanup matters as much here as it does for the player itself. video.fp.destroy() stops monitoring, and without it React 18 StrictMode leaves a second tracker attached to the same element in development.
Step 2: Attach metadata you can segment by
An aggregate rebuffering ratio tells you something is wrong. It doesn't tell you it's wrong for Android users on the 720p rendition in one region. That's the form the answer has to take before anyone can fix it.
Named attributes cover the common dimensions: video_id, video_title, viewer_id, video_content_type and video_stream_type. Ten open fields, custom_1 through custom_10, cover whatever your product needs to slice by, such as plan tier, tenant, or which experiment the viewer is in. The full list is in use custom dimensions.
One rule saves a lot of pain later: keep the values consistent across loads. A video_title that's sometimes the display name and sometimes a slug produces two rows in every report and no way to merge them.
When the same player moves to a new video, tell the SDK. Otherwise both videos are recorded as one session:
video.fp.dispatch("videoChange", {
video_id: nextId,
video_title: nextTitle,
});You can test this against a real adaptive stream first. The free plan covers 10 videos and 100K streaming minutes a month with no credit card.
Dispatch it immediately after loading the new source. A playlist or a shorts feed that skips this reports one enormous view with a nonsensical completion rate.
Step 3: Report the failures nobody sees
Automatic error tracking covers fatal playback failures. It can't see the thing your application decided was an error. A token your own code refused to refresh. A video your API couldn't find. A rendition you blocked.
video.fp.dispatch("error", {
player_error_code: 1024,
player_error_message: "Playback token refresh failed",
player_error_context: `videoId=${videoId}`,
});Send these through the same pipeline as playback errors rather than to your general-purpose logger. A playback problem that appears in two systems with two different session identifiers takes an afternoon to correlate, and correlating it's the whole job. Troubleshoot playback errors covers what the dashboard does with them.
Step 4: With the FastPix player, this is one attribute
If you're already using the FastPix web player, none of the wiring above applies. Collection is built in, and turning it on is one attribute:
import "@fastpix/fp-player";
<fastpix-player
playback-id={playbackId}
stream-type="on-demand"
metadata-workspace-key={import.meta.env.VITE_FASTPIX_WORKSPACE_KEY}
metadata-video-id={videoId}
metadata-video-title={title}
metadata-viewer-user-id={userId}
/>;Every dimension has an attribute form, metadata-custom-1 through metadata-custom-10 included. disable-data-monitoring turns collection off without removing the metadata. The mapping table is in monitor video data.
The same SDK covers players you didn't build, with a monitor per engine: hls.js, Video.js, Shaka, DASH.js, ExoPlayer, AVPlayer and React Native among them. So the choice of player and the choice of analytics stay independent, which matters if you're still deciding between React player libraries.
Privacy is configuration, not a rewrite
Two settings handle most of what a privacy review will ask for, and both are available on the SDK and as player attributes.
Cookies are used by default to recognise a returning viewer across page views. Setting disableCookies: true, or the disable-cookies attribute, stops that, at the cost of unique-viewer counts becoming per-session. Setting respectDoNotTrack: true, or respect-do-not-track, honours the browser preference.
Decide these before you ship rather than after. Turning cookies off later changes what your historical unique-viewer numbers mean, and nothing in the dashboard will warn you.
Related reading
- The best React video player libraries in 2026 covers the players this instrumentation attaches to.
- How to play HLS in React with hls.js is the engine setup the Step 1 code extends.
- Optimizing video performance for web using analytics covers what to change once the numbers arrive.
- Understand data definitions is the precise meaning of every metric and dimension collected.
Start collecting playback data from your React player
Copy your Workspace Key from the dashboard, install @fastpix/video-data-core, and add the fastpixMetrix.tracker() call from Step 1 to the effect that already creates your hls.js instance. The first session appears in the dashboard within a minute of playback ending, with startup time and rebuffering already broken out. The free plan includes 100,000 data views a month with no credit card, and views from FastPix playback IDs don't count against it at all.
Frequently Asked Questions (FAQs)
How do I track video analytics in React?
Attach a data SDK to the playback engine rather than to your React component. The engine knows about renditions and buffer state. With hls.js, pass both the instance and the Hls constructor to fastpixMetrix.tracker() along with your workspace key. Return a cleanup function that calls video.fp.destroy(), or React 18 StrictMode leaves two trackers attached in development.
What video metrics should I measure first?
Video startup time, rebuffering ratio, exits before video start and playback failure rate. Startup time and rebuffering ratio are the pair that correlate with viewers leaving. Exits before video start catch failures your error tracker never sees because a player rendering a black rectangle throws no JavaScript exception.
Can I measure video watch time with the HTML5 video element?
You can build a counter from timeupdate and currentTime, but it will overcount. It keeps counting through rebuffering, treats a forward seek as watched and cannot distinguish a viewer who left from one who paused. Getting watch time right means tracking playing state, seek direction and stall periods separately.
How do I track multiple videos in the same React player?
Dispatch a videoChange event on the video element immediately after loading the new source. Include the new video_id and video_title. Without it, the SDK treats every video played in that element as one continuous session, which produces a single long view and meaningless completion rates. This matters most for playlists and shorts feeds.
Does video analytics work without cookies?
Yes. Set disableCookies: true on the SDK, or add the disable-cookies attribute to the player, and collection continues without setting any. The trade-off is that a returning viewer cannot be recognized across page views, so unique-viewer counts become per-session. There is a matching respectDoNotTrack option for the browser preference.






