DEV Community

Hui
Hui

Posted on

I thought building a video speed controller would take a weekend. The analytics nearly broke me.

It was 2 AM on a Tuesday, and I was staring at my CourseSpeed dashboard looking at a graph that claimed I had just finished a 14-hour AWS certification course in 47 minutes.

I hadn't. I was just testing the 16x speed toggle. But my analytics engine thought I was a god.

When I started building CourseSpeed—a browser extension to inject custom playback speeds and track learning analytics across Udemy, Coursera, LinkedIn Learning, and Skillshare—I thought the hard part would be the UI. It wasn't. Injecting a floating control panel and setting document.querySelector('video').playbackRate = 2.5 takes about ten lines of JavaScript.

The actual nightmare was the learning analytics. Specifically, accurately tracking effective watch time versus wall-clock time across wildly different Single Page Applications (SPAs).

The naive approach that burned me

My first pass at the analytics tracker was straight out of MDN. I listened to the standard HTML5 video events.

// The approach that worked perfectly in my head
const video = document.querySelector('video');

video.addEventListener('ratechange', (e) => {
  sendAnalytics({ type: 'speed_change', rate: e.target.playbackRate });
});

video.addEventListener('timeupdate', () => {
  logWatchTime(video.currentTime, video.playbackRate);
});
Enter fullscreen mode Exit fullscreen mode

This worked flawlessly on Udemy. Then I opened LinkedIn Learning. The dashboard flatlined. Then I tried Coursera. The time spent was wildly inaccurate, drifting by minutes over an hour.

I spent three days debugging this, tearing my hair out over console logs.

Here is what I missed: modern learning platforms don't just drop a raw <video> tag on the page and leave it alone. They wrap it in custom players, throttle events to save CPU, and dynamically destroy and recreate the DOM node when you skip chapters or when the SPA router transitions.

My event listeners were getting orphaned. Or worse, they were firing with stale data because the platform's custom wrapper was dispatching synthetic timeupdate events that didn't reflect the underlying HTML5 video element's true state when the playback rate was manipulated.

Stop trusting the DOM events

I had to rip out the event listeners. If I wanted reliable analytics, I needed to observe the DOM for element swaps and poll the video state myself.

I built a VideoTracker class. Instead of waiting for the platform to tell me the video changed, I used a MutationObserver to watch the specific player wrapper containers.

const observer = new MutationObserver((mutations) => {
  for (const mutation of mutations) {
    if (mutation.type === 'childList') {
      const newVideo = mutation.target.querySelector('video');
      if (newVideo && newVideo !== this.currentVideo) {
        this.attachToVideo(newVideo);
      }
    }
  }
});

// We observe the highest stable container, not the whole document body
const playerRoot = document.querySelector('[data-player-root]') || document.body;
observer.observe(playerRoot, { childList: true, subtree: true });
Enter fullscreen mode Exit fullscreen mode

This solved the orphaned listener problem. When Coursera swapped the video node during a module transition, my tracker caught it, detached the old state, and latched onto the new <video> element within milliseconds.

But then I hit the background tab wall.

The background tab throttling trap

Here’s the real kicker. When you switch tabs to read documentation while a course plays in the background, Chrome (specifically around version 120 and later) aggressively throttles setInterval and setTimeout in background tabs to save battery and CPU.

My analytics pings were getting delayed. The browser would batch them up and send massive, inaccurate chunks of "watch time" when the tab regained focus. If a user left a 2x speed video running in the background for an hour, my extension might only log 15 minutes of actual progress because the setInterval ticking my internal clock was being put to sleep.

I tried switching the internal clock to requestAnimationFrame. That paused entirely in background tabs.

The fix was moving the ticking clock to a Web Worker. Web Workers run on a separate thread and aren't subjected to the same aggressive throttling as the main thread's timers.

The architecture ended up looking like this:

  1. The Web Worker ticks every 1000ms and posts a message to the main thread.
  2. The main thread receives the tick, reads video.currentTime and video.playbackRate directly from the DOM.
  3. The main thread calculates the delta and updates the local analytics state.
// worker.js
let intervalId;

self.onmessage = function(e) {
  if (e.data === 'start') {
    intervalId = setInterval(() => {
      self.postMessage('tick');
    }, 1000);
  } else if (e.data === 'stop') {
    clearInterval(intervalId);
  }
};
Enter fullscreen mode Exit fullscreen mode

It feels like overkill for a browser extension, but it completely eliminated the background-tab drift. The analytics dashboard finally matched reality. A 14-hour course watched at 2x speed now correctly logged as 7 hours of effective learning time, regardless of whether the user was actively staring at the tab or reading MDN docs in another window.

Building tools that live on top of other people's SPAs means you are constantly at the mercy of their DOM updates and browser performance optimizations. You can't just assume the standard APIs will behave the way the spec says they should in a vacuum. You have to build defensively, assume the DOM will mutate out from under you, and never trust a setInterval in a background tab.

Top comments (0)