BugCI/CD

PWA Install Prompt Not Displayed on Mobile: Missing beforeinstallprompt Handler and iOS Fallback

Mobile users of the Takanil Hub web application receive no invitation to install the PWA to their home screen.

Rootlock SRE Engine 7 min read
Diagnostic brief

At a Glance

Mobile users of the Takanil Hub web application receive no invitation to install the PWA to their home screen.

Severity Not rated
Confidence Medium
Frequency Unknown
Impact See analysis

Summary

Mobile users of the Takanil Hub web application receive no invitation to install the PWA to their home screen. The application lacks an install-prompt component that captures the browser's beforeinstallprompt event on Android/Chrome and renders manual guidance on iOS/Safari, where that event is not fired. The practical impact is reduced engagement and retention: users experience the app as a plain website rather than an installable PWA, and the browser's native install affordance is silently discarded.

Root-Cause Analysis

Confirmed evidence

  • No install banner or prompt UI exists in the application.
  • Mobile visitors on Android/Chrome and iOS/Safari receive no installation cue.
  • The app is reported to look like a plain website, indicating either the PWA manifest or install-prompt handling is absent or incomplete.

Confirmed browser behavior

  • Chrome on Android fires beforeinstallprompt before displaying its ambient install UI. If nothing calls event.preventDefault() and stores the event reference, the prompt cannot be triggered programmatically later; the deferred prompt is lost.
  • Safari on iOS never fires beforeinstallprompt. The only path to home-screen installation is the Share → Add to Home Screen flow, which must be communicated via custom in-app UI.
  • Both platforms expose window.matchMedia('(display-mode: standalone)') and navigator.standalone (iOS) to detect whether the app is already running as an installed PWA.

Reasonable inference

The application likely has a valid manifest.json and service worker (a prerequisite for beforeinstallprompt to fire at all), but the event listener is absent from the JavaScript bundle, so the deferred prompt reference is never captured.

Alternative causes to rule out

  • Incomplete or invalid manifest.json (missing start_url, icons at required sizes, display: standalone) — this would suppress beforeinstallprompt entirely.
  • Service worker not registered or failing to activate — also suppresses the event.
  • HTTPS not enforced — PWA install criteria require a secure context.
  • App already meets install criteria but Chrome's engagement heuristics have not yet been satisfied (requires some prior user interaction with the origin).

Run the checklist in the Verification section to rule these out before implementing the prompt component.

Resolution Steps

  1. Validate PWA installability prerequisites before writing any prompt UI. Open Chrome DevTools → Application → Manifest and confirm zero errors. Confirm the service worker status shows "activated and running." Fix any manifest or service worker issues first.
  1. Create an install-prompt component that:
  • Listens for beforeinstallprompt on window and stores the event (calling event.preventDefault() to suppress the ambient mini-infobar).
  • Detects iOS Safari via user-agent sniffing as a fallback path.
  • Reads and writes a dismissal timestamp in localStorage to suppress re-display for 7 days.
  • Checks display-mode: standalone and navigator.standalone to exit early when already installed.
  1. Wire the stored deferred prompt to an "Install" button click handler, calling deferredPrompt.prompt() and awaiting deferredPrompt.userChoice.
  1. Render iOS-specific instructions (Share icon → Add to Home Screen) as static UI inside the same component when the iOS path is detected, since no programmatic prompt is available.
  1. Suppress banner for 7 days on dismissal by recording Date.now() in localStorage under a stable key and skipping render when the elapsed time is less than 604 800 000 ms.
  1. Run npm run build and confirm zero errors before merging.

CLI Commands

Audit PWA installability from the command line using Lighthouse:

npx lighthouse <YOUR_APP_URL> \
  --only-categories=pwa \
  --output=json \
  --output-path=./lighthouse-pwa.json \
  --chrome-flags="--headless"

Inspect the resulting report for installable and pwa-installable-from-manifest audits.

Check that the service worker is registered:

# Serve the production build locally and open in Chrome
npm run build
npx serve -s <BUILD_OUTPUT_DIR> -l 3000
# Then open chrome://inspect or DevTools → Application → Service Workers

Configuration Snippets

Ensure manifest.json meets the minimum install criteria:

{
  "name": "Takanil Hub",
  "short_name": "Takanil",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#000000",
  "icons": [
    {
      "src": "/icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "/icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any maskable"
    }
  ]
}

Reference implementation of the install-prompt component (framework-agnostic TypeScript):

// src/components/InstallPrompt.ts

const STORAGE_KEY = 'pwa_install_dismissed_at';
const SUPPRESS_DURATION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days

let deferredPrompt: BeforeInstallPromptEvent | null = null;

function isStandalone(): boolean {
  return (
    window.matchMedia('(display-mode: standalone)').matches ||
    ('standalone' in navigator && (navigator as any).standalone === true)
  );
}

function isDismissedRecently(): boolean {
  const raw = localStorage.getItem(STORAGE_KEY);
  if (!raw) return false;
  return Date.now() - parseInt(raw, 10) < SUPPRESS_DURATION_MS;
}

function isIosSafari(): boolean {
  const ua = navigator.userAgent;
  return /iphone|ipad|ipod/i.test(ua) && /safari/i.test(ua) && !/crios|fxios/i.test(ua);
}

function dismiss(): void {
  localStorage.setItem(STORAGE_KEY, String(Date.now()));
  // Hide banner UI here
}

// Capture the deferred prompt — must be called early in app bootstrap
window.addEventListener('beforeinstallprompt', (e: Event) => {
  e.preventDefault();
  deferredPrompt = e as BeforeInstallPromptEvent;
  maybeShowBanner();
});

async function triggerInstall(): Promise<void> {
  if (!deferredPrompt) return;
  deferredPrompt.prompt();
  const { outcome } = await deferredPrompt.userChoice;
  if (outcome === 'accepted') {
    deferredPrompt = null;
    dismiss();
  }
}

function maybeShowBanner(): void {
  if (isStandalone() || isDismissedRecently()) return;

  if (isIosSafari()) {
    // Render iOS instruction banner
    renderIosBanner();
  } else if (deferredPrompt) {
    // Render Android/Chrome install banner
    renderAndroidBanner();
  }
}

// Attach triggerInstall to the "Install" button click
// Attach dismiss to the "X" button click

If using React or Vue, wrap this logic in a component that calls maybeShowBanner on mount and binds triggerInstall / dismiss to button handlers.

Add the BeforeInstallPromptEvent type if it is not present in your TypeScript lib:

// src/types/pwa.d.ts
interface BeforeInstallPromptEvent extends Event {
  readonly platforms: string[];
  readonly userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>;
  prompt(): Promise<void>;
}

Verification

Prerequisite checks

# Confirm manifest is served with correct MIME type
curl -I <YOUR_APP_URL>/manifest.json | grep -i content-type
# Expected: content-type: application/manifest+json

Android/Chrome — simulated install prompt

  1. Open Chrome DevTools → Application → Manifest.
  2. Confirm "Installability" shows no errors.
  3. Click "Add to homescreen" in the DevTools panel to force the prompt regardless of engagement heuristics.
  4. Confirm the custom banner appears in the UI and the native install dialog opens when the "Install" button is clicked.

iOS/Safari

  1. Open the app in Safari on a real iOS device or Simulator.
  2. Confirm the iOS instruction banner appears (Share → Add to Home Screen guidance).
  3. Confirm the banner does not appear after dismissal, and reappears after clearing localStorage key pwa_install_dismissed_at.

Standalone mode suppression

// Run in DevTools console while in standalone / after install
window.matchMedia('(display-mode: standalone)').matches; // must return true
// Reload — banner must NOT appear

7-day suppression

// Simulate dismissal timestamp just beyond 7 days ago
localStorage.setItem('pwa_install_dismissed_at', String(Date.now() - 7 * 24 * 60 * 60 * 1000 - 1));
// Reload — banner must reappear

Build verification

npm run build
# Expected: exit code 0, zero TypeScript errors, zero bundler errors

Prevention

  • CI Lighthouse audit: Add a Lighthouse PWA audit step to your CI pipeline and fail the build if the pwa-installable-from-manifest score drops below 1 (pass).
# Example GitHub Actions step
- name: Lighthouse PWA Audit
  run: |
    npx lighthouse ${{ env.APP_URL }} \
      --only-categories=pwa \
      --assert-categories=pwa:1 \
      --chrome-flags="--headless --no-sandbox"
  • Manifest schema validation: Add web-app-manifest-validator or equivalent to a pre-commit hook or CI step to catch missing required fields early.
  • Service worker health monitoring: Instrument the service worker's install, activate, and fetch events with a monitoring integration (e.g., Sentry, Datadog RUM) to alert on registration failures in production.
  • Display-mode telemetry: Track display-mode at page load via your analytics platform to measure the installed vs. browser-tab split and confirm install rates improve post-fix.
  • localStorage key documentation: Document pwa_install_dismissed_at in a central constants file to prevent key collisions and accidental deletion during storage-clearing routines.
  • E2E test: Add a Playwright or Cypress test that asserts the banner is visible in a 375 px mobile viewport when localStorage is clear and the app is not in standalone mode.
Developer FirstBuilt for engineers solving real problems
Evidence DrivenTechnical claims tied to available evidence
Automation ReadyStructured for CLI, APIs, and workflows
Privacy FocusedNo unnecessary data collection in this article UI
STAY AHEAD OF ISSUES

Get new root-cause analyses in your inbox

Engineering-focused updates. No fake subscriber counts. Unsubscribe anytime.