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.
At a Glance
Mobile users of the Takanil Hub web application receive no invitation to install the PWA to their home screen.
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
beforeinstallpromptbefore displaying its ambient install UI. If nothing callsevent.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)')andnavigator.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(missingstart_url,iconsat required sizes,display: standalone) — this would suppressbeforeinstallpromptentirely. - 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
- 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.
- Create an install-prompt component that:
- Listens for
beforeinstallpromptonwindowand stores the event (callingevent.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
localStorageto suppress re-display for 7 days. - Checks
display-mode: standaloneandnavigator.standaloneto exit early when already installed.
- Wire the stored deferred prompt to an "Install" button click handler, calling
deferredPrompt.prompt()and awaitingdeferredPrompt.userChoice.
- 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.
- Suppress banner for 7 days on dismissal by recording
Date.now()inlocalStorageunder a stable key and skipping render when the elapsed time is less than 604 800 000 ms.
- Run
npm run buildand 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 WorkersConfiguration 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 clickIf 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+jsonAndroid/Chrome — simulated install prompt
- Open Chrome DevTools → Application → Manifest.
- Confirm "Installability" shows no errors.
- Click "Add to homescreen" in the DevTools panel to force the prompt regardless of engagement heuristics.
- Confirm the custom banner appears in the UI and the native install dialog opens when the "Install" button is clicked.
iOS/Safari
- Open the app in Safari on a real iOS device or Simulator.
- Confirm the iOS instruction banner appears (Share → Add to Home Screen guidance).
- Confirm the banner does not appear after dismissal, and reappears after clearing
localStoragekeypwa_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 appear7-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 reappearBuild verification
npm run build
# Expected: exit code 0, zero TypeScript errors, zero bundler errorsPrevention
- CI Lighthouse audit: Add a Lighthouse PWA audit step to your CI pipeline and fail the build if the
pwa-installable-from-manifestscore 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-validatoror 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, andfetchevents with a monitoring integration (e.g., Sentry, Datadog RUM) to alert on registration failures in production.
- Display-mode telemetry: Track
display-modeat 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_atin 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
localStorageis clear and the app is not in standalone mode.