Lyra

Online
Progressive Web Apps in 2022: The Complete Implementation Guide
Web Development | PWA 14 min read

Progressive Web Apps in 2022: The Complete Implementation Guide

S
Squalltec Team March 15, 2022

Why PWAs Matter in 2022

The Statistics:

  • 53% of mobile users abandon sites that take > 3 seconds to load
  • Average user has 80 apps installed, uses only 9 regularly
  • 80% of time spent in apps, not mobile browsers
  • But: Users download 0 new apps per month on average

The Problem:

  • Mobile web is too slow
  • Native apps have high barrier to entry
  • App stores have discovery problems

The Solution: Progressive Web Apps

What Are PWAs? Web apps that feel like native apps:

  • Install to home screen
  • Work offline
  • Fast loading
  • Push notifications
  • Camera, GPS access
  • No app store needed

Success Stories:

Twitter Lite (PWA):

  • 65% increase in pages per session
  • 75% increase in Tweets sent
  • 20% decrease in bounce rate
  • 70% faster load time

Pinterest PWA:

  • 60% increase in core engagements
  • 44% increase in ad revenue
  • 50% increase in user-generated revenue

Starbucks PWA:

  • 2x daily active users
  • 99.84% smaller than native iOS app

This guide shows you how to build one.

PWA Core Concepts

Three Pillars:

1. Reliable

  • Load instantly
  • Work offline
  • Handle poor network conditions

2. Fast

  • Respond quickly to user interactions
  • Smooth animations
  • No janky scrolling

3. Engaging

  • Installable to home screen
  • Push notifications
  • Full-screen experience

Technical Requirements:

HTTPS (required for service workers) Service Worker (offline functionality) Web App Manifest (install prompt) Responsive Design (all screen sizes) App Shell Architecture (fast loading)

Step 1: Web App Manifest

What It Is: JSON file describing your app.

manifest.json:

{
  "name": "My Awesome PWA",
  "short_name": "PWA",
  "description": "A progressive web application",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#4285f4",
  "orientation": "portrait-primary",
  "icons": [
    {
      "src": "/icons/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-96x96.png",
      "sizes": "96x96",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-128x128.png",
      "sizes": "128x128",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-144x144.png",
      "sizes": "144x144",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-152x152.png",
      "sizes": "152x152",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-384x384.png",
      "sizes": "384x384",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable any"
    }
  ],
  "screenshots": [
    {
      "src": "/screenshots/home.png",
      "sizes": "540x720",
      "type": "image/png"
    },
    {
      "src": "/screenshots/detail.png",
      "sizes": "540x720",
      "type": "image/png"
    }
  ],
  "categories": ["productivity", "utilities"],
  "shortcuts": [
    {
      "name": "New Task",
      "short_name": "New",
      "description": "Create a new task",
      "url": "/tasks/new",
      "icons": [{ "src": "/icons/new.png", "sizes": "96x96" }]
    }
  ]
}

Link in HTML:

<link rel="manifest" href="/manifest.json">

Key Properties:

display modes:

  • standalone - Looks like native app (recommended)
  • fullscreen - Entire screen
  • minimal-ui - Basic browser UI
  • browser - Regular browser tab

icons:

  • Minimum: 192×192 and 512×512
  • Maskable icons for Android adaptive icons
  • Use solid color background (no transparency)

screenshots:

  • For app store-like install prompt
  • Minimum 320px, maximum 3840px
  • 1-8 screenshots

Step 2: Service Worker Basics

What Is It: JavaScript that runs in background, separate from web page.

Powers:

  • Offline functionality
  • Background sync
  • Push notifications
  • Caching strategies

Lifecycle:

1. Register
2. Install (cache assets)
3. Activate (clean old caches)
4. Fetch (intercept network requests)

Basic Service Worker:

sw.js:

const CACHE_NAME = 'my-pwa-cache-v1';
const urlsToCache = [
  '/',
  '/styles/main.css',
  '/script/main.js',
  '/images/logo.png'
];

// Install event - cache assets
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then((cache) => {
        console.log('Opened cache');
        return cache.addAll(urlsToCache);
      })
  );
});

// Activate event - clean old caches
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.map((cacheName) => {
          if (cacheName !== CACHE_NAME) {
            console.log('Deleting old cache:', cacheName);
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

// Fetch event - serve from cache, fallback to network
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then((response) => {
        // Cache hit - return response
        if (response) {
          return response;
        }
        
        // Clone request
        const fetchRequest = event.request.clone();
        
        return fetch(fetchRequest).then((response) => {
          // Check if valid response
          if (!response || response.status !== 200 || response.type !== 'basic') {
            return response;
          }
          
          // Clone response
          const responseToCache = response.clone();
          
          // Cache the fetched response
          caches.open(CACHE_NAME)
            .then((cache) => {
              cache.put(event.request, responseToCache);
            });
          
          return response;
        });
      })
  );
});

Register Service Worker:

main.js:

if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/sw.js')
      .then((registration) => {
        console.log('SW registered:', registration.scope);
      })
      .catch((error) => {
        console.log('SW registration failed:', error);
      });
  });
}

Step 3: Caching Strategies

Different strategies for different content:

1. Cache First (Static Assets)

Best for: Images, CSS, JS that don’t change often

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then((response) => {
        return response || fetch(event.request);
      })
  );
});

2. Network First (Dynamic Content)

Best for: API calls, user-generated content

self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request)
      .then((response) => {
        // Cache the response
        const responseClone = response.clone();
        caches.open(CACHE_NAME)
          .then((cache) => cache.put(event.request, responseClone));
        return response;
      })
      .catch(() => {
        // Network failed, try cache
        return caches.match(event.request);
      })
  );
});

3. Stale While Revalidate

Best for: Content that updates occasionally

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.match(event.request).then((cachedResponse) => {
        const fetchPromise = fetch(event.request).then((networkResponse) => {
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        });
        
        // Return cached version immediately, update in background
        return cachedResponse || fetchPromise;
      });
    })
  );
});

4. Network Only

Best for: POST requests, analytics

self.addEventListener('fetch', (event) => {
  // Don't cache, just fetch
  event.respondWith(fetch(event.request));
});

5. Cache Only

Best for: App shell (after initial cache)

self.addEventListener('fetch', (event) => {
  event.respondWith(caches.match(event.request));
});

Advanced: Route-Based Caching

self.addEventListener('fetch', (event) => {
  const { request } = event;
  const url = new URL(request.url);
  
  // API calls: Network first
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(request));
  }
  // Images: Cache first
  else if (request.destination === 'image') {
    event.respondWith(cacheFirst(request));
  }
  // HTML: Stale while revalidate
  else if (request.destination === 'document') {
    event.respondWith(staleWhileRevalidate(request));
  }
  // Everything else: Network first
  else {
    event.respondWith(networkFirst(request));
  }
});

async function cacheFirst(request) {
  const cached = await caches.match(request);
  return cached || fetch(request);
}

async function networkFirst(request) {
  try {
    const response = await fetch(request);
    const cache = await caches.open(CACHE_NAME);
    cache.put(request, response.clone());
    return response;
  } catch (error) {
    return caches.match(request);
  }
}

async function staleWhileRevalidate(request) {
  const cache = await caches.open(CACHE_NAME);
  const cached = await cache.match(request);
  
  const fetchPromise = fetch(request).then((response) => {
    cache.put(request, response.clone());
    return response;
  });
  
  return cached || fetchPromise;
}

Step 4: Offline Functionality

Offline Page:

offline.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>You're Offline</title>
  <style>
    body {
      font-family: system-ui;
      display: flex;
      align-items: center;
      justify-content: center;
      height: 100vh;
      margin: 0;
      background: #f5f5f5;
    }
    .offline-container {
      text-align: center;
    }
    h1 {
      color: #666;
    }
  </style>
</head>
<body>
  <div class="offline-container">
    <h1>You're Offline</h1>
    <p>Check your connection and try again.</p>
    <button onclick="window.location.reload()">Retry</button>
  </div>
</body>
</html>

Service Worker:

const OFFLINE_URL = '/offline.html';

// Cache offline page during install
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then((cache) => cache.add(OFFLINE_URL))
  );
});

// Show offline page when network fails
self.addEventListener('fetch', (event) => {
  if (event.request.mode === 'navigate') {
    event.respondWith(
      fetch(event.request)
        .catch(() => {
          return caches.match(OFFLINE_URL);
        })
    );
  }
});

Offline Data Persistence:

IndexedDB for Large Data:

// Store data offline
async function saveOffline(data) {
  const db = await openDB('my-pwa-db', 1, {
    upgrade(db) {
      db.createObjectStore('posts', { keyPath: 'id' });
    }
  });
  
  const tx = db.transaction('posts', 'readwrite');
  await tx.store.put(data);
  await tx.done;
}

// Retrieve data
async function getOfflineData() {
  const db = await openDB('my-pwa-db', 1);
  return db.getAll('posts');
}

Background Sync:

// Register sync when user posts while offline
async function savePost(postData) {
  if ('serviceWorker' in navigator && 'SyncManager' in window) {
    const registration = await navigator.serviceWorker.ready;
    
    // Save post to IndexedDB
    await saveOffline(postData);
    
    // Register sync
    await registration.sync.register('sync-posts');
    
    showNotification('Post will be uploaded when online');
  } else {
    // Fallback: try immediate post
    await fetch('/api/posts', {
      method: 'POST',
      body: JSON.stringify(postData)
    });
  }
}

// In service worker
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-posts') {
    event.waitUntil(syncPosts());
  }
});

async function syncPosts() {
  const posts = await getOfflinePosts();
  
  for (const post of posts) {
    try {
      await fetch('/api/posts', {
        method: 'POST',
        body: JSON.stringify(post)
      });
      
      // Success - remove from offline store
      await deleteOfflinePost(post.id);
    } catch (error) {
      // Failed - will retry on next sync
      console.error('Failed to sync post:', error);
    }
  }
}

Step 5: Push Notifications

Request Permission:

async function requestNotificationPermission() {
  const permission = await Notification.requestPermission();
  
  if (permission === 'granted') {
    console.log('Notification permission granted');
    await subscribeUserToPush();
  } else {
    console.log('Notification permission denied');
  }
}

async function subscribeUserToPush() {
  const registration = await navigator.serviceWorker.ready;
  
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY)
  });
  
  // Send subscription to server
  await fetch('/api/push-subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription)
  });
}

function urlBase64ToUint8Array(base64String) {
  const padding = '='.repeat((4 - base64String.length % 4) % 4);
  const base64 = (base64String + padding)
    .replace(/\-/g, '+')
    .replace(/_/g, '/');
  
  const rawData = window.atob(base64);
  const outputArray = new Uint8Array(rawData.length);
  
  for (let i = 0; i < rawData.length; ++i) {
    outputArray[i] = rawData.charCodeAt(i);
  }
  return outputArray;
}

Handle Push Events (Service Worker):

self.addEventListener('push', (event) => {
  const data = event.data.json();
  
  const options = {
    body: data.body,
    icon: '/icons/icon-192x192.png',
    badge: '/icons/badge-72x72.png',
    vibrate: [200, 100, 200],
    data: {
      url: data.url
    },
    actions: [
      {
        action: 'view',
        title: 'View'
      },
      {
        action: 'close',
        title: 'Close'
      }
    ]
  };
  
  event.waitUntil(
    self.registration.showNotification(data.title, options)
  );
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  
  if (event.action === 'view') {
    event.waitUntil(
      clients.openWindow(event.notification.data.url)
    );
  }
});

Server-Side (Node.js with web-push):

const webpush = require('web-push');

// Set VAPID keys
webpush.setVapidDetails(
  'mailto:your-email@example.com',
  PUBLIC_VAPID_KEY,
  PRIVATE_VAPID_KEY
);

// Send notification
async function sendPushNotification(subscription, payload) {
  try {
    await webpush.sendNotification(subscription, JSON.stringify(payload));
    console.log('Push notification sent');
  } catch (error) {
    console.error('Error sending push:', error);
  }
}

// Example usage
app.post('/api/push-subscribe', (req, res) => {
  const subscription = req.body;
  
  // Save subscription to database
  saveSubscription(subscription);
  
  res.json({ success: true });
});

// Send notification to all subscribers
app.post('/api/notify-all', async (req, res) => {
  const subscriptions = await getAllSubscriptions();
  
  const payload = {
    title: 'New Update!',
    body: 'Check out our latest features',
    url: '/updates'
  };
  
  const promises = subscriptions.map(sub => 
    sendPushNotification(sub, payload)
  );
  
  await Promise.all(promises);
  
  res.json({ sent: subscriptions.length });
});

Step 6: Install Prompt

Detect Install Capability:

let deferredPrompt;

window.addEventListener('beforeinstallprompt', (event) => {
  // Prevent default install prompt
  event.preventDefault();
  
  // Save event for later
  deferredPrompt = event;
  
  // Show custom install button
  document.getElementById('install-button').style.display = 'block';
});

// Custom install button click
document.getElementById('install-button').addEventListener('click', async () => {
  if (!deferredPrompt) {
    return;
  }
  
  // Show install prompt
  deferredPrompt.prompt();
  
  // Wait for user choice
  const { outcome } = await deferredPrompt.userChoice;
  
  console.log(`User ${outcome} the install prompt`);
  
  // Clear prompt
  deferredPrompt = null;
  
  // Hide install button
  document.getElementById('install-button').style.display = 'none';
});

// Detect if already installed
window.addEventListener('appinstalled', (event) => {
  console.log('PWA installed');
  
  // Hide install button
  document.getElementById('install-button').style.display = 'none';
  
  // Track installation
  analytics.track('pwa_installed');
});

// Check if running as installed PWA
if (window.matchMedia('(display-mode: standalone)').matches) {
  console.log('Running as installed PWA');
}

Step 7: App Shell Architecture

Concept: Minimal HTML/CSS/JS to display UI instantly, load content asynchronously.

index.html (App Shell):

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>My PWA</title>
  <link rel="manifest" href="/manifest.json">
  <link rel="stylesheet" href="/styles/app-shell.css">
  
  <!-- Theme color -->
  <meta name="theme-color" content="#4285f4">
  
  <!-- iOS -->
  <meta name="apple-mobile-web-app-capable" content="yes">
  <meta name="apple-mobile-web-app-status-bar-style" content="black">
  <meta name="apple-mobile-web-app-title" content="My PWA">
  <link rel="apple-touch-icon" href="/icons/icon-152x152.png">
</head>
<body>
  <!-- App Shell (loads instantly from cache) -->
  <header>
    <h1>My PWA</h1>
    <nav>
      <a href="/">Home</a>
      <a href="/about">About</a>
    </nav>
  </header>
  
  <main id="content">
    <!-- Loading placeholder -->
    <div class="loader">Loading...</div>
  </main>
  
  <footer>
    <p>&copy; 2022 My PWA</p>
  </footer>
  
  <script src="/js/app.js"></script>
</body>
</html>

app-shell.css (Cached, loads instantly):

/* Minimal CSS for instant paint */
body {
  margin: 0;
  font-family: system-ui;
}

header {
  background: #4285f4;
  color: white;
  padding: 1rem;
}

.loader {
  text-align: center;
  padding: 2rem;
  color: #999;
}

app.js (Load content):

async function loadContent() {
  const content = document.getElementById('content');
  
  try {
    const response = await fetch('/api/content');
    const data = await response.json();
    
    // Replace loader with actual content
    content.innerHTML = renderContent(data);
  } catch (error) {
    content.innerHTML = '<p>Failed to load content</p>';
  }
}

loadContent();

Service Worker (Cache App Shell):

const APP_SHELL = [
  '/',
  '/styles/app-shell.css',
  '/js/app.js',
  '/icons/icon-192x192.png'
];

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open('app-shell-v1')
      .then((cache) => cache.addAll(APP_SHELL))
  );
});

Step 8: Performance Optimization

Lazy Loading:

// Lazy load images
if ('IntersectionObserver' in window) {
  const imageObserver = new IntersectionObserver((entries, observer) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        const img = entry.target;
        img.src = img.dataset.src;
        img.classList.remove('lazy');
        observer.unobserve(img);
      }
    });
  });
  
  document.querySelectorAll('img.lazy').forEach((img) => {
    imageObserver.observe(img);
  });
}

Code Splitting:

// Dynamic import
button.addEventListener('click', async () => {
  const { heavy Function } = await import('./heavy-module.js');
  heavyFunction();
});

Prefetching:

<!-- Prefetch next page -->
<link rel="prefetch" href="/next-page.html">

<!-- Preload critical resources -->
<link rel="preload" href="/fonts/main.woff2" as="font" crossorigin>

Step 9: Testing Your PWA

Lighthouse Audit:

# Install Lighthouse CLI
npm install -g lighthouse

# Run audit
lighthouse https://your-pwa.com --view

# Check PWA score (should be 100)

PWA Criteria (Lighthouse):

Registers a service worker
Responds with 200 when offline
Has a web app manifest
Uses HTTPS
Redirects HTTP to HTTPS
Configured for custom splash screen
Sets theme color
Content sized correctly for viewport
Has valid apple-touch-icon

Chrome DevTools:

1. Open DevTools
2. Application tab
3. Check:
   - Manifest
   - Service Workers
   - Cache Storage
   - IndexedDB
   
4. Test offline:
   - Network tab → Offline checkbox
   - Reload page
   - Should work!

Real-World PWA: To-Do App

Complete Example:

manifest.json:

{
  "name": "PWA To-Do App",
  "short_name": "To-Do",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#4285f4",
  "icons": [
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}

index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>To-Do PWA</title>
  <link rel="manifest" href="/manifest.json">
  <style>
    body {
      font-family: system-ui;
      max-width: 600px;
      margin: 0 auto;
      padding: 20px;
    }
    .todo-item {
      padding: 10px;
      border-bottom: 1px solid #eee;
      display: flex;
      justify-content: space-between;
    }
    .todo-item.completed {
      text-decoration: line-through;
      opacity: 0.6;
    }
  </style>
</head>
<body>
  <h1>To-Do List</h1>
  
  <form id="add-todo-form">
    <input type="text" id="todo-input" placeholder="What needs to be done?" required>
    <button type="submit">Add</button>
  </form>
  
  <div id="todos"></div>
  
  <script src="/js/app.js"></script>
  <script src="/js/sw-register.js"></script>
</body>
</html>

app.js:

let todos = [];

// Load todos from IndexedDB
async function loadTodos() {
  const db = await openDB();
  todos = await db.getAll('todos');
  renderTodos();
}

// Open IndexedDB
function openDB() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open('todo-pwa', 1);
    
    request.onerror = () => reject(request.error);
    request.onsuccess = () => resolve(request.result);
    
    request.onupgradeneeded = (event) => {
      const db = event.target.result;
      db.createObjectStore('todos', { keyPath: 'id', autoIncrement: true });
    };
  });
}

// Add todo
document.getElementById('add-todo-form').addEventListener('submit', async (e) => {
  e.preventDefault();
  
  const input = document.getElementById('todo-input');
  const text = input.value.trim();
  
  if (!text) return;
  
  const todo = {
    text,
    completed: false,
    createdAt: Date.now()
  };
  
  // Save to IndexedDB
  const db = await openDB();
  const tx = db.transaction('todos', 'readwrite');
  const id = await tx.objectStore('todos').add(todo);
  await tx.complete;
  
  todo.id = id;
  todos.push(todo);
  
  renderTodos();
  input.value = '';
});

// Render todos
function renderTodos() {
  const container = document.getElementById('todos');
  
  container.innerHTML = todos.map(todo => `
    <div class="todo-item ${todo.completed ? 'completed' : ''}">
      <span onclick="toggleTodo(${todo.id})">${todo.text}</span>
      <button onclick="deleteTodo(${todo.id})">Delete</button>
    </div>
  `).join('');
}

// Toggle todo
async function toggleTodo(id) {
  const todo = todos.find(t => t.id === id);
  todo.completed = !todo.completed;
  
  const db = await openDB();
  const tx = db.transaction('todos', 'readwrite');
  await tx.objectStore('todos').put(todo);
  await tx.complete;
  
  renderTodos();
}

// Delete todo
async function deleteTodo(id) {
  todos = todos.filter(t => t.id !== id);
  
  const db = await openDB();
  const tx = db.transaction('todos', 'readwrite');
  await tx.objectStore('todos').delete(id);
  await tx.complete;
  
  renderTodos();
}

// Load on start
loadTodos();

sw-register.js:

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js')
    .then(() => console.log('Service Worker registered'))
    .catch((err) => console.error('SW registration failed', err));
}

sw.js:

const CACHE_NAME = 'todo-pwa-v1';
const urlsToCache = [
  '/',
  '/js/app.js',
  '/js/sw-register.js',
  '/icons/icon-192x192.png',
  '/icons/icon-512x512.png'
];

// Install - cache assets
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then((cache) => cache.addAll(urlsToCache))
  );
});

// Activate - clean old caches
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.filter((name) => name !== CACHE_NAME)
          .map((name) => caches.delete(name))
      );
    })
  );
});

// Fetch - cache first
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then((response) => response || fetch(event.request))
  );
});

Result:

  • Works offline
  • Installable
  • Fast
  • Data persists

PWA Checklist

Before Launch:

Manifest

  • name, short_name, start_url
  • display: standalone
  • icons (192×192, 512×512)
  • theme_color, background_color

Service Worker

  • Registered successfully
  • Caches app shell
  • Handles offline requests
  • Updates properly

HTTPS

  • SSL certificate installed
  • HTTP redirects to HTTPS

Responsive

  • Mobile viewport configured
  • Works on all screen sizes

Performance

  • Fast initial load (< 3s)
  • Fast Time to Interactive (< 5s)
  • Smooth animations (60fps)

Offline

  • Works offline
  • Or shows meaningful offline page

Install

  • Installable (passes criteria)
  • Custom install prompt (optional)
  • iOS meta tags

Testing

  • Lighthouse score 90+
  • Tested on multiple devices
  • Tested offline functionality

Conclusion: PWAs Are the Future

Why PWAs Win:

vs. Mobile Websites:

  • Work offline
  • Install to home screen
  • Push notifications
  • Better performance

vs. Native Apps:

  • No app store approval
  • Instant updates
  • Lower development cost
  • Single codebase
  • Discoverability (search engines)

When to Build PWA:

  • Content-focused apps
  • E-commerce
  • News/media
  • Social networks
  • Productivity tools

When Native Still Wins:

  • Need device-specific features
  • Complex games
  • AR/VR applications
  • High-performance requirements

In 2022: PWAs are production-ready for most use cases.

Key Takeaways:

  1. PWAs combine best of web and native apps
  2. Service workers enable offline functionality
  3. Web app manifest makes apps installable
  4. Different caching strategies for different content
  5. Push notifications don’t require app store
  6. App shell architecture ensures fast loading
  7. IndexedDB enables offline data storage
  8. Background sync allows offline writes
  9. Lighthouse audits verify PWA quality
  10. PWAs work cross-platform with single codebase

Ready to build your PWA?

We’ve built PWAs for e-commerce, productivity, and social platforms. Free PWA consultation available.

[Schedule PWA Consultation →]