July 10, 2026
How to Debug Browser Tests That Fail Only on Mobile Safari Because of Scroll Lock, Sticky Headers, or Safe Area Insets
A practical debugging guide for browser tests that fail only on mobile Safari, covering scroll lock issues, sticky header bugs, and safe area insets in Playwright, Selenium, and Cypress.
If a test passes on desktop Chrome, Firefox, and even desktop Safari, but fails only on an iPhone, the instinct is often to blame flaky timing. Sometimes that is true. Often it is not. Mobile Safari has a different viewport model, different scrolling behavior, and a few layout quirks that can make a test look random when it is actually deterministic.
The most common failure patterns I see in this area are not exotic framework bugs. They are usually caused by one of three things: scroll lock that behaves differently on iOS, sticky headers that cover the target element at the exact moment your test clicks it, or safe area insets that shift layouts in ways desktop emulation does not reproduce well. These problems are easy to misdiagnose because they often appear as “element not interactable,” “click intercepted,” or a silent assertion failure after a scroll.
If a browser test fails only on mobile Safari, assume the page is lying to you about its viewport until proven otherwise.
Why mobile Safari fails differently
Mobile Safari is not just a smaller browser. It has its own rules for visual viewport, address bar collapse, touch scrolling, overscroll behavior, and fixed or sticky positioning. When your test runner emulates an iPhone, it may reproduce the user agent and some viewport dimensions, but not every browser UI detail that affects layout and hit testing.
This matters for Test automation because your test code usually interacts with the DOM through a model that assumes stable geometry. A click target is expected to stay visible, a scroll is expected to move the page predictably, and the browser is expected to report consistent coordinates. On iPhone Safari, each of those expectations can be false in edge cases.
Three classes of failure show up most often:
- The test cannot click because a header overlaps the element.
- The test scrolls, but the page immediately bounces back or locks the body.
- The element is visible in the DOM, but hidden under the notch, toolbar, or a safe area offset.
The trick is to distinguish a real application bug from a test artifact. That starts by understanding the interaction between layout, scrolling, and browser UI.
First, reproduce the failure with the right browser model
Before changing selectors or adding waits, confirm that you are actually testing something close to real mobile Safari behavior. Desktop Safari with a narrow viewport is not the same as Safari on iPhone. Neither is a generic mobile emulator in a desktop browser.
If you use Playwright, make sure your project is using an iPhone device profile, not just a small viewport. A device profile gives you more than dimensions, including touch support and user agent details.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({ projects: [ { name: ‘iPhone 13’, use: { …devices[‘iPhone 13’] } } ] });
That still is not identical to real mobile Safari, but it is a better baseline. For Selenium, use Safari’s WebDriver support on macOS for genuine Safari coverage, and be careful about assuming that Chrome mobile emulation has the same layout behavior. Apple’s own documentation for testing with WebDriver in Safari is worth reviewing if your suite runs against actual Safari rather than emulated devices.
When a bug is hard to isolate, capture more than a screenshot. Save the browser console, the DOM state around the target, and the bounding box or coordinate data for the element just before the interaction fails. In many cases, the numbers explain the problem faster than the screenshot.
How scroll lock issues break mobile Safari tests
Scroll lock is one of the most common causes of browser tests fail on mobile Safari. Many apps lock page scrolling when a modal, drawer, or menu opens by setting overflow: hidden on the body or html element. That can work on desktop while behaving differently on iOS Safari, where the page can still move, bounce, or preserve scroll position in ways your test does not expect.
The failure pattern often looks like this:
- a test opens a menu or modal,
- a background element becomes inaccessible,
- the test tries to scroll to an item behind the overlay,
- Safari keeps the page at a different offset, or the overlay itself captures the gesture.
There are also JavaScript-based scroll lock implementations that store the current scroll position, set position: fixed on the body, and restore the page later. If this logic is even slightly wrong, Safari can render content at an offset that is invisible in desktop browsers but obvious on an iPhone.
What to inspect
Check these things in the failing state:
document.body.style.overflowanddocument.documentElement.style.overflow- whether
bodyor a root container hasposition: fixed - whether the page is using a custom scroll container instead of the window
- whether the page adds
touchmoveorwheellisteners that prevent default behavior - whether opening a modal changes the scroll position before the test tries to interact again
If your test framework can evaluate page state, capture it right before the failure.
typescript
const state = await page.evaluate(() => ({
scrollY: window.scrollY,
bodyOverflow: getComputedStyle(document.body).overflow,
htmlOverflow: getComputedStyle(document.documentElement).overflow,
bodyPosition: getComputedStyle(document.body).position
}));
console.log(state);
What usually fixes it
There are two separate problems to solve, one in the app and one in the test.
For the app, prefer scroll-lock patterns that are explicitly tested on iOS. If you store and restore scroll position, verify that the restoration is accurate after modal close. If you lock the body, test the resulting offset on Safari with real touch scrolling, not just desktop scrolling.
For the test, avoid treating scroll as a side effect of clicking. If an element may be hidden behind an overlay or inside a different scroll container, scroll it into view explicitly, then verify that it is actually clickable. In Playwright, that might mean checking visibility and bounding box stability before the click.
typescript
const target = page.getByRole('button', { name: 'Continue' });
await target.scrollIntoViewIfNeeded();
await expect(target).toBeVisible();
await target.click();
That alone will not solve body lock bugs, but it makes the failure more obvious. If a click still fails, the overlay or scroll behavior is probably the real issue.
Sticky headers are often the real reason the click fails
Sticky headers create a deceptively simple problem. The element is in the viewport, but the header covers the top few pixels of the clickable area. Desktop browsers sometimes tolerate this by auto-scrolling a little differently, while mobile Safari can leave the element aligned under the sticky header more often.
The visible symptom is usually one of these:
click intercepted by another element- the click succeeds, but the wrong element receives it
- the test scrolls to a link or button, then immediately fails because the target is partly hidden
Sticky headers are especially troublesome when the layout combines a header, a search bar, and a CTA banner, because the effective overlay height can vary by breakpoint. On mobile, the sticky area may consume much more of the viewport than the desktop test author expects.
How to diagnose header overlap
At the moment before the click, compare the target’s bounding box with the viewport and the header height.
typescript
const info = await page.evaluate(() => {
const el = document.querySelector('[data-testid="checkout-button"]');
const header = document.querySelector('header');
if (!el || !header) return null;
const r1 = el.getBoundingClientRect(); const r2 = header.getBoundingClientRect(); return { elementTop: r1.top, elementBottom: r1.bottom, headerBottom: r2.bottom, viewportHeight: window.innerHeight }; }); console.log(info);
If the element’s top sits under the header bottom, your click target is not really free.
Better fixes than “add a wait”
If the application controls a sticky header, give anchor targets and focused controls enough top padding or scroll margin. CSS scroll-margin-top is often the simplest mitigation.
.section-anchor,
button[data-testid="checkout-button"] {
scroll-margin-top: 88px;
}
That helps browser-initiated scrolling land the target below the sticky region. It also makes your manual debugging easier because the target appears where a human can see it.
In tests, do not rely on a generic page.click() if your app uses complex overlays. Verify the target is unobstructed. In Cypress, scrollBehavior: 'center' or explicit scrollIntoView() sometimes helps, but only if the underlying sticky area is accounted for. If the header is dynamic, your test should measure it or rely on a stable app-side offset rather than a guessed number.
A test that clicks through a sticky header is not robust, it is lucky.
Safe area insets create layout shifts that desktop emulation hides
Safe area insets are another reason browser tests fail on mobile Safari in ways that seem random. Devices with notches, home indicators, or sensor housings expose CSS environment variables such as env(safe-area-inset-top) and env(safe-area-inset-bottom). These can push content away from the edges or cause elements to appear lower than expected.
This usually affects fixed footers, floating action buttons, consent banners, and bottom navigation. A button might be visible on desktop and in emulator screenshots, but on a real iPhone it can sit too close to the bottom edge, under a browser toolbar, or inside an area that is hard to interact with.
If your tests rely on exact coordinates, a safe area inset can move the actual click target enough to break the interaction. If your visual regression tool is involved, the page can also look “off by a few pixels” on iPhone screenshots, which is enough to create noise in a sensitive comparison pipeline.
How to inspect safe area behavior
Check whether the app uses CSS environment variables, and whether your test device exposes them the way you expect.
.footer {
padding-bottom: calc(16px + env(safe-area-inset-bottom));
}
In the browser, you can inspect the computed padding or measure the visible bottom of the target relative to the viewport.
typescript
const footer = await page.locator('footer').boundingBox();
console.log(footer);
If the footer is clipped or sitting below the visible area, that is a layout issue, not a flaky test.
What to do in the app
Use safe area insets deliberately, especially for fixed bottom controls. Test them on actual iPhones, not only in a browser window resized to a similar aspect ratio. If the footer is interactive, leave enough internal spacing so the button remains comfortably tappable after the inset padding is applied.
For screenshots and visual regression, standardize on a device matrix that includes at least one notch device and one non-notch device if your product supports both. That gives you coverage for footer alignment, modal placement, and sticky navigation behavior without overfitting to one screen shape.
A practical debugging workflow that saves time
When the failure only happens on mobile Safari, use a repeatable sequence instead of ad hoc retries.
1. Identify the interaction type
Ask whether the test fails on scroll, tap, focus, or assertion. Scroll-related failures usually point to layout or locking issues. Tap failures often point to overlap or offset. Focus failures can indicate an overlay, a keyboard shift, or a hidden input.
2. Capture geometry before the action
Log the target bounding box, the viewport size, and any obvious overlay or header dimensions. If the element is not where you think it is, stop debugging the test and fix the layout assumption first.
3. Compare real Safari to emulation
If emulation fails but real Safari passes, the problem might be with your test environment. If real Safari fails too, it is likely an application behavior issue. That distinction matters for triage.
4. Check the app state that affects scrolling
Open modals, drawer state, body lock, sticky elements, and fixed containers all change how Safari performs the next interaction. A hidden state mutation earlier in the test can be the true root cause.
5. Re-run with slower, explicit steps
A shorter interaction path is easier to reason about than a single convenience helper. Break the action into scroll, verify, click, then assert.
typescript
const cta = page.getByRole('button', { name: 'Start trial' });
await cta.scrollIntoViewIfNeeded();
await expect(cta).toBeInViewport();
await expect(cta).toBeVisible();
await cta.click();
The point is not to add delays. It is to separate layout failure from action failure.
Tool-specific notes for Playwright, Selenium, and Cypress
Different test frameworks surface these problems in slightly different ways.
Playwright
Playwright is usually the best at explaining the failure because it can tell you when an element is not receiving the click. Still, it will not magically understand your app’s sticky header. Use locator-based interactions, inspect the target before clicking, and prefer semantic selectors over coordinate clicks.
If a test keeps failing due to a mobile Safari-specific overlay, avoid forcing the click unless you are intentionally bypassing a non-user-facing issue. Forced clicks can hide a real regression.
Selenium
With Selenium, Safari issues often appear as coordinate or interactability problems. This is where logging element rectangles is especially useful. If you are using Safari through WebDriver, keep your Safari and Safari Technology Preview setup consistent across the team, because environment drift can make failures hard to reproduce. Apple’s WebDriver documentation is a useful starting point for the exact supported setup in your environment.
Cypress
Cypress can be very helpful for debugging because its command log makes the scroll and click sequence visible. The downside is that Cypress runs in a browser context that may not perfectly mirror real mobile Safari, so do not assume a passing Cypress mobile viewport test means the bug is gone. Use it to find the failure pattern, then confirm on actual Safari if the issue is layout-sensitive.
Decide whether to fix the test or the app
This is the part teams often skip, and it is why flaky labels multiply.
Fix the test when:
- the selector is fragile, but the UI is correct,
- the action is racing the page before layout settles,
- the test assumes a generic viewport instead of the real mobile layout.
Fix the app when:
- a sticky header obscures primary controls,
- the scroll lock traps the page in an unusable state,
- safe area insets make a CTA hard to reach or visually clipped.
A test suite should not paper over a control that users cannot reasonably tap. If the only reason the test passes is because you used force: true or a coordinate click, treat that as a warning, not a solution.
Build a small regression check for the failure mode
Once you understand the bug, encode the failure mode in a focused test so it does not come back later. Do not write a giant end-to-end scenario if the failure is really about overlapping geometry.
A good regression check often looks like this:
- open the page on a mobile Safari-like viewport,
- open the modal or navigation state that triggers scroll lock,
- verify the main target is still visible and not hidden under the header,
- confirm the bottom CTA is clear of safe area clipping,
- interact with the target using the same path a user would use.
If you can express the issue in one short test, you will catch future CSS changes that reintroduce it. That is especially useful in CI, where UI regressions tend to surface only after a layout refactor or a new banner is introduced.
For teams using continuous integration, a lightweight browser matrix is usually better than an enormous one. Continuous integration works best when the signal is actionable. Add the specific mobile Safari scenario that broke before, rather than trying to brute-force every device combination on every commit.
A simple checklist for the next failure
When the next test fails only on iPhone Safari, walk through this checklist:
- Is the test using real Safari behavior or just a narrow viewport?
- Is the target covered by a sticky header, modal, or banner?
- Did a scroll lock change the page offset or prevent the expected scroll?
- Is a safe area inset shifting the bottom or top of the interactive region?
- Does the app rely on fixed coordinates instead of visible, tappable state?
- Can you reproduce the problem with a single target and a single interaction?
That checklist keeps you from chasing generic flakiness when the root cause is a very specific mobile layout issue.
Closing thought
Mobile Safari failures are frustrating because they sit in the overlap between browser behavior, CSS layout, and automation assumptions. The browser is not necessarily broken, and the test is not necessarily flaky. More often, the app and the test disagree about what “visible” and “clickable” mean on a real iPhone.
If you debug geometry first, inspect scroll state second, and treat sticky headers and safe area insets as first-class layout constraints, you will spend far less time retrying tests and far more time fixing the actual bug.
That is the practical path to making browser tests fail on mobile Safari less often, and when they do fail, making the reason obvious.