Five WebKit behaviors that break a TikTok-style autoplaying video feed in Safari but not Chrome: per-document activation death on MPA navigation, the ~5s transient-activation window, the silent-start auto-pause trap, rAF starvation during scroll momentum killing audio fades, and errorless policy pauses. Patterns: a blessed persistent element pool, activation-gated fades, deadline-guaranteed fades, and an expected-pause watchdog.
Building a TikTok-style looping video feed (one autoplaying card at a time, crossfading audio between cards) surfaces five WebKit behaviors that Chrome's autoplay documentation will not prepare you for. Symptoms: sound randomly 're-muting' mid-scroll, sound always lost after page navigation, the first video of a page load ending up paused instead of playing, hearing audio from a different card than the one on screen, and the video you're watching freezing by itself. Verified on Safari 18-era WebKit (macOS) and headless Chromium 130+, in an Astro 7.1 static site; the behaviors are policy-level and not version-fragile.
First: user activation is per-document and dies on every full page load. No browser carries activation across an MPA navigation. Chrome alone permits unmuted autoplay after a same-origin click-through navigation (its engagement exception), so an MPA feed keeps sound in Chrome but never in Safari. The only real fix for Safari is making navigations same-document (a client-side router; in Astro 3+, <ClientRouter /> from astro:transitions), so the persisting document keeps both its activation history and your module state, and the nav click itself provides fresh transient activation for the first play on arrival.
Second: transient activation is a short timer (~5 s in WebKit, see webkit.org/blog/13862) refreshed by pointerdown/keydown — and wheel/trackpad scrolling never refreshes it. So in a feed where scrolling triggers play() on the next card's fresh <video>, any flip more than ~5 s after the last real input rejects with NotAllowedError. This is why the failure feels intermittent: flips right after a tap or keypress succeed, later ones fail. The durable fix is WebKit's per-element grant model (webkit.org/blog/7734): once an element has been played from a real gesture, that element keeps its audible-playback right for the session. Instead of one <video> per card, route all playback through a small persistent pool (front/back pair, src-swapped and crossfaded), and bless every pooled element during a real gesture:
// Bless all pooled media elements on the first real gestures.
// play() inside the handler grants the element WebKit's per-element
// audible-playback right permanently; load() works for src-less elements.
function blessPools(): void {
for (const v of [feedFront, feedBack, tvFront, tvBack]) {
if (v.dataset.blessed === "1") continue;
if (v.src) {
if (v.paused) { v.play().catch(() => {}); v.pause(); }
else v.play().catch(() => {}); // no-op resume, still blesses
} else v.load();
v.dataset.blessed = "1";
}
}
document.addEventListener("pointerdown", blessPools, { capture: true });
document.addEventListener("keydown", blessPools, { capture: true });One real tap licenses audible playback for the rest of the session regardless of scroll distance.
Third, the silent-start trap: WebKit will happily allow an unmuted play() at volume = 0 — and then auto-pause the video the moment your fade-in makes it audible without a gesture. If you implement crossfades as 'start at volume 0, ramp to 1', a fresh page load plays for ~250 ms and freezes. Gate the fade on sticky activation:
if (unmuted && incoming) {
video.muted = false;
if (navigator.userActivation?.hasBeenActive) {
video.volume = 0; // gesture exists: fade-in is safe
fadeVolume(video, 1);
} else {
video.volume = 1; // fresh document: let play() adjudicate
} // audibility atomically — never
} // silent-start-then-fade here
video.play().catch((err: DOMException) => {
if (err.name === "NotAllowedError") {
// degrade to muted with a truthful mute icon; keep the stored pref
}
});Either sound is allowed and persists, or you get a clean NotAllowedError to catch and degrade to muted playback. Never let an inaudible start succeed only to be killed mid-fade.
Fourth: Safari starves requestAnimationFrame during trackpad scroll momentum far more aggressively than Chrome, and an audio crossfade driven purely by a rAF chain silently dies with it. A stalled fade-out leaves the outgoing element unmuted and audible while it now belongs to an off-screen card — you hear the wrong video. A stalled fade-in leaves the incoming element stuck near volume 0 — the visible video is silent. Worst of all, if pause() lives in the fade's completion callback (the natural place), a dead chain means the outgoing element never pauses at all. The fix is a deadline finalizer: every fade arms a plain setTimeout alongside the rAF chain, and whichever fires first snaps the volume to its target and runs the completion callback exactly once:
const fades = new Map<HTMLVideoElement, { raf: number; deadline: number }>();
function cancelFade(video: HTMLVideoElement): void {
const f = fades.get(video);
if (f) { cancelAnimationFrame(f.raf); clearTimeout(f.deadline); fades.delete(video); }
}
function fadeVolume(video: HTMLVideoElement, to: 0 | 1, onDone?: () => void): void {
cancelFade(video);
const finish = (): void => { cancelFade(video); video.volume = to; onDone?.(); };
const step = (now: number): void => {
// ... equal-power ramp ...
const f = fades.get(video);
if (f) f.raf = requestAnimationFrame(step);
};
fades.set(video, {
raf: requestAnimationFrame(step),
// Safari starves rAF during scroll momentum; the deadline guarantees
// the fade completes (and the pause-on-done actually pauses).
deadline: window.setTimeout(finish, FADE_MS + 50),
});
}Both paths route through cancelFade first, which removes the map entry the other path checks, so finish runs at most once. Timers are not throttled the way rAF is during momentum, so worst-case audio inconsistency is bounded at FADE_MS + 50. Corollary: because a stalled fade leg can otherwise play straight through a mute, a global mute button should hard-silence every pooled element (cancel fade, pause, mute), not just the one on screen.
Fifth: WebKit can pause a playing element mid-session on policy grounds with no error whatsoever — no exception, no rejected promise, just a pause event. One trigger: a fade-in it judges to have 'become audible' under a grant it doesn't honor (your in-gesture blessing is your bookkeeping, not a guaranteed audible grant). If nothing listens, the on-screen video sits frozen until the next scroll. Distinguish your own pauses from policy pauses with a WeakSet, and self-heal by resuming muted with a truthful mute icon:
// Mark every pause you initiate:
expectedPause.add(video); video.pause();
// Anything else pausing the active element is a policy pause:
video.addEventListener("pause", () => {
if (expectedPause.has(video)) { expectedPause.delete(video); return; }
if (video !== activeElement || document.hidden) return;
window.setTimeout(() => { // let in-flight rebinds settle
if (!video.paused) return; // raced flip: no-op
unmuted = false; renderSoundIcons();
video.muted = true; video.volume = 1;
video.play().catch(() => {});
}, 0);
});Resuming muted (never unmuted) matters: muted playback is always allowed, so a repeat policy pause is impossible and no loop guard is needed — and it can never blast unwanted audio. One tap restores sound. This mirrors the NotAllowedError degrade above: the UI stays truthful, the video keeps moving.
Two smaller notes from the same work. Detach a pooled media element from the DOM (el.remove()) before re-sourcing it for preload: an element still parented in the previous card's container repaints its poster the instant src changes, flashing the wrong poster. And 'volume-up key to unmute' cannot be built for Safari: macOS/iOS consume hardware volume keys before the page sees them, the Media Session API defines no volume actions, and HTMLMediaElement.volume is read-only on iOS.
Recommendation: treat Chrome's behavior as the exception, not the baseline. Design for WebKit's model — per-element grants, a persistent blessed pool, same-document navigation, full-volume atomic play attempts on gestureless documents with NotAllowedError as the muted-degrade signal, deadline-guaranteed fades so rAF starvation can't leave the wrong element audible, and a pause watchdog that resumes muted — and Chrome works automatically.