<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Hui</title>
    <description>The latest articles on DEV Community by Hui (@claytonchow).</description>
    <link>https://dev.to/claytonchow</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4020357%2Fbbf89c0d-1547-416e-9f48-23759cd8c20b.png</url>
      <title>DEV Community: Hui</title>
      <link>https://dev.to/claytonchow</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/claytonchow"/>
    <language>en</language>
    <item>
      <title>I built an AI-powered video speed controller for online courses</title>
      <dc:creator>Hui</dc:creator>
      <pubDate>Wed, 22 Jul 2026 05:32:24 +0000</pubDate>
      <link>https://dev.to/claytonchow/i-built-an-ai-powered-video-speed-controller-for-online-courses-46ia</link>
      <guid>https://dev.to/claytonchow/i-built-an-ai-powered-video-speed-controller-for-online-courses-46ia</guid>
      <description>&lt;p&gt;I was taking 12 online courses in 3 months. Python, machine learning, web development — all while working full-time.&lt;/p&gt;

&lt;p&gt;Every platform had a different video player. Udemy's speed control had 4 steps. Coursera's shortcuts were different. LinkedIn Learning didn't remember my settings. I was spending more time fighting the players than actually learning.&lt;/p&gt;

&lt;p&gt;So I built CourseSpeed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;CourseSpeed is a Chrome extension that gives you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Precision speed control (0.25x–16x) with per-platform memory — Coursera stays at 1.5x, Udemy at 1.75x&lt;/li&gt;
&lt;li&gt;AI study notes — one click captures audio and generates timestamp-linked notes you can export anywhere&lt;/li&gt;
&lt;li&gt;Learning analytics — shows your real efficiency, not just "hours watched"&lt;/li&gt;
&lt;li&gt;Keyboard shortcuts for everything — no mouse needed&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The tech stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Manifest V3 Chrome extension&lt;/li&gt;
&lt;li&gt;Vanilla JS (no framework — extension needs to be fast and lightweight)&lt;/li&gt;
&lt;li&gt;AI note generation via speech-to-text + LLM summarization&lt;/li&gt;
&lt;li&gt;All data stored locally in Chrome storage — zero server costs&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Chrome's MV3 migration is painful but worth it.&lt;/strong&gt; Service workers replace background pages, and the new API surface takes time to learn.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Course platforms don't want you to control speed.&lt;/strong&gt; Most of them strip or override custom speed controls. Getting CourseSpeed to work across Udemy, Coursera, LinkedIn Learning, and Skillshare required per-platform DOM manipulation strategies.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Science backs this up.&lt;/strong&gt; Murphy et al. (2022) found 2x speed causes no significant learning loss. Watching a lecture twice at 2x produces better retention than once at normal speed. I built CourseSpeed on this research — not assumptions.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The results so far
&lt;/h2&gt;

&lt;p&gt;CourseSpeed just launched on Product Hunt and has active users on the Chrome Web Store. The most requested feature so far: per-platform speed presets (already built).&lt;/p&gt;

&lt;p&gt;If you take online courses and want to learn faster without losing comprehension, try it out:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://joycraft.dev/coursespeed" rel="noopener noreferrer"&gt;https://joycraft.dev/coursespeed&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Ask me anything about building Chrome extensions, the MV3 migration, or the science of video speed learning.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>javascript</category>
      <category>productivity</category>
      <category>tools</category>
    </item>
    <item>
      <title>The 1.08x Anomaly: What 4 Million Playback Events Taught Me About How We Actually Watch TV</title>
      <dc:creator>Hui</dc:creator>
      <pubDate>Thu, 09 Jul 2026 10:48:08 +0000</pubDate>
      <link>https://dev.to/claytonchow/the-108x-anomaly-what-4-million-playback-events-taught-me-about-how-we-actually-watch-tv-14jk</link>
      <guid>https://dev.to/claytonchow/the-108x-anomaly-what-4-million-playback-events-taught-me-about-how-we-actually-watch-tv-14jk</guid>
      <description>&lt;p&gt;Tuesday morning, sitting in a cafe with an overpriced flat white, I was scrolling through the monthly aggregated telemetry dashboard for StreamEnhancer. &lt;/p&gt;

&lt;p&gt;When I first built this extension to control playback speed across YouTube, Netflix, Disney+, Prime, and Max, I had a very specific mental model of the user. I pictured the hyper-optimized tech worker blasting through a 40-minute YouTube tutorial at 2.5x speed, or the student skimming a recorded lecture at 1.75x. I built the UI around those assumptions. Big, bold buttons for 1.5x and 2.0x. &lt;/p&gt;

&lt;p&gt;Then I looked at the histogram for narrative streaming platforms. &lt;/p&gt;

&lt;p&gt;Out of 4.2 million speed-change events logged last month, the massive, undeniable spike wasn't at 1.5x. It wasn't at 2.0x. &lt;/p&gt;

&lt;p&gt;It was at exactly 1.08x. &lt;/p&gt;

&lt;p&gt;Specifically, 61% of all speed adjustments on Netflix, Max, and Disney+ landed in the microscopic band between 1.05x and 1.12x. People aren't trying to hack their way through a movie in half the time. They are trying to shave exactly four minutes off a 50-minute TV episode without their brain registering that the audio has been altered. &lt;/p&gt;

&lt;p&gt;It completely changed how I think about media consumption, and more importantly, how I architect the underlying media pipeline for StreamEnhancer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "Invisible Speedup" and the Audio Pipeline
&lt;/h2&gt;

&lt;p&gt;At 1.25x, you notice the cadence is off. The pauses between sentences feel unnaturally short. At 1.5x, the pitch correction algorithms in browsers start to make actors sound slightly metallic. &lt;/p&gt;

&lt;p&gt;But at 1.08x? Your brain just thinks the actors are speaking a bit more briskly. It’s the Goldilocks zone of time-stretching. &lt;/p&gt;

&lt;p&gt;The problem is that browsers are actually pretty bad at handling micro-speedups natively. If you just set &lt;code&gt;video.playbackRate = 1.08&lt;/code&gt; on an HTML5 video element, modern browsers (like Chrome 132) will attempt to preserve the pitch using their built-in time-stretching algorithms. But for complex, multi-layered movie soundtracks—where you have dialogue, a sweeping orchestral score, and foley effects mixed together—the browser's native phase vocoder introduces a subtle "phasing" or "flutter" artifact. &lt;/p&gt;

&lt;p&gt;It’s barely noticeable at 1.5x because your brain is already accepting the audio as "fast." But at 1.08x, where the user expects the audio to sound completely natural, that subtle flutter breaks the illusion.&lt;/p&gt;

&lt;p&gt;Because the data showed me that this "invisible speedup" was the primary use case, I had to rethink how StreamEnhancer handles the audio context for narrative platforms. Instead of relying purely on the native &lt;code&gt;playbackRate&lt;/code&gt; property, we route the audio through the Web Audio API for micro-adjustments.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// High-level architecture for micro-speed audio handling&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;audioContext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AudioContext&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;audioContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createMediaElementSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;videoElement&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// We use a custom granular overlap-add (OLA) processor &lt;/span&gt;
&lt;span class="c1"&gt;// for speeds between 1.01x and 1.15x to prevent phasing artifacts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;customPitchShifter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createGranularProcessor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;audioContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;grainSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2048&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;overlapRatio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;targetRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.08&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;customPitchShifter&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;customPitchShifter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;audioContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Disable native pitch preservation so our custom node handles it&lt;/span&gt;
&lt;span class="nx"&gt;videoElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;preservesPitch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;videoElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;playbackRate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.08&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By handling the granular synthesis ourselves for that specific 1.01x–1.15x band, we eliminate the metallic flutter on movie soundtracks. It takes more CPU, but the data proved this is exactly what people actually care about. For speeds above 1.25x, we just fall back to the browser's native implementation to save battery life.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bimodal Reality of YouTube
&lt;/h2&gt;

&lt;p&gt;If Netflix and Max are about the "invisible speedup," YouTube is a completely different beast. &lt;/p&gt;

&lt;p&gt;When I isolated the telemetry for YouTube, the histogram didn't show a neat curve. It showed a harsh, bimodal distribution. &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Peak 1:&lt;/strong&gt; 1.0x (Music videos, podcasts, and high-production video essays).&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Peak 2:&lt;/strong&gt; 2.0x to 3.0x (Tutorials, tech reviews, and news).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is almost no middle ground on YouTube. You are either consuming it as art/entertainment, or you are consuming it as pure information extraction. &lt;/p&gt;

&lt;p&gt;This taught me a hard lesson about context-aware UI. Initially, StreamEnhancer used a single, unified speed-control overlay for all platforms. But the data screamed that a YouTube user wants quick, aggressive jumps (tapping a hotkey to instantly jump from 1.0x to 2.0x), while a Netflix user wants fine-grained control (scrolling a wheel to dial in exactly 1.08x). &lt;/p&gt;

&lt;p&gt;We ended up splitting the default hotkey mappings based on the domain. On YouTube, the &lt;code&gt;+&lt;/code&gt; and &lt;code&gt;-&lt;/code&gt; keys jump in 0.5x increments. On Netflix/Max, they jump in 0.05x increments. &lt;/p&gt;

&lt;h2&gt;
  
  
  The 14-Second Rule and the "Subtitle Drop"
&lt;/h2&gt;

&lt;p&gt;The last piece of data that completely caught me off guard was &lt;em&gt;when&lt;/em&gt; people change the speed. &lt;/p&gt;

&lt;p&gt;I assumed people would settle in, watch for a few minutes, realize the pacing was too slow, and then reach for the extension. &lt;/p&gt;

&lt;p&gt;Wrong. &lt;strong&gt;78% of all speed adjustments happen in the first 14 seconds of a video.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;People know exactly what they want before the intro sequence even finishes. If the StreamEnhancer UI takes more than a second to inject into the DOM and become interactive, they rage-click. This forced us to heavily optimize the content script injection. We moved away from waiting for the &lt;code&gt;DOMContentLoaded&lt;/code&gt; event and started injecting the speed-controller UI via a &lt;code&gt;MutationObserver&lt;/code&gt; that triggers the millisecond the specific platform's video player container hits the DOM.&lt;/p&gt;

&lt;p&gt;But there was a secondary, weirder spike in the "when" data. &lt;/p&gt;

&lt;p&gt;On Netflix and Disney+, there was a massive cluster of speed adjustments happening around the &lt;strong&gt;12-minute to 15-minute mark&lt;/strong&gt;, and almost all of them were &lt;em&gt;downward&lt;/em&gt; adjustments, dropping the speed to 0.85x or 0.9x. &lt;/p&gt;

&lt;p&gt;I couldn't figure it out until I cross-referenced it with the language settings in the anonymized telemetry payloads. &lt;/p&gt;

&lt;p&gt;Subtitles. &lt;/p&gt;

&lt;p&gt;People watching foreign-language shows, or even just complex sci-fi/fantasy shows with heavy exposition (like &lt;em&gt;House of the Dragon&lt;/em&gt; or &lt;em&gt;Shōgun&lt;/em&gt;), let the first 10 minutes play at 1.0x. But around the 12-minute mark, when the plot thickens and the dialogue gets dense, they drop the speed to 0.9x so they have an extra fraction of a second to read the subtitles without pausing the video. &lt;/p&gt;

&lt;p&gt;It’s such a specific, human behavior. They aren't pausing because pausing breaks the immersion and ruins the audio swell. They just slow the world down by 10% so their eyes can catch up to their ears.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building for the Margins
&lt;/h2&gt;

&lt;p&gt;When you build a tool like StreamEnhancer, it’s easy to get caught up in the extremes. You want to support 0.1x so people can analyze golf swings, and 16x so people can skim 4-hour podcasts. &lt;/p&gt;

&lt;p&gt;But the telemetry doesn't lie. The vast majority of your users are living in the margins. They want 1.08x to get to bed 4 minutes earlier. They want 0.9x to read a subtitle. They want to make the adjustment in the first 14 seconds and never touch the UI again.&lt;/p&gt;

&lt;p&gt;Looking at the numbers didn't just change my feature roadmap; it completely shifted my philosophy on what a "power user" actually is. A power user isn't the person watching a video at 4x speed. A power user is the person who knows exactly how to manipulate the media to perfectly match their cognitive load in that exact moment. &lt;/p&gt;

&lt;p&gt;And honestly? That's a lot more interesting to build for.&lt;/p&gt;

</description>
      <category>netflix</category>
      <category>youtube</category>
      <category>disney</category>
      <category>streaming</category>
    </item>
    <item>
      <title>The 412% Illusion: What 50,000 hours of playback data taught me about "speed learning"</title>
      <dc:creator>Hui</dc:creator>
      <pubDate>Thu, 09 Jul 2026 10:45:44 +0000</pubDate>
      <link>https://dev.to/claytonchow/the-412-illusion-what-50000-hours-of-playback-data-taught-me-about-speed-learning-4n7m</link>
      <guid>https://dev.to/claytonchow/the-412-illusion-what-50000-hours-of-playback-data-taught-me-about-speed-learning-4n7m</guid>
      <description>&lt;p&gt;412%. &lt;/p&gt;

&lt;p&gt;That was the number sitting in my weekly aggregate query results on a random Tuesday morning while I was eating a bagel. I actually stopped chewing for a second. &lt;/p&gt;

&lt;p&gt;For context, CourseSpeed is the browser extension I built to give users granular playback speed control and learning analytics across Udemy, Coursera, LinkedIn Learning, and Skillshare. I originally built it because I was tired of the clunky native speed controls on these platforms. I assumed my users were just like me: impatient developers trying to blast through a 40-hour React masterclass in a weekend.&lt;/p&gt;

&lt;p&gt;But when you build an analytics layer on top of a video player, you stop guessing how people learn and start seeing how they &lt;em&gt;actually&lt;/em&gt; learn. &lt;/p&gt;

&lt;p&gt;And the data completely broke my mental model of "speed learning."&lt;/p&gt;

&lt;h2&gt;
  
  
  The Micro-Rewind Tax
&lt;/h2&gt;

&lt;p&gt;Let's talk about that 412%. &lt;/p&gt;

&lt;p&gt;I was looking at the delta in user behavior when they switched their default playback speed from 1.25x to 2.0x. I expected the total time spent on the platform to drop by roughly 37% (the mathematical difference between the two speeds). &lt;/p&gt;

&lt;p&gt;Instead, the "effective watch time" only dropped by about 8%. Why? Because micro-rewinds spiked by 412%.&lt;/p&gt;

&lt;p&gt;A micro-rewind is when a user jumps back 5 to 15 seconds in the video. At 1.25x, people mostly just let the video play. At 2.0x, the cognitive load gets too high, they miss a crucial sentence, and they aggressively mash the left arrow key. &lt;/p&gt;

&lt;p&gt;They weren't saving time. They were just buffering their own brains.&lt;/p&gt;

&lt;p&gt;To track this without being creepy, CourseSpeed doesn't record &lt;em&gt;what&lt;/em&gt; you're watching. No video titles, no URLs, no user IDs tied to specific courses. We just listen to the HTML5 &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; element's DOM events and batch the telemetry. &lt;/p&gt;

&lt;p&gt;Here is the stripped-down logic we use in the content script to calculate the "rewind tax":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// content-script.js (Manifest V3)&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;lastTimestamp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;microRewindCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;totalRewindSeconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;videoElement&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;videoElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timeupdate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;lastTimestamp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;videoElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTime&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;videoElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;seeked&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;currentTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;videoElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTime&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lastTimestamp&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;currentTime&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Define a micro-rewind as jumping back between 3 and 20 seconds&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;microRewindCount&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;totalRewindSeconds&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Batch and send to our analytics endpoint&lt;/span&gt;
  &lt;span class="nf"&gt;flushTelemetry&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;seek_behavior&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;playback_rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;videoElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;playbackRate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;micro_rewinds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;microRewindCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;time_lost_to_rewinds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;totalRewindSeconds&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When I saw the 412% spike, I realized my UI was actually encouraging bad learning habits. I was making it &lt;em&gt;too easy&lt;/em&gt; to hit 2.0x. &lt;/p&gt;

&lt;p&gt;I ended up redesigning the speed selector. Instead of a linear slider, I added a subtle visual "friction" indicator. If you push past 1.5x, the UI gently shows your historical "rewind ratio" for that speed. It’s just a tiny text hint: &lt;em&gt;"You usually rewind 4x more at this speed."&lt;/em&gt; &lt;/p&gt;

&lt;p&gt;Surprisingly, users loved it. It made them more intentional.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Platform Personality Split
&lt;/h2&gt;

&lt;p&gt;The second thing that caught me off guard was how wildly the data varied depending on the hostname. I initially thought "a video is a video," but the platform dictates the pedagogy, which dictates the playback behavior.&lt;/p&gt;

&lt;p&gt;I ran a clustering analysis on the average playback rates across the four major platforms we support. The results were stark.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Udemy&lt;/strong&gt; users are speed demons. The median playback rate is 1.65x. But their session lengths are short. They treat courses like a checklist. Speed up, get the concept, mark as complete, move on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Coursera and edX&lt;/strong&gt; users hover around 1.15x. They rarely touch the speed controls once they set them. The content is usually more academic and dense, so they treat it like a university lecture. &lt;/p&gt;

&lt;p&gt;But &lt;strong&gt;LinkedIn Learning&lt;/strong&gt; was the weirdest. The data showed a massive bimodal distribution. Users were either watching at exactly 1.0x or exactly 2.0x. Almost nobody sat in the 1.25x to 1.75x middle ground. &lt;/p&gt;

&lt;p&gt;Digging into the session logs, I figured out why. LinkedIn Learning videos are heavily segmented into 2-to-3 minute micro-chapters. At 1.0x, it's a quick coffee-break watch. At 2.0x, it's a 60-second summary. The middle speeds just feel awkward for content that short.&lt;/p&gt;

&lt;p&gt;This completely changed how I shipped the latest update. Instead of a global default speed, CourseSpeed now uses platform-aware presets. If you're on LinkedIn Learning, the speed toggle snaps to 1.0x and 2.0x. If you're on Coursera, it gives you fine-grained 0.05x increments between 1.0x and 1.5x. &lt;/p&gt;

&lt;p&gt;Matching the tool to the platform's native content rhythm increased our daily active user retention by 18% in a single month.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "Pause-to-Completion" Ratio
&lt;/h2&gt;

&lt;p&gt;This is the metric I’m actually most proud of, even though it’s the least "hacky" one.&lt;/p&gt;

&lt;p&gt;Everyone obsesses over completion rates in ed-tech. Course creators want to know why students drop off. I wanted to know if my extension was helping or hurting. &lt;/p&gt;

&lt;p&gt;I created a metric called the &lt;strong&gt;Pause-to-Completion Ratio&lt;/strong&gt;. I looked at users who successfully finished a course (reached the final video) versus those who abandoned it (no activity for 30+ days).&lt;/p&gt;

&lt;p&gt;The speed they watched at didn't matter nearly as much as I thought. You can finish a course at 2.0x just as easily as 1.0x. &lt;/p&gt;

&lt;p&gt;What mattered was the pauses. &lt;/p&gt;

&lt;p&gt;Users who paused the video for &amp;gt;30 seconds at least once every 15 minutes had an &lt;strong&gt;88% course completion rate&lt;/strong&gt;. &lt;br&gt;
Users who blasted through with zero long pauses had a &lt;strong&gt;14% completion rate&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The people who were actually learning were stopping to take notes, write code, or just stare at the wall and process what they just heard. The people who were just "consuming" were treating it like Netflix. And just like a Netflix binge, they eventually got bored and stopped.&lt;/p&gt;

&lt;p&gt;I didn't want to just observe this; I wanted to act on it. &lt;/p&gt;

&lt;p&gt;I introduced a "Focus Mode" in CourseSpeed. If the telemetry shows you've been playing a video at &amp;gt;1.5x for 45 minutes without a single pause longer than 10 seconds, the extension gently fades in a non-intrusive overlay: &lt;em&gt;"You've been going fast for a while. Good time to pause and review?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It doesn't force a pause. It just breaks the trance. &lt;/p&gt;

&lt;h2&gt;
  
  
  Data over assumptions
&lt;/h2&gt;

&lt;p&gt;Building CourseSpeed started as a scratch-my-own-itch project. I just wanted to watch tutorials faster. But building the analytics layer turned it into a mirror reflecting how human attention actually works.&lt;/p&gt;

&lt;p&gt;We think we want to consume information as fast as possible. But the data shows that our brains still need time to compile the code, so to speak. &lt;/p&gt;

&lt;p&gt;If you're building any kind of tool that interacts with user habits, don't just build the feature you think they want. Build the telemetry to see what they actually do. The numbers will humble you, but they'll also point you toward a much better product.&lt;/p&gt;

</description>
      <category>onlinelearning</category>
      <category>udemy</category>
      <category>coursera</category>
      <category>studyfaster</category>
    </item>
    <item>
      <title>The unglamorous reality of maintaining a streaming extension as a solo dev</title>
      <dc:creator>Hui</dc:creator>
      <pubDate>Thu, 09 Jul 2026 06:47:31 +0000</pubDate>
      <link>https://dev.to/claytonchow/the-unglamorous-reality-of-maintaining-a-streaming-extension-as-a-solo-dev-4k2f</link>
      <guid>https://dev.to/claytonchow/the-unglamorous-reality-of-maintaining-a-streaming-extension-as-a-solo-dev-4k2f</guid>
      <description>&lt;p&gt;Tuesday, 8:15 AM. I was waiting for the kettle to boil when the email notification popped up on my phone. &lt;/p&gt;

&lt;p&gt;&lt;em&gt;Chrome Web Store Developer Support: Action Required for StreamEnhancer.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;My stomach did that familiar little drop. It wasn’t a malware flag, and it wasn’t a copyright strike. It was a policy rejection under the "Interfering with third-party website functionality" clause. &lt;/p&gt;

&lt;p&gt;Building StreamEnhancer—the extension that lets you fine-tune playback speed on YouTube, Netflix, Disney+, Prime, and Max—was the fun part. Figuring out how to inject UI overlays into wildly different video players and keeping the audio pitch intact at 2.5x speed? That was a great puzzle. &lt;/p&gt;

&lt;p&gt;But nobody warns you about the administrative and emotional overhead of keeping a media-manipulation extension alive in the wild. &lt;/p&gt;

&lt;p&gt;Here is what actually takes up my time these days.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CWS Review Tug-of-War
&lt;/h2&gt;

&lt;p&gt;When you build an extension that touches major streaming platforms, you are automatically flagged for deeper manual review by the Chrome Web Store team. And honestly? I get it. The web is full of malicious extensions trying to intercept video streams or inject ads into Netflix. &lt;/p&gt;

&lt;p&gt;But StreamEnhancer doesn't touch DRM. It doesn't decrypt Widevine. It literally just finds the HTML5 &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; element and tweaks the &lt;code&gt;playbackRate&lt;/code&gt; property. &lt;/p&gt;

&lt;p&gt;Explaining this to a reviewer who is processing hundreds of extensions a day is a diplomatic exercise, not a technical one. &lt;/p&gt;

&lt;p&gt;When I got that Tuesday morning rejection, the reviewer cited a policy about modifying the core experience of a third-party service. I couldn't just reply with "but it's just the standard HTML5 API." I had to build a case.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What actually works with CWS reviewers:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A short, unlisted YouTube video (or Loom) showing the extension in action.&lt;/li&gt;
&lt;li&gt;A screen recording of the Chrome DevTools Network tab, proving no encrypted media streams are being intercepted or modified.&lt;/li&gt;
&lt;li&gt;A written breakdown mapping your extension's features directly to standard, documented browser APIs.&lt;/li&gt;
&lt;/ol&gt;
&lt;/blockquote&gt;

&lt;p&gt;I spent two hours recording a video, pausing to explicitly point out that the video source URLs were untouched, and writing a polite, overly detailed explanation of how &lt;code&gt;HTMLMediaElement.playbackRate&lt;/code&gt; is a native browser feature, not a hack. &lt;/p&gt;

&lt;p&gt;It took four days to get approved. Four days where my existing users were stuck on the old version, and new users couldn't install it. You just have to sit with that anxiety and keep checking your email.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "Is Netflix Broken or Is It You?" Support Queue
&lt;/h2&gt;

&lt;p&gt;The other massive time sink is user support. And with streaming extensions, support is deeply psychological.&lt;/p&gt;

&lt;p&gt;Streaming platforms update their DOM structures and player wrappers constantly. Disney+ is particularly notorious for tweaking their web player on random Thursday nights. When they change the class names on their player controls, my injected speed buttons might misalign or disappear entirely.&lt;/p&gt;

&lt;p&gt;When that happens, my inbox fills up. &lt;/p&gt;

&lt;p&gt;But here’s the thing: users don't email me saying, "Hey, the DOM structure on the Disney+ web player changed, causing your overlay to fail." &lt;/p&gt;

&lt;p&gt;They email me saying:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Did Netflix ban my account?"&lt;/li&gt;
&lt;li&gt;"Why is my TV freezing?"&lt;/li&gt;
&lt;li&gt;"Your extension broke my streaming, I want a refund."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When you're a solo dev, you aren't just fixing code. You are de-escalating panic. People pay $15 to $25 a month for these streaming services. They feel entitled to a flawless experience. If my $3 extension accidentally covers up the "Skip Intro" button because of a z-index miscalculation, they are going to be annoyed.&lt;/p&gt;

&lt;p&gt;I had to learn to separate my ego from their frustration. &lt;/p&gt;

&lt;p&gt;Early on, I would get defensive. I’d write back long explanations about how Netflix changed their code, not me. It didn't help. It just made them angrier.&lt;/p&gt;

&lt;p&gt;Now, my support process is entirely about speed and validation. &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Acknowledge immediately.&lt;/strong&gt; "You're right, the player updated and the controls are hidden. I'm working on the fix right now."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provide a temporary workaround.&lt;/strong&gt; "In the meantime, you can use the keyboard shortcuts (Shift + &amp;gt;) to change speed without the UI."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ship the fix and follow up.&lt;/strong&gt; &lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I also set up a very specific FAQ page. The very first question is: &lt;em&gt;"Is my streaming service broken, or is it StreamEnhancer?"&lt;/em&gt; I included a quick guide on how to disable the extension to test if the native player works. It cut my "my TV is broken" support tickets by about 60%.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reality of the solo dev maintenance loop
&lt;/h2&gt;

&lt;p&gt;Look, building the thing is maybe 20% of the job. &lt;/p&gt;

&lt;p&gt;The other 80% is monitoring the Chrome Web Store policy updates, keeping an eye on streaming platform changelogs (or just waiting for users to tell me they changed something), and managing the expectations of people who just want to watch &lt;em&gt;The Bear&lt;/em&gt; at 1.25x speed without the audio sounding like chipmunks.&lt;/p&gt;

&lt;p&gt;It’s unglamorous. It’s a lot of email writing and policy reading. But when I get a random five-star review from a med student who says StreamEnhancer saved them hundreds of hours of lecture watching? &lt;/p&gt;

&lt;p&gt;Yeah. I'll happily deal with the CWS review queue for that.&lt;/p&gt;

</description>
      <category>netflix</category>
      <category>youtube</category>
      <category>disney</category>
      <category>streaming</category>
    </item>
    <item>
      <title>Stop relying on `timeupdate`: Building battery-efficient video analytics in the browser</title>
      <dc:creator>Hui</dc:creator>
      <pubDate>Thu, 09 Jul 2026 06:45:20 +0000</pubDate>
      <link>https://dev.to/claytonchow/stop-relying-on-timeupdate-building-battery-efficient-video-analytics-in-the-browser-j07</link>
      <guid>https://dev.to/claytonchow/stop-relying-on-timeupdate-building-battery-efficient-video-analytics-in-the-browser-j07</guid>
      <description>&lt;p&gt;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. &lt;/p&gt;

&lt;p&gt;When I checked my dashboard later, the platform told me I was 85% done with the course. &lt;/p&gt;

&lt;p&gt;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. &lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;timeupdate&lt;/code&gt; event. &lt;/p&gt;

&lt;p&gt;Here is what you should be doing instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;timeupdate&lt;/code&gt; trap
&lt;/h2&gt;

&lt;p&gt;If you open the dev tools on almost any video player and attach a listener to &lt;code&gt;timeupdate&lt;/code&gt;, you'll see it firing constantly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The old, bad way&lt;/span&gt;
&lt;span class="nx"&gt;videoElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timeupdate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;logAnalytics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTime&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem? The HTML5 spec doesn't dictate exactly how often &lt;code&gt;timeupdate&lt;/code&gt; 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. &lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enter &lt;code&gt;requestVideoFrameCallback&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Chrome 83 introduced &lt;code&gt;requestVideoFrameCallback&lt;/code&gt; (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.&lt;/p&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;trackFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// metadata.mediaTime gives you the exact presentation timestamp&lt;/span&gt;
    &lt;span class="c1"&gt;// metadata.presentedFrames tells you how many frames have been shown&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;currentTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mediaTime&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Do your analytics math here&lt;/span&gt;
    &lt;span class="nf"&gt;calculateEngagement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentTime&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Queue the next frame&lt;/span&gt;
    &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestVideoFrameCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trackFrame&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Kick it off&lt;/span&gt;
&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestVideoFrameCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trackFrame&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why is this better? &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It's battery efficient.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You get metadata.&lt;/strong&gt; The callback passes a &lt;code&gt;metadata&lt;/code&gt; object that includes &lt;code&gt;mediaTime&lt;/code&gt;, &lt;code&gt;presentedFrames&lt;/code&gt;, and &lt;code&gt;expectedDisplayTime&lt;/code&gt;. This lets you detect if the video is actually dropping frames or buffering, which is incredible for diagnosing playback quality issues.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When I migrated CourseSpeed's core tracking loop from &lt;code&gt;timeupdate&lt;/code&gt; to &lt;code&gt;requestVideoFrameCallback&lt;/code&gt;, 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."&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding the video tag in the wild
&lt;/h2&gt;

&lt;p&gt;Of course, getting the analytics loop right is only half the battle. The other half is actually finding the &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; element. &lt;/p&gt;

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

&lt;p&gt;If you're writing a content script, &lt;code&gt;document.querySelector('video')&lt;/code&gt; is going to fail you 90% of the time. &lt;/p&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;findVideoElements&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Check current root&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;videos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;videos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="c1"&gt;// Pierce open shadow DOMs&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;shadowHosts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;shadowHosts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shadowRoot&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;findVideoElements&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shadowRoot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="c1"&gt;// Pierce same-origin iframes&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;iframes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;iframe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;iframes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;iframe&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// This will throw if cross-origin, which is fine, we just catch it&lt;/span&gt;
            &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;iframeDoc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;iframe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;contentDocument&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;iframe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;contentWindow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="nf"&gt;findVideoElements&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;iframeDoc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// Cross-origin iframe. &lt;/span&gt;
            &lt;span class="c1"&gt;// If the platform uses postMessage for player control, &lt;/span&gt;
            &lt;span class="c1"&gt;// you have to hook into that instead.&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h2&gt;
  
  
  A quick note on Firefox
&lt;/h2&gt;

&lt;p&gt;Since Firefox doesn't support &lt;code&gt;requestVideoFrameCallback&lt;/code&gt; yet, you need a fallback. Don't just revert to &lt;code&gt;timeupdate&lt;/code&gt; blindly. &lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;requestAnimationFrame&lt;/code&gt; combined with checking &lt;code&gt;video.currentTime&lt;/code&gt;. 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 &lt;code&gt;timeupdate&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fallbackLoop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paused&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;calculateEngagement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTime&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fallbackLoop&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;requestVideoFrameCallback&lt;/code&gt; instead of high-level DOM events, you anchor your code to the rendering engine itself. &lt;/p&gt;

&lt;p&gt;It makes your analytics smoother, your extension lighter, and your Sunday morning coffee breaks a lot less frustrating.&lt;/p&gt;

</description>
      <category>onlinelearning</category>
      <category>udemy</category>
      <category>coursera</category>
      <category>studyfaster</category>
    </item>
    <item>
      <title>4.2 million hours of video later: What StreamEnhancer telemetry taught me about human impatience</title>
      <dc:creator>Hui</dc:creator>
      <pubDate>Thu, 09 Jul 2026 06:32:15 +0000</pubDate>
      <link>https://dev.to/claytonchow/42-million-hours-of-video-later-what-streamenhancer-telemetry-taught-me-about-human-impatience-38in</link>
      <guid>https://dev.to/claytonchow/42-million-hours-of-video-later-what-streamenhancer-telemetry-taught-me-about-human-impatience-38in</guid>
      <description>&lt;p&gt;1.14x. &lt;/p&gt;

&lt;p&gt;That was the exact median playback speed across all StreamEnhancer users last month. &lt;/p&gt;

&lt;p&gt;I was sitting in a coffee shop on a Tuesday morning, running the monthly aggregation script against our anonymized telemetry database. I honestly expected the number to be higher. If you spend any time on tech Twitter or Reddit, you’d think everyone is watching tutorials at 2.5x speed. The internet loves to brag about media speedrunning. "I watch lectures at 3x, I'm basically a cyborg."&lt;/p&gt;

&lt;p&gt;But the aggregate data told a completely different story. The reality of how people actually consume media is a lot more nuanced, and honestly, a lot more interesting than the hustle-culture speedrun narrative suggests. &lt;/p&gt;

&lt;p&gt;When you build a tool like StreamEnhancer, you start with assumptions. I assumed people just wanted to go fast. The data proved me wrong. People don't just want to go fast; they want to go &lt;em&gt;exactly as fast as their brain can process without feeling stressed&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Here is what 4.2 million hours of streaming telemetry actually looks like.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Platform Personality Split
&lt;/h3&gt;

&lt;p&gt;The 1.14x median is basically a useless number on its own because it mashes together completely different behaviors. When I broke the data down by platform, the variance was wild. It turns out, the platform dictates the psychology.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Netflix and Max users are "Set and Forgetters."&lt;/strong&gt;&lt;br&gt;
The median speed on Netflix is 1.25x. But more importantly, the average number of speed changes per session is just &lt;strong&gt;0.8&lt;/strong&gt;. People find a comfortable cruising speed for a narrative show, set it, and leave it alone. They are optimizing for a steady state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;YouTube users are "Active Pilots."&lt;/strong&gt;&lt;br&gt;
The median speed on YouTube is actually lower (1.10x), but the average number of speed changes per video is &lt;strong&gt;4.6&lt;/strong&gt;. &lt;/p&gt;

&lt;p&gt;YouTube is a chaotic mix of content. A single 20-minute video might have a slow-talking intro, a dense technical explanation, a 60-second sponsor read, and a rapid-fire conclusion. Users are constantly riding the speed slider like a clutch pedal. They drop to 1.0x when the creator is showing complex code on screen, bump it to 2.0x for the sponsor pitch, and settle at 1.25x for the talking-head segments. &lt;/p&gt;

&lt;p&gt;I thought I was building a tool for speedrunners. I actually built a pacing tool for people trying to manage their cognitive load.&lt;/p&gt;
&lt;h3&gt;
  
  
  The 0.05x Obsession
&lt;/h3&gt;

&lt;p&gt;This was the biggest surprise in the dataset, and it completely changed my product roadmap.&lt;/p&gt;

&lt;p&gt;When I first shipped StreamEnhancer, the speed controls jumped in 0.25x increments. 1.0 → 1.25 → 1.5. Standard stuff. That's how the native HTML5 &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; element usually handles it if you just hook into &lt;code&gt;playbackRate&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;In version 2.4, I added fine-grained control, allowing 0.05x steps. I figured maybe 10% of power users would care. &lt;/p&gt;

&lt;p&gt;Instead, &lt;strong&gt;71% of all speed changes became micro-adjustments.&lt;/strong&gt; &lt;/p&gt;

&lt;p&gt;People weren't jumping from 1.0 to 1.5. They were nudging. &lt;br&gt;
&lt;code&gt;1.10 → 1.15 → 1.20 → 1.15.&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;They were hunting for the exact threshold where the video felt fast, but the audio didn't start sounding artificial. Even with the &lt;code&gt;preservesPitch&lt;/code&gt; property updates in recent Chrome versions (which does a great job preventing the "chipmunk" effect), pushing past 1.3x on certain audio tracks introduces weird phasing artifacts. &lt;/p&gt;

&lt;p&gt;Users are highly sensitive to this. They will spend 15 seconds nudging the speed up and down by 0.05x just to find the "sweet spot" for a specific video's audio mix. &lt;/p&gt;

&lt;p&gt;Here’s what a typical telemetry payload looks like for a YouTube session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"speed_change"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"platform"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"youtube"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"session_id_hash"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"a8f9c2e..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"previous_rate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1.10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"new_rate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1.15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"delta"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"session_total_changes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"time_in_video_sec"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;342&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because of this data, I completely ripped out the "preset speed" buttons in the UI overlay. Presets are useless to an Active Pilot. I replaced them with a drag-slider that has subtle haptic-style snapping at 1.0x and 1.25x, but allows free-floating micro-adjustments everywhere else. Usage satisfaction scores jumped 22% in the next update.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Rewind Paradox
&lt;/h3&gt;

&lt;p&gt;Here's a weird correlation I found while digging through the event logs. &lt;/p&gt;

&lt;p&gt;I track (anonymously) when the playback speed is increased, and when the user triggers a "rewind 10 seconds" event via our custom hotkeys. &lt;/p&gt;

&lt;p&gt;If a user is watching at 1.5x or higher, the likelihood of them hitting "rewind 10s" within the next 30 seconds is &lt;strong&gt;340% higher&lt;/strong&gt; than if they are watching at 1.0x.&lt;/p&gt;

&lt;p&gt;Obvious, right? You go too fast, you miss a detail, you rewind. &lt;/p&gt;

&lt;p&gt;But here's the kicker that made me stop and think: &lt;strong&gt;they rarely drop the speed back down after rewinding.&lt;/strong&gt; &lt;/p&gt;

&lt;p&gt;They miss a concept at 1.5x. They rewind 10 seconds. They watch it &lt;em&gt;again&lt;/em&gt; at 1.5x. If they miss it again, they rewind &lt;em&gt;again&lt;/em&gt; at 1.5x. &lt;/p&gt;

&lt;p&gt;Human attention is a funny thing. We would rather rewind three times at 1.5x speed (taking 20 seconds total) than just watch it once at 1.0x speed (taking 10 seconds). The math doesn't make sense for actual time saved, but it makes perfect sense psychologically. &lt;/p&gt;

&lt;p&gt;The &lt;em&gt;perception&lt;/em&gt; of moving fast matters more than the actual time saved. Dropping the speed to 1.0x feels like a penalty. It feels like you're "losing" the speedrun. Rewinding at 1.5x feels like you're just buffering your brain while maintaining your momentum.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the numbers actually mean
&lt;/h3&gt;

&lt;p&gt;Looking at analytics for a browser extension is different from looking at SaaS dashboard metrics. You aren't tracking churn or MRR. You're tracking human impatience, attention spans, and the subtle ways people try to hack their own biology to consume more information.&lt;/p&gt;

&lt;p&gt;The data from StreamEnhancer taught me that when you build consumer tools, you shouldn't build for the extreme edge cases (the 3.0x speedrunners). You should build for the messy, nuanced middle. The people who just want to watch a Netflix show at 1.25x so they can go to sleep 12 minutes earlier, or the developer who wants to nudge a tutorial to 1.15x so it matches their reading speed.&lt;/p&gt;

&lt;p&gt;Next time you watch a video and feel the urge to speed it up, pay attention to the exact number you choose. You're probably not just trying to save time. You're just trying to sync the internet to your own internal clock.&lt;/p&gt;

</description>
      <category>netflix</category>
      <category>youtube</category>
      <category>disney</category>
      <category>streaming</category>
    </item>
    <item>
      <title>Bypassing the 2x speed limit on stubborn video players (and why `playbackRate` lies to you)</title>
      <dc:creator>Hui</dc:creator>
      <pubDate>Thu, 09 Jul 2026 06:29:40 +0000</pubDate>
      <link>https://dev.to/claytonchow/bypassing-the-2x-speed-limit-on-stubborn-video-players-and-why-playbackrate-lies-to-you-2b4o</link>
      <guid>https://dev.to/claytonchow/bypassing-the-2x-speed-limit-on-stubborn-video-players-and-why-playbackrate-lies-to-you-2b4o</guid>
      <description>&lt;p&gt;I was riding the train back from a conference last month, trying to finish a Coursera module on my laptop. The instructor was speaking at roughly the speed of a melting glacier. I hit the 2x button in the custom UI. It wasn't enough. &lt;/p&gt;

&lt;p&gt;I opened dev tools, typed &lt;code&gt;document.querySelector('video').playbackRate = 3.5&lt;/code&gt;, and watched the playback instantly snap back to 2.0. &lt;/p&gt;

&lt;p&gt;If you've ever written a browser extension, a userscript, or just tried to hack your way through a mandatory compliance training video, you've probably hit this exact wall. The HTML5 video API makes speed control look trivial. But modern learning platforms don't just use raw &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; tags. They wrap them in heavy state managers. &lt;/p&gt;

&lt;p&gt;Here is what's actually happening under the hood, and the DOM trick you need to bypass it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Synthetic Event Trap
&lt;/h2&gt;

&lt;p&gt;Platforms like Udemy, LinkedIn Learning, and Coursera usually build their players in React or Vue. When you manually set &lt;code&gt;video.playbackRate = 3.5&lt;/code&gt; in the console, the browser does its job and fires a &lt;code&gt;ratechange&lt;/code&gt; event. &lt;/p&gt;

&lt;p&gt;The framework's synthetic event system catches that event, checks its internal state, realizes &lt;code&gt;3.5&lt;/code&gt; is outside the bounds of their custom UI slider (which usually maxes out at 2.0), and forcefully clamps it back down by re-assigning the property. &lt;/p&gt;

&lt;p&gt;You aren't fighting the browser. You're fighting the framework's reconciliation loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: Prototype Descriptor Hijacking
&lt;/h2&gt;

&lt;p&gt;You can't just stop the &lt;code&gt;ratechange&lt;/code&gt; event propagation reliably without breaking the player's internal analytics or UI updates. Instead, you need to intercept the property setter itself. &lt;/p&gt;

&lt;p&gt;By overriding the property descriptor on the &lt;code&gt;HTMLMediaElement&lt;/code&gt; prototype, you can silently ignore the framework's attempts to clamp the speed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;targetSpeed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;3.5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;proto&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;HTMLMediaElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Grab the browser's native descriptor&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;originalDescriptor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getOwnPropertyDescriptor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;proto&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;playbackRate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;defineProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;proto&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;playbackRate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;originalDescriptor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;get&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// If the platform tries to clamp it to their UI max (usually &amp;lt;= 2.5), &lt;/span&gt;
        &lt;span class="c1"&gt;// and we've flagged this video for override, just ignore the set.&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mf"&gt;2.5&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__speedOverrideActive&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; 
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nx"&gt;originalDescriptor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;set&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;configurable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Apply the override to the specific video instance&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__speedOverrideActive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Now set the speed. The framework's subsequent attempts to reset it to 2.0 will fail silently.&lt;/span&gt;
&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;playbackRate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;targetSpeed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A quick heads up if you're building an extension: a lot of these platforms render their video player inside an iframe. &lt;code&gt;document.querySelector('video')&lt;/code&gt; will return null on the top-level window. You'll need to target the iframe's &lt;code&gt;contentDocument&lt;/code&gt; first, assuming it's not blocked by cross-origin restrictions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't forget the Chipmunk effect
&lt;/h2&gt;

&lt;p&gt;There's a secondary issue when you push past 2.0x or 2.5x. &lt;/p&gt;

&lt;p&gt;To save CPU cycles, some platforms explicitly disable audio pitch correction when the playback rate changes. In Chrome 130+, the standard property is &lt;code&gt;preservesPitch&lt;/code&gt;, but older WebKit/Blink implementations still rely on vendor prefixes. If your instructor suddenly sounds like an angry chipmunk at 3x speed, force the pitch preservation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;preservesPitch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webkitPreservesPitch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Safari/older Chrome&lt;/span&gt;
&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mozPreservesPitch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// Firefox&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It’s a tiny detail, but it makes the difference between a usable 3x speed and an unlistenable one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tracking the "Rewind Density"
&lt;/h2&gt;

&lt;p&gt;Getting the video to play at high speeds is only half the battle if you actually want to learn something. &lt;/p&gt;

&lt;p&gt;When I was wiring up the core logic for CourseSpeed, I realized that just forcing a 3x playback rate was useless if the user had to constantly pause or rewind. I needed to know if the speed was actually sustainable for the content being watched.&lt;/p&gt;

&lt;p&gt;I ended up implementing a metric I call "rewind density." &lt;/p&gt;

&lt;p&gt;Instead of just logging the current playback speed, the analytics hook tracks the delta between high-speed playback and rewind events (specifically, dropping the speed back to 1.0x or hitting the left-arrow key to jump back 10 seconds). &lt;/p&gt;

&lt;p&gt;If you're building your own learning tools or analytics dashboards, don't just track &lt;em&gt;average watch speed&lt;/em&gt;. Track how often the user is forced to abandon that speed. A video watched at 2.5x with zero rewinds is a great fit. A video watched at 2.5x with a rewind every 40 seconds means the content is too dense, and the user is just creating an illusion of productivity.&lt;/p&gt;

&lt;p&gt;The DOM hacks get the video to play fast. But paying attention to how the user interacts with the player is what actually makes the speed useful.&lt;/p&gt;

</description>
      <category>onlinelearning</category>
      <category>udemy</category>
      <category>coursera</category>
      <category>studyfaster</category>
    </item>
    <item>
      <title>The 1x1 Pixel That Broke My Streaming Extension</title>
      <dc:creator>Hui</dc:creator>
      <pubDate>Thu, 09 Jul 2026 04:17:02 +0000</pubDate>
      <link>https://dev.to/claytonchow/the-1x1-pixel-that-broke-my-streaming-extension-3bmk</link>
      <guid>https://dev.to/claytonchow/the-1x1-pixel-that-broke-my-streaming-extension-3bmk</guid>
      <description>&lt;p&gt;Saturday morning, 9 AM. I was at the kitchen island, making a pour-over coffee and testing StreamEnhancer on my MacBook while chopping onions for a frittata. &lt;/p&gt;

&lt;p&gt;I had the extension running, playing an episode of &lt;em&gt;The Mandalorian&lt;/em&gt; on Disney+ at 1.5x speed. Everything was smooth. The audio pitch correction was holding up, the custom UI overlay was tracking the progress bar perfectly. Feeling confident, I hit my hotkey to bump the speed to 2.0x. &lt;/p&gt;

&lt;p&gt;Fourteen minutes later, the video froze. The audio cut out with a harsh digital stutter, and a generic "Something went wrong" error replaced the player.&lt;/p&gt;

&lt;p&gt;I figured it was a memory leak. StreamEnhancer injects a fairly heavy UI overlay and listens to a lot of media events to sync the speed indicators across YouTube, Netflix, Prime, Max, and Disney+. I restarted Chrome 128, loaded the page, and tried again. &lt;/p&gt;

&lt;p&gt;Exact same thing. 14 minutes at 2.0x, then death. &lt;/p&gt;

&lt;p&gt;I dropped the speed back to 1.5x. The episode finished without a hitch. &lt;/p&gt;

&lt;h3&gt;
  
  
  The naive assumption
&lt;/h3&gt;

&lt;p&gt;My initial code for applying the playback speed was, frankly, lazy. When you're building a cross-platform tool, you want to write the least amount of platform-specific code possible.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The "please just work" approach&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;videos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;videos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;playbackRate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;targetSpeed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I assumed a streaming page would only have one active &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; element. Or at least, only one that actually mattered. YouTube behaves exactly like this. Netflix is a bit more complex with its canvas rendering for certain UI elements, but the core media tag is straightforward. &lt;/p&gt;

&lt;p&gt;But Disney+ and Max? They are a different beast.&lt;/p&gt;

&lt;p&gt;I opened DevTools, cleared the console, and just searched the DOM for &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;There were three of them.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The main player. &lt;code&gt;videoWidth&lt;/code&gt; was 1920.&lt;/li&gt;
&lt;li&gt;A hidden element used for ad-tracking and analytics beacons.&lt;/li&gt;
&lt;li&gt;A 1x1 pixel video, positioned absolutely off-screen, with no audio.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The accidental discovery
&lt;/h3&gt;

&lt;p&gt;I paused the main movie. The 1x1 pixel video kept playing. I muted the tab. It kept playing. &lt;/p&gt;

&lt;p&gt;I dug into the network tab and watched the requests. That tiny, invisible video tag was streaming a continuous, low-bitrate media chunk. It turns out, this is a DRM heartbeat. &lt;/p&gt;

&lt;p&gt;When you stream protected content, the browser uses Encrypted Media Extensions (EME) to talk to a Content Decryption Module (CDM) like Widevine or PlayReady. The platform needs to verify the session is still valid and the environment hasn't been tampered with. Instead of relying purely on standard XHR/Fetch network requests—which can be easily intercepted, mocked, or blocked by ad-blockers—some platforms tie the license renewal to the playback lifecycle of a secondary, heavily monitored media element.&lt;/p&gt;

&lt;p&gt;The heartbeat video plays silently in the background. When it hits a certain timestamp, it triggers a license renewal. &lt;/p&gt;

&lt;p&gt;Here is where my extension broke everything.&lt;/p&gt;

&lt;p&gt;When I forced &lt;code&gt;playbackRate = 2.0&lt;/code&gt; on &lt;em&gt;every&lt;/em&gt; video tag in the DOM, I sped up the DRM heartbeat. &lt;/p&gt;

&lt;p&gt;The heartbeat video reached its renewal checkpoint twice as fast. The CDM fired a license renewal request to the server. But the server-side logic expects these renewals at specific real-world intervals. When it started receiving them at 2x speed, the platform's sanity check flagged the client as anomalous. It assumed the environment was tampered with, or the license simply burned through its validity window too fast.&lt;/p&gt;

&lt;p&gt;Result: Session revoked. Black screen.&lt;/p&gt;

&lt;h3&gt;
  
  
  Isolating the primary media
&lt;/h3&gt;

&lt;p&gt;I didn't want to mess with the DRM heartbeat. I just wanted to speed up the movie. I needed a reliable way to identify the &lt;em&gt;actual&lt;/em&gt; primary video tag across five different streaming platforms, ignoring the ghosts in the DOM.&lt;/p&gt;

&lt;p&gt;Checking &lt;code&gt;videoWidth &amp;gt; 0&lt;/code&gt; wasn't enough. Ad trackers can have dimensions. Checking if the element is visible in the viewport wasn't enough either, because picture-in-picture modes and minimized players change the bounding client rect.&lt;/p&gt;

&lt;p&gt;I ended up writing a scoring function for StreamEnhancer that runs every time the URL changes or the player initializes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;findPrimaryMediaElement&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;candidates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;primary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;maxScore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="kc"&gt;Infinity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="c1"&gt;// 1. Dimension check (The most reliable filter)&lt;/span&gt;
        &lt;span class="c1"&gt;// Heartbeats and trackers are usually tiny&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videoWidth&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;800&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videoHeight&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; 
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videoWidth&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Heavily penalize the 1x1 pixels&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// 2. Rendered area check&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBoundingClientRect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;area&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;area&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; 

        &lt;span class="c1"&gt;// 3. DOM hierarchy check&lt;/span&gt;
        &lt;span class="c1"&gt;// Primary players are usually nested deep in specific player containers&lt;/span&gt;
        &lt;span class="c1"&gt;// while trackers are often injected at the root body level&lt;/span&gt;
        &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parentElement&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nx"&gt;depth&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="nx"&gt;node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parentElement&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;maxScore&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nx"&gt;maxScore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="nx"&gt;primary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It’s not perfect. Every time Netflix or Prime updates their player architecture, I have to tweak the weights. Prime Video, for instance, sometimes wraps their main video in a shadow DOM during initial load before hydrating it into the main document, which required adding a &lt;code&gt;MutationObserver&lt;/code&gt; just to catch the element when it finally surfaces.&lt;/p&gt;

&lt;p&gt;But the scoring system solved the DRM crash. By only applying &lt;code&gt;playbackRate&lt;/code&gt; to the highest-scoring element, the 1x1 pixel heartbeat continues to play at 1.0x, the server stays happy, and the user gets to watch their show at 2.5x without getting kicked out.&lt;/p&gt;

&lt;h3&gt;
  
  
  The web is a battlefield
&lt;/h3&gt;

&lt;p&gt;Before building StreamEnhancer, I thought of the DOM as a document. A tree of elements that represent what the user sees. &lt;/p&gt;

&lt;p&gt;Working across Netflix, Max, Disney+, and the others taught me that on modern streaming sites, the DOM is a battlefield. There are decoys, trackers, heartbeats, and hidden state machines all fighting for resources and verifying each other. &lt;/p&gt;

&lt;p&gt;When you build tools that interact with these platforms, you aren't just manipulating a video tag. You're stepping into a highly paranoid ecosystem. Sometimes, the most important part of your code isn't the feature you're building, but the invisible things you've learned to leave alone.&lt;/p&gt;

</description>
      <category>netflix</category>
      <category>youtube</category>
      <category>disney</category>
      <category>streaming</category>
    </item>
    <item>
      <title>I thought building a video speed controller would take a weekend. The analytics nearly broke me.</title>
      <dc:creator>Hui</dc:creator>
      <pubDate>Thu, 09 Jul 2026 03:10:27 +0000</pubDate>
      <link>https://dev.to/claytonchow/i-thought-building-a-video-speed-controller-would-take-a-weekend-the-analytics-nearly-broke-me-pb1</link>
      <guid>https://dev.to/claytonchow/i-thought-building-a-video-speed-controller-would-take-a-weekend-the-analytics-nearly-broke-me-pb1</guid>
      <description>&lt;p&gt;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. &lt;/p&gt;

&lt;p&gt;I hadn't. I was just testing the 16x speed toggle. But my analytics engine thought I was a god.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;document.querySelector('video').playbackRate = 2.5&lt;/code&gt; takes about ten lines of JavaScript. &lt;/p&gt;

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

&lt;h2&gt;
  
  
  The naive approach that burned me
&lt;/h2&gt;

&lt;p&gt;My first pass at the analytics tracker was straight out of MDN. I listened to the standard HTML5 video events.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The approach that worked perfectly in my head&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ratechange&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;sendAnalytics&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;speed_change&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;playbackRate&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timeupdate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;logWatchTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;playbackRate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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. &lt;/p&gt;

&lt;p&gt;I spent three days debugging this, tearing my hair out over console logs. &lt;/p&gt;

&lt;p&gt;Here is what I missed: modern learning platforms don't just drop a raw &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; 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. &lt;/p&gt;

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

&lt;h2&gt;
  
  
  Stop trusting the DOM events
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;I built a &lt;code&gt;VideoTracker&lt;/code&gt; class. Instead of waiting for the platform to tell me the video changed, I used a &lt;code&gt;MutationObserver&lt;/code&gt; to watch the specific player wrapper containers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;observer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MutationObserver&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;mutations&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mutation&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;mutations&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mutation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;childList&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;newVideo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mutation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newVideo&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;newVideo&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentVideo&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;attachToVideo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newVideo&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// We observe the highest stable container, not the whole document body&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;playerRoot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[data-player-root]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;observer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;playerRoot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;childList&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;subtree&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; element within milliseconds.&lt;/p&gt;

&lt;p&gt;But then I hit the background tab wall.&lt;/p&gt;

&lt;h2&gt;
  
  
  The background tab throttling trap
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;setInterval&lt;/code&gt; and &lt;code&gt;setTimeout&lt;/code&gt; in background tabs to save battery and CPU.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;setInterval&lt;/code&gt; ticking my internal clock was being put to sleep.&lt;/p&gt;

&lt;p&gt;I tried switching the internal clock to &lt;code&gt;requestAnimationFrame&lt;/code&gt;. That paused entirely in background tabs. &lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The architecture ended up looking like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The Web Worker ticks every 1000ms and posts a message to the main thread.&lt;/li&gt;
&lt;li&gt;The main thread receives the tick, reads &lt;code&gt;video.currentTime&lt;/code&gt; and &lt;code&gt;video.playbackRate&lt;/code&gt; directly from the DOM.&lt;/li&gt;
&lt;li&gt;The main thread calculates the delta and updates the local analytics state.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// worker.js&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;intervalId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onmessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;start&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;intervalId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tick&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stop&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;clearInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;intervalId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;setInterval&lt;/code&gt; in a background tab.&lt;/p&gt;

</description>
      <category>onlinelearning</category>
      <category>udemy</category>
      <category>coursera</category>
      <category>studyfaster</category>
    </item>
  </channel>
</rss>
