DEV Community

Mason K
Mason K

Posted on

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

<!-- this is a hint, not an instruction -->
<video preload="auto" src="..."></video>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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(); },
  };
}
Enter fullscreen mode Exit fullscreen mode

💡 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<number>}     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 <= Math.min(total - 1, hi); i++) out.add(i);
  return out;
}
Enter fullscreen mode Exit fullscreen mode

Same bandwidth budget, roughly double the hit rate. One conditional.

4. The reconciler (this is the important part) 🔑

Here's the bug you will otherwise ship: a user flicks quickly through six items. Preload fires on scroll-in, teardown fires on scroll-out, and because they're independent effects racing each other you tear down a player you just decided to preload. Symptom: fine when you scroll slowly, broken when you scroll fast. Fine in dev, broken in the wild.

Fix: never fire preload and teardown as separate effects. Compute the desired set, diff it against what's attached, act on the diff.

// feed/reconciler.js
import { attach } from "./player.js";
import { desiredWindow } from "./window.js";

export function createFeedController({ items, getVideoEl }) {
  const attached = new Map();  // index -> { 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; } };
}
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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)