Most Safari-specific observer bugs are not IntersectionObserver bugs but viewport and scheduling differences — a moving toolbar, throttled frames, rubber-band overscroll and iframe restrictions — so design thresholds and margins with slack, avoid relying on exact edge timing, and test on a real iPhone.
Problem / Scenario Context
A media site's "autoplay when 50% visible" videos behave perfectly in Chrome on Android and desktop. On iPhones, some videos start while barely visible, others do not start until well past halfway, and a video near the bottom of the page toggles play and pause repeatedly when the user reaches the end of the article and the page bounces. A related embed inside a cross-origin iframe ignores its rootMargin entirely in Safari.
These are real differences in the environment the observer measures, and a few are genuine behaviour differences. The Browser Compatibility & Polyfills topic covers support; this page covers behaviour.
Mechanics Explanation
Four characteristics of Safari (especially on iOS) account for most reports:
- The viewport changes size during scroll. Safari's bottom toolbar collapses as the user scrolls down and expands when they scroll up or reach the end. The layout viewport — the implicit root — changes height, so elements near the bottom edge cross thresholds without being scrolled. A 50% threshold on an element straddling the bottom edge can flip as the toolbar moves.
- Rubber-band overscroll moves content past the edges. At the top and bottom of the page, elastic overscroll translates the content and then springs back. Intersections are recomputed during the bounce, producing enter/leave/enter sequences for elements near the ends.
- Frames may be throttled. Low Power Mode caps rendering at 30 Hz, and intersections are computed per rendering update, so crossings are detected at coarser scroll positions during fast scrolls. With a single threshold, the element might already be at 70% when the 50% crossing is reported.
rootMarginis ignored for cross-origin iframe targets. Per specification,rootMarginonly applies when the target is in the same origin-domain as the root; for the implicit root inside a cross-origin iframe, margins do not apply. All engines implement this now, but Safari was historically where teams first hit it. The iframe troubleshooting guide covers the fix.
Comparison Table: Symptoms, Causes and Fixes
| Symptom on iPhone | Likely cause | Fix |
|---|---|---|
| Video plays at 30% or pauses at 60% | single threshold + coarse frames | thresholds [0.25, 0.5, 0.75] and hysteresis |
| Play/pause flapping at page end | rubber-band bounce | require state to hold for ~150 ms |
| Elements "enter" when scrolling stops | toolbar expanding changes root | hysteresis; ignore changes under a few percent |
| Pre-load margin has no effect in embed | cross-origin iframe | root: document inside the iframe |
| Sticky header covers "visible" items | fixed UI over the viewport | negative top rootMargin |
| Pinch-zoomed page still reports visible | visual vs layout viewport | see pinch-zoom guide |
Minimal Reproducible Example
const video = document.querySelector<HTMLVideoElement>('video.autoplay')!;
new IntersectionObserver(([e]) => {
e.intersectionRatio >= 0.5 ? video.play() : video.pause();
}, { threshold: 0.5 }).observe(video);
On an iPhone, place the video near the bottom of a short article and scroll to the end: as the page bounces and the toolbar reappears, the video toggles several times.
Production-Safe Solution
Add hysteresis (different enter and leave thresholds) and a short settle period, and use intermediate thresholds so coarse frames still deliver timely entries.
interface VisibilityToggleOptions {
enterAt?: number; // become "visible" at or above this ratio
leaveAt?: number; // become "hidden" at or below this ratio
settleMs?: number; // state must hold this long before acting
}
export function observeWithHysteresis(
el: Element,
onChange: (visible: boolean) => void,
{ enterAt = 0.6, leaveAt = 0.4, settleMs = 150 }: VisibilityToggleOptions = {},
): () => void {
let visible = false;
let timer = 0;
const io = new IntersectionObserver(([e]) => {
const r = e.intersectionRatio;
const next = visible ? r > leaveAt : r >= enterAt; // hysteresis band
if (next === visible) { clearTimeout(timer); return; }
clearTimeout(timer);
timer = window.setTimeout(() => { // settle: ignore bounces
visible = next;
onChange(visible);
}, settleMs);
}, { threshold: [0, leaveAt, 0.5, enterAt, 1] });
io.observe(el);
return () => { clearTimeout(timer); io.disconnect(); };
}
observeWithHysteresis(video, (v) => (v ? video.play() : video.pause()));
The hysteresis band means a toolbar moving by a few percent of the viewport cannot flip the state; the settle delay absorbs rubber-band bounces, which reverse within a few frames; and the extra thresholds guarantee entries near both boundaries even at 30 Hz.
Testing Safari Without a Mac on Your Desk
Behaviour differences only show up on real WebKit, and several only on iOS. Practical options:
- Remote-debug a physical iPhone from Safari on macOS (Develop menu). This is the only way to see toolbar and overscroll effects.
- Playwright's WebKit build runs WebKit on Linux and Windows in CI. It reproduces engine-level behaviour — option validation, entry fields, iframe margins — but not the iOS toolbar or overscroll physics.
- Cloud device farms provide real iPhones for manual and automated sessions when no Mac is available.
A useful split: automate the engine-level checks in Playwright WebKit on every build, and keep a short manual checklist for iOS viewport behaviour, run on a real device for releases that touch scroll-driven features.
// playwright.config.ts excerpt: run observer tests on all three engines.
export default {
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
{ name: 'webkit', use: { browserName: 'webkit' } },
{ name: 'firefox', use: { browserName: 'firefox' } },
],
};
Verification Steps
- Scroll to the end of a page on an iPhone and confirm media toggles at most once.
- Enable Low Power Mode and repeat fast scrolls through autoplay content.
- Run the observer test suite on Playwright WebKit in CI.
- Load cross-origin embeds and confirm pre-loading uses the iframe-document root.
- Compare impression counts by browser in analytics; outliers on iOS usually point at toolbar effects.
Common Mistakes to Avoid
- A single threshold for on/off behaviour. Any oscillation around it flips the state.
- Treating every crossing as a user scroll. Toolbars and bounces move content too.
- Relying on desktop Safari to reproduce iOS. The toolbar and overscroll physics differ.
- Blaming the observer for iframe margins. The specification ignores margins for cross-origin targets.
FAQ
Is Safari's IntersectionObserver implementation buggy?
Modern Safari implements the specification well. Most differences come from iOS viewport behaviour — the collapsing toolbar and elastic overscroll — and from frame throttling, which change what the observer measures rather than how it measures.
Why do elements near the bottom cross thresholds when I stop scrolling?
Because Safari's toolbar expands when scrolling ends in some situations, shrinking the layout viewport. Elements near the bottom edge lose visible area without moving, and cross thresholds.
Does Low Power Mode affect IntersectionObserver?
Indirectly. Intersections are computed once per rendering update, and Low Power Mode lowers the rendering rate to about 30 Hz. Crossings are then detected at coarser scroll positions, so intermediate thresholds help.
How do I pre-load content inside a cross-origin iframe on Safari?
Inside the iframe, create the observer with root set to the iframe's own document. rootMargin then applies relative to the iframe's viewport, which is the behaviour the margin was meant to have.
Should I use overscroll-behavior to stop the bounce?
overscroll-behavior: none on the root disables the elastic bounce in current Safari versions, which removes one source of flapping. It also removes an iOS interaction users expect, so hysteresis is usually the better default.
Related
- Tracking the Address Bar Collapse on Mobile Safari — measuring the toolbar
- Autoplaying Video Only When in Viewport — the playback logic
- Feature-Detecting Observer APIs Safely — engine-level differences
↑ Back to Browser Compatibility & Polyfills for Observer APIs