DEV Community

Build a directional preload window for a scrolling video feed with hls.js

TL;DR Vertical video feeds are fast on the first item and stutter on every one after. We'll build a preload window that follows scroll direction, attaches hls.js sources to off-screen items ahead of the user, and tears down everything outside the window so you stay under Safari's video element ceiling. Reconciler pattern, ~120 lines. The problem Item 1 plays instantly because the page loaded around it. Item 2 shows black, then a spinner, then a stutter into low quality. Preloading everything fixes the stutter and replaces it with a data-plan fire and a crash on iPhone. What we want: preload a small number of items in the direction the user is scrolling, and aggressively release everything else. 1. Why preload="auto" isn't a strategy Browsers treat preload as a suggestion and mobile browsers take it least seriously, deliberately, to protect the user's data. With HLS you get real control, because loading happens in JS. In hls.js, attaching a source is the trigger. The moment an instance has a manifest URL, it fetches the playlist and starts pulling segments, viewport or no viewport. That's our primitive. npm i hls.js@^1.7.0 2. A player wrapper with two modes ๐ŸŽ›๏ธ Off-screen items need a shallow buffer. Just enough to start. The active item gets the normal one. // feed/player.js (hls.js 1.7.x) import Hls from "hls.js"; const PRELOAD_CONFIG = { // enough to render frame one, not enough to burn a data plan maxBufferLength: 4, maxMaxBufferLength: 6, // go get fragment 1 before media is attached startFragPrefetch: true, autoStartLoad: true, }; const ACTIVE_CONFIG = { maxBufferLength: 30, // hls.js default maxMaxBufferLength: 60, startFragPrefetch: true, autoStartLoad: true, }; export function attach(videoEl, url, { active = false } = {}) { if (videoEl.canPlayType("application/vnd.apple.mpegurl")) { // Safari plays HLS natively; no hls.js instance to manage videoEl.src = url; videoEl.preload = active ? "auto" : "metadata"; return { native: true, destroy: () => { videoEl.removeAttribute("src"); videoEl.load(); } }; } const hls = new Hls(active ? ACTIVE_CONFIG : PRELOAD_CONFIG); hls.loadSource(url); hls.attachMedia(videoEl); return { native: false, hls, destroy: () => { hls.destroy(); }, }; } ๐Ÿ’ก Tip: maxBufferLength defaults to 30 in hls.js. Thirty seconds for the item being watched is sensible. Thirty seconds each for four queued items is not. 3. The directional window The naive window is symmetric: 2 before, 2 after. But scroll isn't symmetric. Someone moving down keeps moving down, and the item behind them is the one they just finished. // feed/window.js const AHEAD = 4; // items to preload in the direction of travel const BEHIND = 1; // cheap insurance for a quick scroll-back /** * @param {number} index current active item * @param {1|-1} direction 1 = scrolling down, -1 = scrolling up * @param {number} total item count * @returns {Set } indices that should have a source attached */ export function desiredWindow(index, direction, total) { const lo = direction === 1 ? index - BEHIND : index - AHEAD; const hi = direction === 1 ? index + AHEAD : index + BEHIND; const out = new Set(); for (let i = Math.max(0, lo); i { native, hls, destroy, active } let lastIndex = 0; function reconcile(index) { const direction = index >= lastIndex ? 1 : -1; lastIndex = index; const want = desiredWindow(index, direction, items.length); // 1. release everything outside the window for (const [i, handle] of attached) { if (!want.has(i)) { handle.destroy(); attached.delete(i); } } // 2. attach everything inside it that isn't already for (const i of want) { const el = getVideoEl(i); if (!el) continue; // not rendered yet, skip const existing = attached.get(i); const shouldBeActive = i === index; if (existing) { // promote the newly-active item to the deep-buffer config if (shouldBeActive && !existing.active) { existing.destroy(); attached.set(i, { ...attach(el, items[i].hlsUrl, { active: true }), active: true }); } continue; } attached.set(i, { ...attach(el, items[i].hlsUrl, { active: shouldBeActive }), active: shouldBeActive, }); } } function destroyAll() { for (const [, h] of attached) h.destroy(); attached.clear(); } return { reconcile, destroyAll, get size() { return attached.size; } }; } Drive it from whatever tells you the active index. With CSS scroll snapping, an IntersectionObserver is enough: // feed/index.js const controller = createFeedController({ items, getVideoEl: (i) => els[i] }); const io = new IntersectionObserver((entries) => { for (const e of entries) { if (e.intersectionRatio > 0.6) { controller.reconcile(Number(e.target.dataset.index)); } } }, { threshold: [0.6] }); els.forEach((el) => io.observe(el)); 5. Teardown is not optional on iOS Mobile Safari has a widely reported ceiling of around 16 simultaneous video elements; past that, additional ones render black. It's not an explicitly documented API limit, it's a wall people keep hitting. In an infinite feed that mounts a player per item and never unmounts, you hit it fast. Our window caps attachments at AHEAD + BEHIND + 1 = 6 , comfortably under it. But release the source, not the element: // keep the DOM node mounted, drop the media handle.destroy(); // hls.destroy() frees buffers + decoder // element stays in the list for cheap re-attach on scroll-back Unmounting the component too means scrolling back pays full layout cost. Keep the shell, make the media disposable. โš ๏ธ Multiple simultaneous video elements are a memory and GPU pressure problem beyond the element count. It shows up as a crash, not a catchable error, so you can't detect it and back off. You have to stay under the line by construction. 6. Measure the thing users feel ๐Ÿ“Š The metric is time from scroll settling to first frame painted, on item 2 and beyond. Item 1 is a page load and it flatters you. // feed/metrics.js const marks = new Map(); export function markScrollSettled(index) { marks.set(index, performance.now()); } export function markFirstFrame(index, videoEl) { videoEl.addEventListener("playing", () => { const t0 = marks.get(index); if (t0 == null) return; const ms = Math.round(performance.now() - t0); marks.delete(index); navigator.sendBeacon?.("/metrics/feed-ttff", JSON.stringify({ index, ms, position: index, // item 40 matters more than item 2 conn: navigator.connection?.effectiveType ?? "unknown", })); }, { once: true }); } Chart it against bytes per session. These move in opposite directions and tuning one alone ships something beautiful on wifi and hostile on a phone plan. The window size you want is the smallest AHEAD where time-to-first-frame goes flat. Start at 4, walk it down to 2, and watch where the curve bends. Past the flat point you're buying nothing and spending someone else's money. ๐Ÿ’ก Tip: if the feed is instant for ten items and degrades after that, your teardown isn't keeping up. More preloading will not fix it. What's next - hls.js doesn't yet expose a first-class "preload N seconds off-screen then stop" API. There's an open issue for it and a related multi-segment prefetch proposal. Worth watching; the per-instance maxBufferLength trick above is the practical stand-in. - Serve a lower-rendition first segment to off-screen items so the preload costs less, then let ABR climb once the item is active. - If your feed is React Native rather than web, the same window/reconciler logic applies. Mux wrote up a feed that preloads by scroll direction and the structure transfers directly. The general lesson: "make it feel instant" is a scheduling problem wearing a rendering problem's clothes. You aren't loading faster, you're loading earlier, and the craft is in guessing well and giving up fast. Top comments (0)

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.