DEV Community

Hui
Hui

Posted on

Stop relying on `timeupdate`: Building battery-efficient video analytics in the browser

Sunday mornings are for pour-over coffee and catching up on tech courses. A few months ago, I was working through an advanced Rust module on one of the big e-learning platforms. I paused, scrubbed back to re-watch a tricky section on lifetimes, and then skipped ahead.

When I checked my dashboard later, the platform told me I was 85% done with the course.

I wasn't. I was maybe 40% done. The platform's progress tracking was essentially just looking at the highest timestamp I'd reached, completely ignoring the scrubbing and re-watching. It was garbage data.

That specific frustration is what pushed me to build CourseSpeed. I wanted actual, granular learning analytics and better playback speed controls across Udemy, Coursera, and the rest. But building a browser extension that tracks video engagement accurately without turning the user's laptop into a space heater taught me a hard lesson about how we traditionally handle video events in the DOM.

If you're building anything that interacts with HTML5 video—whether it's a custom player, an analytics script, or a browser extension—you need to stop using the timeupdate event.

Here is what you should be doing instead.

The timeupdate trap

If you open the dev tools on almost any video player and attach a listener to timeupdate, you'll see it firing constantly.

// The old, bad way
videoElement.addEventListener('timeupdate', (e) => {
    logAnalytics(e.target.currentTime);
});
Enter fullscreen mode Exit fullscreen mode

The problem? The HTML5 spec doesn't dictate exactly how often timeupdate should fire. It just says it should fire when the playback position changes. In practice, browsers fire it anywhere from 4 to 60 times a second.

If you're just updating a UI progress bar, that's fine. But if you're doing what I do in CourseSpeed—calculating watch-time density, tracking playback speed changes, and syncing local analytics to a backend—that unpredictable firing rate is a nightmare. You end up doing heavy computations on the main thread at random intervals, causing micro-stutters in the video playback and draining battery life on mobile.

Enter requestVideoFrameCallback

Chrome 83 introduced requestVideoFrameCallback (often abbreviated as rVFC), and it's now supported in all major Chromium-based browsers and Safari. Firefox is still dragging its feet on it, but for extension development and modern web apps, it's a game changer.

Instead of relying on a time-based event, rVFC hooks directly into the browser's rendering pipeline. It fires exactly once per rendered video frame.

const video = document.querySelector('video');

function trackFrame(now, metadata) {
    // metadata.mediaTime gives you the exact presentation timestamp
    // metadata.presentedFrames tells you how many frames have been shown

    const currentTime = metadata.mediaTime;

    // Do your analytics math here
    calculateEngagement(currentTime);

    // Queue the next frame
    video.requestVideoFrameCallback(trackFrame);
}

// Kick it off
video.requestVideoFrameCallback(trackFrame);
Enter fullscreen mode Exit fullscreen mode

Why is this better?

  1. It's battery efficient. It aligns with the display's refresh rate and the video's actual frame rate. If the video is 30fps, it fires 30 times a second. If the user pauses, it stops firing completely. No wasted cycles.
  2. You get metadata. The callback passes a metadata object that includes mediaTime, presentedFrames, and expectedDisplayTime. This lets you detect if the video is actually dropping frames or buffering, which is incredible for diagnosing playback quality issues.

When I migrated CourseSpeed's core tracking loop from timeupdate to requestVideoFrameCallback, CPU usage during a 2-hour course dropped by about 14% on my M1 MacBook. That's not a marginal gain. That's the difference between a user keeping your extension enabled and uninstalling it because it's "slowing down their browser."

Finding the video tag in the wild

Of course, getting the analytics loop right is only half the battle. The other half is actually finding the <video> element.

E-learning platforms don't just hand you a clean <video src="..."> tag. They bury it. Udemy uses nested iframes. Coursera wraps things in complex React trees. Skillshare sometimes throws shadow DOMs into the mix.

If you're writing a content script, document.querySelector('video') is going to fail you 90% of the time.

I ended up writing a recursive walker that pierces same-origin iframes and open shadow roots. It's a bit brute-force, but it works reliably across the platforms I support.

function findVideoElements(root = document, results = []) {
    // Check current root
    const videos = root.querySelectorAll('video');
    videos.forEach(v => results.push(v));

    // Pierce open shadow DOMs
    const shadowHosts = root.querySelectorAll('*');
    shadowHosts.forEach(el => {
        if (el.shadowRoot) {
            findVideoElements(el.shadowRoot, results);
        }
    });

    // Pierce same-origin iframes
    const iframes = root.querySelectorAll('iframe');
    iframes.forEach(iframe => {
        try {
            // This will throw if cross-origin, which is fine, we just catch it
            const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
            findVideoElements(iframeDoc, results);
        } catch (e) {
            // Cross-origin iframe. 
            // If the platform uses postMessage for player control, 
            // you have to hook into that instead.
        }
    });

    return results;
}
Enter fullscreen mode Exit fullscreen mode

You don't want to run this on every DOM mutation. Run it once on page load, and then set up a highly specific MutationObserver that only watches for the insertion of new iframe or video nodes.

A quick note on Firefox

Since Firefox doesn't support requestVideoFrameCallback yet, you need a fallback. Don't just revert to timeupdate blindly.

Use requestAnimationFrame combined with checking video.currentTime. It's not as perfectly synced to the video decoder as rVFC, but it ties your logic to the browser's paint cycle rather than the video element's internal clock, which is still vastly superior to the random firing of timeupdate.

function fallbackLoop() {
    if (!video.paused) {
        calculateEngagement(video.currentTime);
        requestAnimationFrame(fallbackLoop);
    }
}
Enter fullscreen mode Exit fullscreen mode

Building tools that sit on top of other people's web apps is always a bit of a cat-and-mouse game. The platforms change their DOM structures, they update their player APIs, and things break. But by relying on low-level browser APIs like requestVideoFrameCallback instead of high-level DOM events, you anchor your code to the rendering engine itself.

It makes your analytics smoother, your extension lighter, and your Sunday morning coffee breaks a lot less frustrating.

Top comments (0)