The Video That Only Froze in Safari

August 26, 2026 (1w ago)

This story took place in October–November 2025. Browser autoplay policies are a moving target — Safari's behavior described here was accurate then, but verify against current WebKit behavior before borrowing any of these fixes.

I work as a full-stack engineer on an AI video creation platform — users generate a video with AI, then refine it in a browser-based editor with a timeline, chapters, avatars, and voice narration. The playback engine renders each chapter's objects (text, images, video clips) on a canvas and drives them off a shared clock.

One day a bug report came in that would eat several weeks of my life, on and off:

"Videos freeze during playback. Only in Safari."

Chrome: fine. Firefox: fine. Safari: a video object would render its first frame and just... sit there. No error dialog, no crash. Sometimes it played, sometimes it froze — and it seemed to depend on where in the video the playback started.

Step 1: Reproduce it properly

"Sometimes it freezes" is not a bug report you can fix. So the first job was turning it into something deterministic.

After a lot of clicking around, the pattern emerged: videos played fine in the chapter where the user had interacted with the page, and froze after chapter transitions. If you pressed play in chapter 1, a video in chapter 1 played. A video in chapter 3? Frozen first frame.

That pattern is a gift, because it points away from "video decoding bug" and toward lifecycle.

Step 2: Hypotheses

I had three:

  1. A decoding/codec issue — Safari is notoriously picky about codecs. But the same file played fine in the first chapter, so the bytes were decodable. Rejected.
  2. A race in our playback clock — maybe we called play() before the element had data. Plausible, but it didn't explain why only later chapters failed.
  3. Safari's autoplay policy — Safari doesn't just require "a user gesture somewhere on the page" like Chrome mostly does. It grants playback permission to specific DOM elements that were activated by a user gesture.

Hypothesis 3 turned out to be the key, and here's the mechanism:

Our editor is a React app. At every chapter transition, React unmounts the old chapter's objects and mounts the new ones. To React this is routine reconciliation. To Safari, it means the <video> element that had permission to play no longer exists. The new element is a stranger. When our clock calls videoElement.play() on it — with no user gesture in the same call stack — Safari rejects the promise, and you get exactly what users saw: a rendered first frame that never moves.

The confirmation was satisfyingly simple: log the play() promise rejection. Chrome resolved it; Safari threw NotAllowedError — but only for elements mounted after the initial gesture.

Safari grants autoplay to the specific DOM element that received the user gesture. After a chapter transition, React mounts a brand-new video element with no permission, so play() rejects with NotAllowedError and the first frame freezes.

Step 3: The fix — and the revert

If Safari blesses elements, the answer is: stop destroying the elements.

I built a VideoElementPool — a small manager that lives outside React, pre-creates a handful of <video> elements up front (while we still have the user's gesture), and hands them out to video objects as chapters mount. Instead of React creating and destroying <video> tags, a component acquires a persistent element, swaps its src, and attaches it to its container. On unmount it releases the element back to the pool.

class VideoElementPool {
  private pool: PoolItem[] = []
 
  acquire(objectId: string): HTMLVideoElement {
    // Reuse the element already assigned to this object, if any
    const existing = this.pool.find((i) => i.currentObjectId === objectId)
    if (existing) return existing.element
 
    // Otherwise hand out an idle, pre-blessed element
    const idle = this.pool.find((i) => !i.inUse)
    idle.inUse = true
    idle.currentObjectId = objectId
    this.reset(idle.element)
    return idle.element
  }
 
  release(objectId: string) { /* mark idle, detach, reset */ }
}

The VideoElementPool lives outside React and never destroys its video elements. React components acquire a pre-blessed element and swap its src on mount, and release it back to the pool on unmount.

I shipped it in late October 2025. It was reverted the same day.

The first version swapped element identity out from under the existing video component, but left the rest of that component's assumptions untouched — its refs, its event listeners, its interaction with the audio engine all still assumed they owned the element's lifecycle. Pooling the DOM node while the component around it believed otherwise broke playback in ways the Safari bug never did.

That revert taught me the real lesson of this bug: the fix wasn't "add a pool." It was change the ownership model. Two days later I landed the second version, which reworked the video component's whole lifecycle around the pool — acquisition on mount, imperative attachment to the container, coordination with the audio engine and the player — instead of bolting a pool onto code that assumed React-managed elements. That one stuck.

Step 4: The bug after the bug

A few weeks later, in late November, a new report: videos occasionally froze in Safari again. Rarer, but real.

This is the hidden tax of object pooling: pooled objects carry state between lives. A recycled <video> element kept whatever muted and loop values its previous user set. And Safari has one more rule: programmatic play() on an unmuted video without a gesture gets rejected — even on a blessed element. So if an element was released while unmuted and later reused, play() silently rejected. Frozen frame, once again.

A pooled video element released while unmuted is later acquired by another object. Calling play() without resetting state gets rejected by Safari. The fix: re-assert muted and loop before every play().

The fix was small but principled: never trust inherited state on a pooled resource — re-assert every property that gates playback, right before playing.

const video = videoRef.current
if (!video) return
 
if (trims?.length) {
  video.currentTime = trims[0].start
}
video.muted = true   // Safari rejects unmuted programmatic play()
video.loop = false   // don't inherit loop state from the element's previous life
video.play()

(Audio wasn't lost, by the way — narration and video audio run through a separate audio pipeline, which is also how you sidestep autoplay muting rules.)

While tracing the loop state I also found an unrelated one-character bug that had been hiding in the loop toggle:

isLoop: o.isLoop ? !o.isLoop : false   // 🐛 always false — the toggle could never turn ON
isLoop: !o.isLoop ? true : false       // ✅ actually toggles

That's the kind of thing you only catch when a hard bug forces you to read adjacent code with full suspicion.

Tradeoffs

The pool is not free:

Would I prefer a declarative fix? Absolutely. But Safari's permission model is attached to DOM identity, React's model destroys DOM identity, and no amount of wishing changes either. Sometimes the correct engineering decision is a well-contained ugly bridge between two systems that disagree.

Results

What I took away

  1. "Only in Safari" usually means policy, not parsing. When one browser fails silently, read its permission model before its codec support.
  2. Reproduce until the pattern names the subsystem. "Freezes after chapter transitions" pointed at element lifecycle long before any code did.
  3. A revert is data. My first fix failed because I changed a low-level mechanism without changing the ownership model above it. The second fix succeeded because the revert told me exactly that.
  4. Pooled resources lie about their state. If you recycle objects, re-assert every invariant at the boundary — the bug you prevent will be the silent kind.