Progressive Web Apps (PWA): Building Native-Like Web Experiences

8 december 2024 · CodeMatic Team

Progressive Web Apps

Progressive Web Apps (PWAs) combine the best of web and native applications. They're installable, work offline, and provide a native-like experience. This guide covers everything you need to build production-ready PWAs.

What Makes a PWA?

  • Installable: Users can install on their devices
  • Offline Capable: Works without internet connection
  • Fast: Loads quickly, even on slow networks
  • Responsive: Works on any device and screen size
  • Secure: Served over HTTPS
  • Engaging: Push notifications and background sync

Service Workers: The Heart of PWAs

Service Workers are JavaScript files that act as a proxy between your app and the network. They enable offline functionality, background sync, and push notifications.

Basic Service Worker Setup

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

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

// Fetch event - serve from cache, fallback to network
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then((response) => {
        // Return cached version or fetch from network
        return response || fetch(event.request);
      })
  );
});

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

Registering Service Worker

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

Advanced Caching Strategies

Cache First (Static Assets)

self.addEventListener('fetch', (event) => {
  if (event.request.destination === 'image' || 
      event.request.destination === 'style') {
    event.respondWith(
      caches.match(event.request).then((response) => {
        return response || fetch(event.request).then((fetchResponse) => {
          return caches.open(CACHE_NAME).then((cache) => {
            cache.put(event.request, fetchResponse.clone());
            return fetchResponse;
          });
        });
      })
    );
  }
});

Network First (Dynamic Content)

self.addEventListener('fetch', (event) => {
  if (event.request.url.includes('/api/')) {
    event.respondWith(
      fetch(event.request)
        .then((response) => {
          const responseClone = response.clone();
          caches.open(CACHE_NAME).then((cache) => {
            cache.put(event.request, responseClone);
          });
          return response;
        })
        .catch(() => {
          return caches.match(event.request);
        })
    );
  }
});

Stale-While-Revalidate

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 cachedResponse || fetchPromise;
      });
    })
  );
});

Web App Manifest

The manifest file tells the browser how your app should behave when installed.

// manifest.json
{
  "name": "CodeMatic PWA",
  "short_name": "CodeMatic",
  "description": "Progressive Web App example",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#0F172A",
  "theme_color": "#28C55E",
  "orientation": "portrait-primary",
  "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"
    }
  ],
  "screenshots": [
    {
      "src": "/screenshots/desktop.png",
      "sizes": "1280x720",
      "type": "image/png",
      "form_factor": "wide"
    }
  ],
  "categories": ["productivity", "business"],
  "shortcuts": [
    {
      "name": "Dashboard",
      "short_name": "Dashboard",
      "description": "Open dashboard",
      "url": "/dashboard",
      "icons": [{ "src": "/icons/dashboard.png", "sizes": "96x96" }]
    }
  ]
}

Linking Manifest in HTML

<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#28C55E">
<link rel="apple-touch-icon" href="/icons/icon-192.png">

Background Sync

Background Sync allows your app to defer actions until the user has connectivity.

// Register background sync
async function syncData() {
  const registration = await navigator.serviceWorker.ready;
  
  try {
    await registration.sync.register('sync-data');
    console.log('Background sync registered');
  } catch (error) {
    console.log('Background sync failed:', error);
  }
}

// In Service Worker
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-data') {
    event.waitUntil(
      fetch('/api/sync', {
        method: 'POST',
        body: JSON.stringify(getPendingData()),
      })
    );
  }
});

Push Notifications

Engage users with push notifications even when your app is closed.

Requesting Permission and Subscribing

async function subscribeToPush() {
  const registration = await navigator.serviceWorker.ready;
  
  try {
    const subscription = await registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: urlBase64ToUint8Array(
        'YOUR_VAPID_PUBLIC_KEY'
      ),
    });
    
    // Send subscription to server
    await fetch('/api/push-subscribe', {
      method: 'POST',
      body: JSON.stringify(subscription),
    });
  } catch (error) {
    console.error('Push subscription failed:', error);
  }
}

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

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

PWA in Next.js

Next.js has excellent PWA support. Use next-pwa for easy integration.

// next.config.js
const withPWA = require('next-pwa')({
  dest: 'public',
  register: true,
  skipWaiting: true,
  disable: process.env.NODE_ENV === 'development',
});

module.exports = withPWA({
  // Your Next.js config
});

// public/manifest.json (as shown above)

// Install prompt component
function InstallPWA() {
  const [deferredPrompt, setDeferredPrompt] = useState(null);
  
  useEffect(() => {
    window.addEventListener('beforeinstallprompt', (e) => {
      e.preventDefault();
      setDeferredPrompt(e);
    });
  }, []);
  
  const handleInstall = async () => {
    if (deferredPrompt) {
      deferredPrompt.prompt();
      const { outcome } = await deferredPrompt.userChoice;
      if (outcome === 'accepted') {
        setDeferredPrompt(null);
      }
    }
  };
  
  return deferredPrompt && (
    <button onClick={handleInstall}>
      Install App
    </button>
  );
}

Performance Metrics for PWAs

  • Lighthouse Score: Aim for 90+ in all categories
  • First Contentful Paint: < 1.8s
  • Time to Interactive: < 3.8s
  • Offline Functionality: Core features work offline
  • Installable: Meets installability criteria

Real-World Example

We built a PWA for a restaurant ordering system:

  • Offline menu browsing with image caching
  • Order queueing when offline, sync when online
  • Push notifications for order status updates
  • Installable on all devices
  • Result: 40% increase in repeat orders, 60% faster load times

Conclusion

PWAs offer a powerful way to create native-like experiences on the web. Start with service workers for offline functionality, add a manifest for installability, then enhance with push notifications and background sync. Focus on performance and user experience to create compelling PWAs.