Front-end Engineering Lab
PatternsMobile & PWA

Install Prompt Strategies

Optimize A2HS (Add to Home Screen) prompts for maximum conversion

Add to Home Screen (A2HS) prompts encourage users to install your PWA. Poor implementation annoys users; good implementation increases installs by 3-5x.

PWA Installation Requirements

manifest.json

{
  "name": "My Awesome App",
  "short_name": "MyApp",
  "description": "An awesome progressive web app",
  "start_url": "/",
  "display": "standalone",
  "theme_color": "#000000",
  "background_color": "#ffffff",
  "orientation": "portrait",
  "icons": [
    {
      "src": "/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any maskable"
    }
  ],
  "screenshots": [
    {
      "src": "/screenshot1.png",
      "sizes": "540x720",
      "type": "image/png",
      "form_factor": "narrow"
    },
    {
      "src": "/screenshot2.png",
      "sizes": "1920x1080",
      "type": "image/png",
      "form_factor": "wide"
    }
  ]
}

Service Worker

// sw.js - Must be registered
self.addEventListener('install', (event) => {
  console.log('Service Worker installed');
});

self.addEventListener('fetch', (event) => {
  // Handle fetch events
});

HTTPS

✅ HTTPS required (except localhost)
❌ HTTP won't work

Capturing beforeinstallprompt

// utils/install-prompt.ts
let deferredPrompt: any = null;

export function setupInstallPrompt() {
  window.addEventListener('beforeinstallprompt', (e) => {
    // Prevent default mini-infobar
    e.preventDefault();
    
    // Store for later use
    deferredPrompt = e;
    
    console.log('Install prompt available');
    
    // Dispatch custom event
    window.dispatchEvent(new Event('install-available'));
  });

  // Track successful installation
  window.addEventListener('appinstalled', () => {
    console.log('PWA installed successfully');
    deferredPrompt = null;
    
    // Analytics
    trackEvent('pwa_installed');
    
    // Thank user
    showThankYouMessage();
  });
}

export async function showInstallPrompt(): Promise<boolean> {
  if (!deferredPrompt) {
    console.log('Install prompt not available');
    return false;
  }

  // Show native prompt
  deferredPrompt.prompt();
  
  // Wait for user response
  const { outcome } = await deferredPrompt.userChoice;
  
  console.log(`Install prompt ${outcome}`);
  
  // Track outcome
  trackEvent('install_prompt_response', { outcome });
  
  // Clear prompt
  deferredPrompt = null;
  
  return outcome === 'accepted';
}

export function isInstallAvailable(): boolean {
  return deferredPrompt !== null;
}

Smart Prompt Timing

Wrong: Immediate Prompt

// ❌ BAD: Annoys users
window.addEventListener('load', () => {
  showInstallPrompt();
});

Right: Contextual Prompt

// ✅ GOOD: Show after user engagement
class InstallPromptManager {
  private interactions = 0;
  private timeOnSite = 0;
  private startTime = Date.now();

  constructor() {
    this.trackEngagement();
  }

  private trackEngagement() {
    // Track interactions
    ['click', 'scroll', 'keypress'].forEach(event => {
      document.addEventListener(event, () => {
        this.interactions++;
        this.checkPromptCriteria();
      });
    });

    // Track time on site
    setInterval(() => {
      this.timeOnSite = (Date.now() - this.startTime) / 1000;
      this.checkPromptCriteria();
    }, 5000);
  }

  private checkPromptCriteria() {
    // Show prompt when user is engaged
    if (
      this.interactions >= 5 &&
      this.timeOnSite >= 30 &&
      !hasSeenPromptRecently()
    ) {
      this.showPrompt();
    }
  }

  private async showPrompt() {
    const accepted = await showInstallPrompt();
    
    if (!accepted) {
      // Don't show again for 7 days
      localStorage.setItem('last-prompt-shown', Date.now().toString());
    }
  }
}

function hasSeenPromptRecently(): boolean {
  const lastShown = localStorage.getItem('last-prompt-shown');
  if (!lastShown) return false;
  
  const daysSince = (Date.now() - parseInt(lastShown)) / (1000 * 60 * 60 * 24);
  return daysSince < 7;
}

Contextual Prompts

After Feature Use

// Show after user completes action
async function saveDocument() {
  await saveToServer();
  
  // User just experienced value
  if (isInstallAvailable() && !hasSeenPromptRecently()) {
    showInstallBanner({
      message: 'Save documents offline! Install the app',
      context: 'after-save',
    });
  }
}

Before Limited Feature

// Encourage install to unlock feature
function accessPremiumFeature() {
  if (!isPWAInstalled()) {
    showInstallBanner({
      message: 'Install the app to access offline mode',
      cta: 'Install App',
      context: 'feature-gate',
    });
  } else {
    // Allow access
    showPremiumFeature();
  }
}

Custom Install Banner

// components/InstallBanner.tsx
import { useState, useEffect } from 'react';
import { isInstallAvailable, showInstallPrompt } from '@/utils/install-prompt';

export function InstallBanner() {
  const [show, setShow] = useState(false);

  useEffect(() => {
    const handleInstallAvailable = () => {
      // Show banner after criteria met
      setTimeout(() => {
        if (isInstallAvailable() && !hasSeenPromptRecently()) {
          setShow(true);
        }
      }, 30000); // 30 seconds
    };

    window.addEventListener('install-available', handleInstallAvailable);
    return () => window.removeEventListener('install-available', handleInstallAvailable);
  }, []);

  const handleInstall = async () => {
    const accepted = await showInstallPrompt();
    
    if (accepted || !isInstallAvailable()) {
      setShow(false);
    }
  };

  const handleDismiss = () => {
    setShow(false);
    localStorage.setItem('last-prompt-shown', Date.now().toString());
    
    // Track dismissal
    trackEvent('install_banner_dismissed');
  };

  if (!show) return null;

  return (
    <div className="install-banner">
      <div className="install-content">
        <img src="/icon-192x192.png" alt="App icon" className="app-icon" />
        
        <div className="install-text">
          <h3>Install My App</h3>
          <p>Get quick access and work offline</p>
        </div>
      </div>
      
      <div className="install-actions">
        <button onClick={handleInstall} className="install-button">
          Install
        </button>
        <button onClick={handleDismiss} className="dismiss-button">

        </button>
      </div>
    </div>
  );
}

iOS Installation Guide

// components/IOSInstallPrompt.tsx
import { useState, useEffect } from 'react';

export function IOSInstallPrompt() {
  const [show, setShow] = useState(false);

  useEffect(() => {
    // Detect iOS Safari
    const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
    const isStandalone = window.matchMedia('(display-mode: standalone)').matches;
    const isInSafari = !!(window as any).safari;

    if (isIOS && !isStandalone && isInSafari) {
      // Show instructions after engagement
      setTimeout(() => {
        setShow(true);
      }, 30000);
    }
  }, []);

  if (!show) return null;

  return (
    <div className="ios-install-prompt">
      <div className="ios-install-content">
        <h3>Install this app</h3>
        <p>Tap the share button below, then select "Add to Home Screen"</p>
        
        <div className="ios-instructions">
          <div className="instruction">
            <span className="step">1</span>
            <p>Tap <span className="icon">⎙</span> (Share button)</p>
          </div>
          <div className="instruction">
            <span className="step">2</span>
            <p>Select "Add to Home Screen"</p>
          </div>
          <div className="instruction">
            <span className="step">3</span>
            <p>Tap "Add"</p>
          </div>
        </div>
        
        <button onClick={() => setShow(false)}>Got it</button>
      </div>
    </div>
  );
}

A/B Testing Prompts

// Test different prompt strategies
type PromptVariant = 'immediate' | 'delayed' | 'contextual';

class InstallPromptTest {
  private variant: PromptVariant;

  constructor() {
    // Assign variant (33% each)
    const rand = Math.random();
    if (rand < 0.33) {
      this.variant = 'immediate';
    } else if (rand < 0.66) {
      this.variant = 'delayed';
    } else {
      this.variant = 'contextual';
    }

    this.runVariant();
  }

  private runVariant() {
    switch (this.variant) {
      case 'immediate':
        window.addEventListener('load', this.showPrompt);
        break;
      
      case 'delayed':
        setTimeout(this.showPrompt, 30000);
        break;
      
      case 'contextual':
        this.trackEngagementAndPrompt();
        break;
    }
  }

  private async showPrompt() {
    const accepted = await showInstallPrompt();
    
    // Track results
    trackEvent('install_prompt_shown', {
      variant: this.variant,
      accepted,
    });
  }

  private trackEngagementAndPrompt() {
    // Show after user completes key action
    document.addEventListener('user-saved-document', this.showPrompt);
  }
}

Analytics

// Track install funnel
export class InstallAnalytics {
  trackPromptAvailable() {
    trackEvent('install_prompt_available');
  }

  trackPromptShown(context: string) {
    trackEvent('install_prompt_shown', { context });
  }

  trackPromptAccepted() {
    trackEvent('install_prompt_accepted');
  }

  trackPromptDismissed() {
    trackEvent('install_prompt_dismissed');
  }

  trackInstalled() {
    trackEvent('pwa_installed');
  }

  // Calculate conversion rate
  getConversionRate() {
    const shown = getEventCount('install_prompt_shown');
    const installed = getEventCount('pwa_installed');
    
    return shown > 0 ? (installed / shown) * 100 : 0;
  }
}

Best Practices

  1. Wait for engagement: 30+ seconds, 5+ interactions
  2. Explain value: Clear benefit of installing
  3. Contextual timing: After user experiences value
  4. Dismissible: Easy to close, don't be annoying
  5. Once per session: Don't spam
  6. Cooldown period: 7+ days between prompts
  7. iOS instructions: Different flow for Safari
  8. Track everything: Measure conversion rates
  9. A/B test: Find optimal strategy
  10. Respect dismissals: Don't show again soon

Measuring Success

// Installation metrics
export function trackInstallMetrics() {
  // Check if installed
  if (window.matchMedia('(display-mode: standalone)').matches) {
    trackEvent('session_in_pwa');
  } else {
    trackEvent('session_in_browser');
  }

  // Track conversion funnel
  const funnel = {
    promptsShown: getEventCount('install_prompt_shown'),
    accepted: getEventCount('install_prompt_accepted'),
    dismissed: getEventCount('install_prompt_dismissed'),
    installed: getEventCount('pwa_installed'),
  };

  console.log('Install Funnel:', funnel);
  
  return {
    conversionRate: (funnel.installed / funnel.promptsShown) * 100,
    acceptanceRate: (funnel.accepted / funnel.promptsShown) * 100,
    dismissalRate: (funnel.dismissed / funnel.promptsShown) * 100,
  };
}

Common Pitfalls

Immediate prompt: Annoys new users
Wait for engagement signals

No context: "Install" with no reason
Explain clear benefits

Persistent: Shows repeatedly
Cooldown periods, respect dismissals

No iOS support: Safari users confused
Show iOS-specific instructions

No tracking: Can't optimize
Track funnel, measure conversion

Install prompts should feel helpful, not intrusive—wait for the right moment and explain the value!

On this page