A client's Shopify store had a single inline product-loop video, a few seconds long, no sound, meant to run smoothly under the hero image. On my machine it was fine. On the client's Android it was fine. Then the client opened it on an iPhone and sent me a screen recording: instead of playing on its own, the video sat still with the native play controls sitting in the middle of it, and the whole page felt heavy while scrolling. He had to tap play on the video manually every time.
My first instinct was to blame the file. I checked the codec, and it was H.264 — the most universal format there is. I figured if it was already H.264 it had to be safe on iOS. It was not that simple. Once I opened DevTools attached to the iPhone, two separate problems surfaced, and both compounded into the same single symptom.
Why iOS refuses
The first problem was in the encoding profile. My file was encoded as H.264 High profile at 1080p, around 3.37 Mbps. On desktop and Android that is a non-issue. But Safari on iOS often rejects the High profile for inline autoplay; WebKit prefers Main or Baseline. So even though the extension was .mp4 and the codec was H.264, the iOS decoder treated it as an unfit autoplay candidate and refused to play it automatically. The bitrate and the 1080p resolution were also what made scrolling feel heavy on the device.
The second problem was subtler and had to do with how autoplay was triggered. I was relying on the HTML autoplay attribute. The catch is that the attribute tries to play at parse time, before any user gesture exists. iOS blocks autoplay that arrives without a gesture context, and when that block fires, Safari silently falls back to showing the native play controls. So even if the profile had been right, the way I triggered playback was still wrong by WebKit's rules.
Once I separated those two things in my head, the symptom made sense. Android played it because it is more permissive about both the profile and autoplay. iOS refused at two points at once, then showed the controls as a fallback, making the video look broken when the file itself was fine.
The fix
I fixed it from both sides: the file first, then the way it is triggered.
For the file, I re-encoded to H.264 Main profile at 720p. That resolved the profile rejection outright and lightened the decode load that was making scrolling heavy:
ffmpeg -i input.mp4 -profile:v main -level 4.0 -crf 26 -vf scale=1280:-2 -an -movflags +faststart output.mp4-profile:v main swaps High for Main, scale=1280:-2 drops it to 720p with an automatically even height, -an strips the audio track that was never used, and -movflags +faststart moves the metadata to the front so playback can begin before the file is fully downloaded.
For the markup, I dropped the autoplay attribute and replaced it with the combination iOS actually permits for inline muted playback:
<video muted loop playsinline preload="metadata" data-lazy-video>
<source src="output.mp4" type="video/mp4" />
</video>The key here is playsinline (so iOS does not force fullscreen) plus muted and loop, with no autoplay. A muted video is treated more leniently by WebKit, but it still must not be triggered from parse time.
Playback is driven by an IntersectionObserver. Instead of playing at parse, I call video.play() once the element enters the viewport, because the act of scrolling counts as enough of a user-gesture context for iOS:
const io = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const video = entry.target;
video.muted = true;
video.play().catch(() => {
video.controls = true;
});
});
}, { threshold: 0.15, rootMargin: '0px 0px 200px 0px' });
document.querySelectorAll('[data-lazy-video]').forEach((v) => io.observe(v));I set video.muted = true again right before play() to be sure, then the .catch() handles the case where iOS still refuses. If that happens, I set video.controls = true as a graceful fallback: the user sees a play button rather than a mysteriously dead video. The 200px rootMargin gives the video a little buffering head start before it is actually visible.
The takeaway
H.264 is not a guarantee on its own; the profile matters. iOS Safari is picky and prefers Main or Baseline over High for inline autoplay, so re-encode with -profile:v main and drop to 720p to keep the decode light. And do not rely on the autoplay attribute on iOS; it fires before any gesture and is silently blocked. Trigger playback from an IntersectionObserver so the scroll context counts as a gesture, always set muted before play(), and attach a .catch() that turns on controls as a fallback. Since then, when someone says "it works on Android but not on iPhone," I no longer blame the file blindly — I check the encoding profile and how autoplay is triggered first.
