Mobile PWA Gotchas: Safe Areas, Cameras, and Platform Quirks

The iOS and Android issues we hit building a PWA - safe areas, camera stability, viewport height bugs, and why each platform breaks in unique ways.

Building a PWA that feels native on mobile requires fighting two different platforms. iOS and Android break in completely different ways. Here’s what we learned.

The Safe Area Background Problem

Dark mode worked fine. Light mode had a jarring white strip at the bottom where iOS renders the home indicator. Same code, different results.

The culprit: iOS determines safe area colors from a combination of the theme-color meta tag and the document’s background. Dark mode used #121212 everywhere. Light mode used a gradient background but had #f8f9fa as the theme color. The mismatch created a visible seam.

What Didn’t Work

My first instinct was component-level CSS. I restructured the bottom input area, adjusted padding, tweaked z-index. None of it mattered. iOS completely ignores component styles when rendering safe areas - it looks at document-level properties only.

The Fix

Three changes at the document level:

1. Extend the background into the safe area:

html:not(.dark),
html:not(.dark) body {
  background: linear-gradient(...), rgba(255, 255, 255, 1);
  min-height: calc(100% + env(safe-area-inset-top));
  background-attachment: fixed;
}

The min-height with env(safe-area-inset-top) extends the document past the viewport. background-attachment: fixed keeps the gradient stationary during scroll.

2. Match the theme-color to the gradient base:

themeColorMeta.setAttribute(
  "content",
  resolvedTheme === "light" ? "#ffffff" : "#121212",
);

The theme-color must match your actual background. If you’re using a gradient, use the gradient’s base color.

3. Add background_color to the PWA manifest:

{
  "background_color": "#ffffff"
}

This affects the splash screen and helps iOS cache the right colors.

The Caching Trap

One user reported the fix worked briefly after updating, then reverted after closing and reopening the app. iOS caches PWA manifest values aggressively. For manifest changes to fully take effect, users need to delete and reinstall the PWA. There’s no way around this.

Camera Chaos

Our camera implementation tried to be clever. On iPhones with multiple lenses, we wanted buttons for 0.5x, 1x, and 2x zoom to switch between ultra-wide, wide, and telephoto cameras.

The result: black screen on iOS, flashing feed on Android, and four identical “1x” buttons instead of distinct lens options.

Why It Broke

The multi-camera approach relied on parsing device labels from enumerateDevices(). Problem: those labels aren’t available until after the user grants camera permission. Before permission, every camera shows up as something generic. Our categorization logic saw four cameras, couldn’t determine their types, and defaulted all of them to “1x”.

Worse, there was a race condition. The code checked isLoadingDevices() but didn’t actually wait for enumeration to complete. activeCamera() could return undefined, leaving the video element with no source - hence the black screen.

The Simpler Solution

We ripped out the lens selector entirely and replaced it with pinch-to-zoom:

const [zoomLevel, setZoomLevel] = createSignal(1);
let initialPinchDistance = 0;

const handleTouchMove = (e: TouchEvent) => {
  if (e.touches.length === 2 && initialPinchDistance > 0) {
    const currentDistance = getDistance(e.touches[0], e.touches[1]);
    const scaleFactor = currentDistance / initialPinchDistance;
    setZoomLevel((prev) => Math.min(5, Math.max(1, prev * scaleFactor)));
    initialPinchDistance = currentDistance;
  }
};

The video element gets a CSS transform:

<video
  style={{
    transform: `${isFrontCamera() ? "scaleX(-1)" : ""} scale(${zoomLevel()})`,
  }}
/>

For captures, we crop the canvas to match the visible zoomed area:

const handleSnap = () => {
  const zoom = zoomLevel();
  const visibleWidth = videoWidth / zoom;
  const visibleHeight = videoHeight / zoom;
  const offsetX = (videoWidth - visibleWidth) / 2;
  const offsetY = (videoHeight - visibleHeight) / 2;

  canvasRef.width = visibleWidth;
  canvasRef.height = visibleHeight;

  context.drawImage(
    videoRef,
    offsetX,
    offsetY,
    visibleWidth,
    visibleHeight,
    0,
    0,
    visibleWidth,
    visibleHeight,
  );
};

Camera switching now uses facingMode constraints directly. The browser figures out which physical camera to use:

const stream = await navigator.mediaDevices.getUserMedia({
  video: { facingMode: facing === "front" ? "user" : "environment" },
});

Button Type Defaults

While debugging the camera, we discovered another iOS quirk. Pressing the camera button also submitted the message form. The fix: update our Button component to default to type="button".

In HTML, buttons inside forms default to type="submit". This behavior catches everyone eventually. We changed our base component so buttons only submit when explicitly told to:

<Polymorphic
  as={local.as || "button"}
  type={local.as ? undefined : "button"}
  {...rest}
/>

Android-Specific Gotchas

Viewport Height Units Are Broken

CSS 100dvh (dynamic viewport height) works perfectly on iOS - it adjusts when the keyboard appears. On Android PWA, it’s unreliable and buggy.

The fix: Use JavaScript-calculated viewport height on Android:

const updateHeight = () => {
  const vh = window.innerHeight * 0.01;
  document.documentElement.style.setProperty("--vh", `${vh}px`);
};

window.addEventListener("resize", updateHeight);
window.addEventListener("orientationchange", updateHeight);
window.visualViewport?.addEventListener("resize", updateHeight);

Then in CSS:

body.platform-ios .app {
  height: 100dvh;
}
body.platform-android .app {
  height: calc(var(--vh, 1vh) * 100);
}

Textarea Scrolling

Android has trouble with internal scrolling on textarea elements when the keyboard is open. The fix:

body.platform-android textarea {
  touch-action: pan-y;
  -webkit-overflow-scrolling: touch;
  overflow-y: auto;
  overscroll-behavior-y: contain;
}

Speech Recognition Word Duplication

Android Chrome’s SpeechRecognition API has unique bugs - word duplication, missing spaces, inconsistent result indexing. We had to implement deduplication logic that tracks processed results by unique keys and processes from index 0 (not just event.resultIndex).

Camera Was Flashing on Android Too

The camera issues weren’t iOS-only. Android showed a flashing, unstable feed with the multi-lens approach. Same fix: simplify to facingMode constraints and digital pinch-to-zoom.

Platform Detection

We implemented platform detection for targeted fixes:

export function applyPlatformClasses(): void {
  const { isIOS, isAndroid, isPWA, isMobile } = getPlatformInfo();
  const body = document.body;

  if (isIOS) body.classList.add("platform-ios");
  if (isAndroid) body.classList.add("platform-android");
  if (isPWA) body.classList.add("platform-pwa");
  if (isMobile) body.classList.add("platform-mobile");
}

This lets us write platform-specific CSS without JavaScript feature detection.

Takeaways

Safe areas are document-level. Don’t waste time on component CSS. Use env(safe-area-inset-*), match your theme-color to your actual background, and accept that manifest changes require PWA reinstalls.

Viewport height is platform-specific. iOS loves dvh. Android needs JavaScript calculation. Test on both.

Browser APIs lie. Device labels aren’t available pre-permission. Race conditions hide in async code that looks correct. When in doubt, simplify. Digital zoom with facingMode constraints is more reliable than trying to outsmart the platform.

Default to type="button". Save yourself the debugging session.

Detect and branch. Platform-specific classes let you fix issues without affecting the other platform. Some bugs are truly iOS-only or Android-only.

Mobile PWAs remain second-class citizens compared to native apps. But with platform-specific CSS, JavaScript viewport handling, and enough simplification, you can get close enough.