Shack Logo
Dan's Sandbox
← Back to Blog
DEBUGGING

DEBUGGING SCROLL-DRIVEN VIDEO SCRUBBING ON MOBILE & IOS

Apr 2026·8 min read

A real-world debugging journey — from choppy scrubbing to a blank video on iPhones.

While building an immersive scroll-driven section for a portfolio project, I ran into two back-to-back bugs that only surfaced on specific devices. Both were subtle, both were maddening, and both taught me a lot about how mobile browsers — especially iOS Safari — handle video.

Here's the full story.

The project: scroll-scrubbed video with GSAP

The idea was simple: as the user scrolls through a pinned section, a video scrubs forward frame-by-frame, synced to the scroll position. GSAP's ScrollTrigger drives a timeline that tweens the video's currentTime property.


My original implementation looked like this:

// naive approach — tween currentTime directly on the video element if (!videoRef.current) return; videoRef.current.onloadedmetadata = () => { revealVideoTl.to( videoRef.current, { duration: 5, currentTime: videoRef.current?.duration, }, "<", ); };

On desktop it looked incredible — silky smooth. Then I tested on an Android and iPhone.

Bug #1 — Choppy scrubbing on mobile

On mobile, the video scrubbing was visibly stuttery. Every scroll tick triggered a new seek directly on the video element, and mobile browsers simply can't process seeks as fast as desktop ones. GSAP was firing onUpdate dozens of times per second, each time telling the video to jump to a new timestamp — stacking seeks on top of each other before the previous one could finish.


Root cause: Setting video.currentTime on every GSAP tick causes seek requests to pile up. Mobile browsers queue them; desktop browsers handle the throughput with ease.


The fix: a RAF-gated seek queue


Instead of tweening currentTime on the video element directly, I introduced a proxy object that GSAP animates. The actual seek is deferred to a requestAnimationFrame callback, ensuring we only ever attempt one seek per frame. If the video is still seeking when the next frame arrives, we set a pending flag and retry once the seeked event fires.

// ✅ proxy object — GSAP animates this, not the video directly const scrubProxy = { currentTime: 0 }; let targetTime = 0; let rafId = 0; let pending = false; const applySeek = () => { rafId = 0; if (video.readyState < 2) return; // no frame data yet if (video.seeking) { pending = true; // already seeking — retry on seeked return; } if (Math.abs(video.currentTime - targetTime) < 0.033) return; video.currentTime = targetTime; }; const queueSeek = (time: number) => { targetTime = Math.max(0, Math.min(video.duration, time)); if (!rafId) rafId = requestAnimationFrame(applySeek); }; const onSeeked = () => { if (pending) { pending = false; if (!rafId) rafId = requestAnimationFrame(applySeek); } }; video.addEventListener("seeked", onSeeked); revealVideoTl.to(scrubProxy, { duration: 5, currentTime: video.duration, ease: "none", onUpdate: () => queueSeek(scrubProxy.currentTime), }, "<");

Mobile scrubbing became smooth. Bug #1: resolved.

Bug #2 — Video not showing on iOS at all

After shipping the scrubbing fix, iOS users reported seeing only the poster image — the video never appeared. Android and desktop were both fine.


The cause is a quirk specific to iOS Safari: the browser won't decode or render any video frames until play() has been called at least once, even for a muted, playsInline video being controlled entirely via currentTime. Without that initial unlock, the video element sits inert — metadata loads, but no frames are ever painted.


Root cause: iOS Safari requires an explicit play() call (triggered by a user gesture) before it will decode and render any video frames — even when you only intend to scrub via currentTime.


There was a second, related issue: I was initializing the GSAP scrub inside loadedmetadata, which only confirms that duration and dimensions are known — not that any frame data has been decoded. On iOS, this meant the scrub logic initialized before the video was actually renderable.


The two-part fix


Before: Waited for loadedmetadata, tweened currentTime directly.


After: Two targeted changes:


1. Silently call play() then immediately pause() on the first touchstart or scroll event to unlock iOS's media pipeline. 2. Wait for readyState >= 2 (HAVE_CURRENT_DATA) instead of just loadedmetadata, so frame data is guaranteed to exist before the scrub begins.

// ✅ iOS unlock — fires once on the first user interaction const unlockiOS = () => { video.play() .then(() => { video.pause(); video.currentTime = 0; }) .catch(() => {}); }; window.addEventListener("touchstart", unlockiOS, { once: true }); window.addEventListener("scroll", unlockiOS, { once: true, passive: true }); // ✅ Wait for readyState >= 2 (HAVE_CURRENT_DATA), not just loadedmetadata if (video.readyState >= 2) { onLoadedMetadata(); } else { video.addEventListener("loadedmetadata", onLoadedMetadata, { once: true }); }

After both fixes, the video rendered and scrubbed correctly on every device tested — desktop, Android, and iOS.

Key takeaways

• Never tween currentTime directly — use a proxy object and gate seeks through requestAnimationFrame. • Track a pending flag and retry on the seeked event to handle overlapping seek requests on mobile. • On iOS, a muted play() → pause() call on first user interaction is required to unlock the video pipeline for scrubbing. • readyState >= 2 is the right threshold for "video is ready to scrub" — loadedmetadata alone is not enough on iOS. • Always clean up event listeners in your useGSAP return function to avoid leaks across re-renders.

GSAPiOSScrollTriggerVideoPerformance