DEV Community

Cover image for PWA Development Guide: Building App-Like Web Experiences with Service Workers and Caching
Nithin Bharadwaj
Nithin Bharadwaj

Posted on

PWA Development Guide: Building App-Like Web Experiences with Service Workers and Caching

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

Building a web application that feels like a native app on your phone or computer is now a standard expectation. This is where Progressive Web Apps (PWAs) come in. They are web applications that use modern web capabilities to provide a reliable, fast, and engaging user experience, similar to what you'd get from an app installed from an app store.

I remember the first time I successfully installed a PWA I had built. Clicking the home screen icon and seeing it launch without an address bar felt like magic. The core idea is simple: use the web platform's strengths and enhance them with specific technologies to close the gap with native apps. Let's look at some practical techniques to make this happen.

Technique 1: Guiding the User to Install Your App

The first interaction many users will have with your PWA's "app-like" nature is the install prompt. Browsers like Chrome, Edge, and Safari have built-in mechanisms to suggest installing a PW. We can listen for this event and control how we present the option to the user.

The key is the beforeinstallprompt event. When the browser determines your site meets PWA criteria (like having a service worker and a web app manifest), it fires this event. We can intercept it, store it, and show our own custom install button at the right moment—perhaps after the user has had a positive interaction with the site.

// A simple class to manage the install flow
class AppInstaller {
  constructor() {
    this.promptEvent = null;
    this.setupListeners();
  }

  setupListeners() {
    // Capture the install prompt event
    window.addEventListener('beforeinstallprompt', (e) => {
      e.preventDefault(); // Stop the browser's automatic prompt
      this.promptEvent = e;
      this.showCustomInstallButton(); // Show your own stylish button
    });

    // Know when the app is successfully installed
    window.addEventListener('appinstalled', () => {
      console.log('App was installed!');
      this.promptEvent = null;
      // You can track the installation here
    });
  }

  showCustomInstallButton() {
    const installBtn = document.getElementById('custom-install-btn');
    if (installBtn) {
      installBtn.classList.remove('hidden');
      installBtn.addEventListener('click', () => this.promptUser());
    }
  }

  async promptUser() {
    if (!this.promptEvent) return;
    // This triggers the native browser installation panel
    this.promptEvent.prompt();
    // Wait for the user's choice
    const { outcome } = await this.promptEvent.userChoice;
    console.log(`User response: ${outcome}`);
    // Hide your button after the prompt
    this.promptEvent = null;
    document.getElementById('custom-install-btn').classList.add('hidden');
  }
}

// Initialize when your app loads
new AppInstaller();
Enter fullscreen mode Exit fullscreen mode

This approach puts you in control. Instead of a browser-generated prompt appearing at an awkward time, you can integrate the offer seamlessly into your interface, explaining the benefits of installation in your own words.

Technique 2: The Service Worker as Your Offline Engine

The service worker is the most critical part of a PWA. It's a script that runs in the background, separate from your web page. Think of it as a network proxy sitting between your app, the browser, and the internet. It can't access the DOM directly, but it can control network requests, manage caches, and enable offline functionality.

Registration must be handled carefully. You only want to register it if the browser supports it, and you should scope it appropriately.

// In your main app JavaScript (e.g., app.js)
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const registration = await navigator.serviceWorker.register('/sw.js', {
        scope: '/' // Controls which pages the SW manages
      });
      console.log('ServiceWorker registered:', registration.scope);

      // Check for updates periodically
      setInterval(() => {
        registration.update();
      }, 60 * 60 * 1000); // Check every hour

    } catch (error) {
      console.error('ServiceWorker registration failed:', error);
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

The service worker file (sw.js) has a distinct lifecycle: install, activate, and then it sits idle until a fetch event (or push, sync). The install event is perfect for pre-caching essential assets the very first time.

Technique 3: Smart Caching for Speed and Reliability

Caching is what makes a PWA fast and reliable offline. The strategy you choose depends on the type of asset.

  • Cache-First for Static Assets: Your app's shell (HTML, core CSS, JavaScript, logo) rarely changes. These are perfect for caching immediately on install and serving from the cache first, always.
  • Network-First for Dynamic Data: For live API calls, like a news feed or user messages, you typically want the freshest data. Try the network first, and only fall back to cache if the network fails.
  • Stale-While-Revalidate: A great hybrid. For assets that can be slightly stale (like user avatars or article lists), serve from cache immediately for a fast response, but then fetch from the network in the background to update the cache for the next visit.

Here’s how you can implement these strategies inside your service worker:

// Inside sw.js
const CACHE_NAME = 'my-app-v1';
const STATIC_CACHE = [
  '/',
  '/index.html',
  '/styles/main.min.css',
  '/scripts/app.min.js',
  '/images/icon-192.png'
];

self.addEventListener('install', event => {
  // Pre-cache static resources during install
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => cache.addAll(STATIC_CACHE))
      .then(() => self.skipWaiting()) // Activate immediately
  );
});

self.addEventListener('activate', event => {
  // Clean up old caches when a new SW activates
  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames.map(name => {
          if (name !== CACHE_NAME) {
            return caches.delete(name); // Remove old cache
          }
        })
      );
    }).then(() => self.clients.claim()) // Take control of all open pages
  );
});

self.addEventListener('fetch', event => {
  const request = event.request;
  const url = new URL(request.url);

  // Strategy 1: Cache-First for static assets from our origin
  if (url.origin === location.origin && isStaticAsset(request)) {
    event.respondWith(cacheFirst(request));
    return;
  }

  // Strategy 2: Network-First for API calls
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(request));
    return;
  }

  // Strategy 3: Generic Network-with-Cache-Fallback
  event.respondWith(networkFallback(request));
});

async function cacheFirst(request) {
  const cached = await caches.match(request);
  if (cached) return cached;
  return fetch(request); // Should rarely happen if pre-cached
}

async function networkFirst(request) {
  try {
    const networkResponse = await fetch(request);
    // Optionally cache a successful API response for offline reading
    if (networkResponse.ok && request.method === 'GET') {
      const cache = await caches.open('api-data');
      cache.put(request, networkResponse.clone());
    }
    return networkResponse;
  } catch (error) {
    const cached = await caches.match(request);
    if (cached) return cached;
    // Return a meaningful offline state for APIs
    return new Response(JSON.stringify({ offline: true }), {
      headers: { 'Content-Type': 'application/json' }
    });
  }
}

async function networkFallback(request) {
  try {
    return await fetch(request);
  } catch (error) {
    const cached = await caches.match(request);
    if (cached) return cached;
    // For HTML requests, show a custom offline page
    if (request.headers.get('Accept').includes('text/html')) {
      return caches.match('/offline.html');
    }
    // For other requests (images, etc.), maybe return a placeholder
    return new Response('You are offline.');
  }
}

function isStaticAsset(request) {
  return request.url.match(/\.(js|css|png|jpg|ico|svg|woff2)$/);
}
Enter fullscreen mode Exit fullscreen mode

Technique 4: Creating a Purposeful Offline Experience

"Offline" doesn't have to mean "broken." A good PWA anticipates disconnection. Beyond caching, you should design a user interface that communicates state.

Always have a fallback offline HTML page for navigation errors. More importantly, for single-page applications (SPAs), your core app shell JavaScript should be cached. This means even if you're offline, the app can still boot up, show cached views, and display a clear message like "No network connection. Showing cached data."

I often add a small network status indicator to the UI that updates based on the navigator.onLine property and the service worker's cache readiness.

// In your main app code
function updateNetworkStatusUI() {
  const statusEl = document.getElementById('network-status');
  if (!navigator.onLine) {
    statusEl.textContent = 'Offline Mode';
    statusEl.classList.add('offline');
  } else {
    statusEl.textContent = 'Online';
    statusEl.classList.remove('offline');
  }
}

window.addEventListener('online', updateNetworkStatusUI);
window.addEventListener('offline', updateNetworkStatusUI);
// Call it once on load
updateNetworkStatusUI();
Enter fullscreen mode Exit fullscreen mode

Technique 5: Background Sync for Offline Actions

What if a user submits a form while offline? With the Background Sync API, you can queue that action and have the service worker send it automatically once the connection is restored.

First, in your page code, you request a sync after an offline action.

// In your page, after a user submits a comment offline
async function saveComment(commentData) {
  // 1. Save locally to IndexedDB
  await saveToLocalDB(commentData);

  // 2. Register a background sync
  if ('serviceWorker' in navigator && 'SyncManager' in window) {
    const reg = await navigator.serviceWorker.ready;
    try {
      await reg.sync.register('sync-new-comments');
      console.log('Background sync registered!');
    } catch (error) {
      console.log('Background sync registration failed:', error);
    }
  } else {
    // Fallback: try to send immediately
    postCommentToServer(commentData);
  }
}
Enter fullscreen mode Exit fullscreen mode

Then, your service worker listens for that sync event.

// In sw.js
self.addEventListener('sync', event => {
  if (event.tag === 'sync-new-comments') {
    console.log('Sync event fired! Connection is back.');
    event.waitUntil(syncCommentsToServer());
  }
});

async function syncCommentsToServer() {
  const db = await getLocalDB();
  const pendingComments = await db.getAll('pendingComments');

  for (const comment of pendingComments) {
    try {
      const response = await fetch('/api/comments', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(comment.data)
      });

      if (response.ok) {
        // Remove from local DB on success
        await db.delete('pendingComments', comment.id);
        // Notify the open page
        notifyClient('Comment synced successfully!');
      }
    } catch (error) {
      console.error('Sync failed for comment:', comment.id);
      // The sync will retry automatically later
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This creates a seamless experience. The user can take actions anytime, and the app handles the complexity of sending them later.

Technique 6: Re-engagement with Push Notifications

Push notifications are a powerful way to bring users back to your app. They work even when your app's tab is closed. The flow involves two main parts: asking for permission and handling the incoming push message.

First, request permission from the user in a context where it makes sense.

// In your page
async function subscribeToPushNotifications() {
  if (!('serviceWorker' in navigator)) return;

  const reg = await navigator.serviceWorker.ready;
  // Request permission from the user
  const permission = await Notification.requestPermission();

  if (permission !== 'granted') {
    alert('You denied notification permissions.');
    return;
  }

  // Get a unique PushSubscription object from the browser
  const subscription = await reg.pushManager.subscribe({
    userVisibleOnly: true, // Important: always show a notification
    applicationServerKey: urlBase64ToUint8Array('YOUR_PUBLIC_VAPID_KEY')
  });

  // Send this subscription object to your server
  await fetch('/api/push-subscription', {
    method: 'POST',
    body: JSON.stringify(subscription),
    headers: { 'Content-Type': 'application/json' }
  });

  console.log('User is subscribed to push!');
}
Enter fullscreen mode Exit fullscreen mode

Your server stores these subscriptions. When you want to send a notification, your server sends a web push payload to a URL specified in the subscription.

The service worker then receives the push event, even if the app is closed, and displays the notification.

// In sw.js
self.addEventListener('push', event => {
  let data = { title: 'New Update!', body: 'Something happened.' };
  try {
    data = event.data.json(); // If your server sends JSON
  } catch (e) {
    console.log('Push data is not JSON:', event.data.text());
  }

  const options = {
    body: data.body,
    icon: '/images/icon-192.png',
    badge: '/images/badge-96.png',
    data: { url: data.url || '/' }, // URL to open on click
    actions: [
      { action: 'view', title: 'Open' },
      { action: 'close', title: 'Close' }
    ]
  };

  event.waitUntil(
    self.registration.showNotification(data.title, options)
  );
});

// Handle when the user clicks the notification
self.addEventListener('notificationclick', event => {
  event.notification.close();

  const urlToOpen = event.notification.data.url;

  event.waitUntil(
    clients.matchAll({ type: 'window' }).then(windowClients => {
      // Check if the app is already open in a tab
      for (const client of windowClients) {
        if (client.url === urlToOpen && 'focus' in client) {
          return client.focus();
        }
      }
      // Otherwise, open a new window/tab
      if (clients.openWindow) {
        return clients.openWindow(urlToOpen);
      }
    })
  );
});
Enter fullscreen mode Exit fullscreen mode

Technique 7: Making Your App Feel Like Home (The Web App Manifest)

The manifest.json file is a simple JSON file that tells the browser about your app and how it should behave when 'installed' on a user's device. It controls the app icon, splash screen colors, display mode, and orientation.

{
  "name": "My Awesome PWA",
  "short_name": "AwesomeApp",
  "description": "An amazing app-like experience on the web.",
  "start_url": "/?source=pwa",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#3f51b5",
  "orientation": "portrait-primary",
  "icons": [
    {
      "src": "/images/icon-192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "/images/icon-512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ],
  "scope": "/",
  "categories": ["productivity", "utilities"]
}
Enter fullscreen mode Exit fullscreen mode

Link this file in the <head> of your HTML: <link rel="manifest" href="/manifest.json">. This small file is what allows the browser to generate the "Add to Home Screen" prompt and gives your launched app its native feel.

Technique 8: Keeping Everything Up to Date

A PWA is not a static thing. You will update its code. How do you ensure users get the new version? The service worker lifecycle handles this elegantly.

When you update your sw.js file, the browser detects the byte difference. It installs the new service worker in the background (install event), but the old one remains in control. The new one enters a waiting state.

You can prompt the user to reload to get the update, or you can make the new service worker take control immediately for all open tabs using self.skipWaiting() during install and self.clients.claim() during activation (as shown in Technique 3's code). The latter is more aggressive but provides a seamless update. For critical updates, you might want to show a refresh prompt.

// In your main app, listening for a controller change
let refreshing = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
  if (refreshing) return;
  refreshing = true;
  // Option 1: Auto-reload
  window.location.reload();

  // Option 2: Show a custom "Update Available" toast notification
  // that the user can click to refresh.
});
Enter fullscreen mode Exit fullscreen mode

Putting all these techniques together transforms a website into a resilient, engaging application. It starts with the foundational service worker and cache, builds up through offline and sync capabilities, and is polished with install prompts, push notifications, and a proper manifest. The result is a single codebase that delivers a high-quality experience across phones, tablets, and desktops, blurring the line between the web and native platforms. The best part is starting simple—a basic service worker and a manifest—and then progressively enhancing your app with more of these features over time.

📘 Checkout my latest ebook for free on my channel!

Be sure to like, share, comment, and subscribe to the channel!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Top comments (0)