Back to articles

Advanced PWA Features: Offline Support, Push Notifications & Background Sync

Build Production-Ready Progressive Web Apps with Advanced Capabilities

August 15, 2024
Advanced PWA features showing offline support, push notifications, and background sync capabilities
pwa
frontend
javascript
performance
web development
13 min read

Progressive Web Apps (PWAs) build on basic web apps by adding features like offline support, background sync, and push notifications. These let a PWA work reliably even when the network is flaky and re-engage users when they’re away.

In this article, we’ll cover key advanced features step by step:

  • How to cache and store data for offline use
  • How to use Background Sync to retry tasks when online, and
  • How to implement Push Notifications (including generating VAPID keys and using the web-push library).

We’ll use plain JavaScript (no frameworks) and explain why each feature matters in real-world apps.

Service Worker Setup

First, ensure you have a registered service worker since all these features rely on it. In your main JS (or, app.js), register the service worker on page load:

// app.js
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/service-worker.js')
    .then(reg => console.log('SW registered:', reg.scope))
    .catch(err => console.error('SW registration failed:', err));
}

This creates service-worker.js and allows it to intercept network requests, handle background events, and manage caches.

Once registered, you can use the service worker’s fetch event to serve cached files and the push/sync events for background tasks. The service worker runs in its own thread, so that the main thread can stay responsive to user input while resources are cached in the background.

Offline Caching Strategies

We cache essential resources in the browser’s Cache Storage to make a PWA work offline. There are different strategies:

  • Cache-First (Offline-First): Check the cache before the network. This gives fast responses when cached but risks serving stale data.
  • Network-First (Online-First): Try the network and fall back to the cache if offline. This gives fresh data but is slower if the network is used.
tip

Choose the strategy based on content. Static assets (HTML, CSS) are often cached first, whereas API data might be network-first.

For example, a cache-first strategy in the service worker:

// service-worker.js
self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request).then(cacheRes => {
      if (cacheRes) {
        // Return cached asset
        return cacheRes;
      }
      // Fetch from network and cache it for later
      return fetch(event.request).then(networkRes => {
        return caches.open('dynamic-cache').then(cache => {
          cache.put(event.request, networkRes.clone());
          return networkRes;
        });
      }).catch(() => {
        // If both fail, show fallback (if request is navigation)
        if (event.request.mode === 'navigate') {
          return caches.match('/offline.html');
        }
      });
    })
  );
});

This code tries the cache first (caches.match), then falls back to fetch. If offline and not in the cache, it returns a generic offline page. Serving cached responses makes the work even if network connectivity is problematic.

In fact, from the app’s point of view, it is still making network requests and receiving responses like it would usually do, while the service worker manages caching behind the scenes.

Alternatively, a network-first approach fetches fresh data and then caches it:

self.addEventListener('fetch', event => {
  event.respondWith(
    fetch(event.request).then(networkRes => {
      return caches.open('dynamic-cache').then(cache => {
        cache.put(event.request, networkRes.clone());
        return networkRes;
      });
    }).catch(() => caches.match(event.request))  // fallback to cache if offline
  );
});
bonus

You can also mix strategies. For example, precache an app “shell” (HTML/CSS/JS) in the install event, then use network-first for API calls. Caching static files at install time is common:

self.addEventListener('install', event => {
 event.waitUntil(
   caches.open('static-cache').then(cache => {
     return cache.addAll([
       '/', '/offline.html', '/styles.css', '/app.js', '/logo.png'
     ]);
   })
 );
});

This precaches core assets, allowing the app to load offline. Then, during fetch, we serve these from the cache if available.

Overall, caching lets your app serve content quickly and work offline.

Best practices

  • Use a separate cache for static vs dynamic content.

  • Limit cache sizes (use cache.delete() for old entries).

  • Notify users if they go offline using window.addEventListener('offline', …) in your app.

important

Remember to serve a fallback page or message for navigation requests when offline — so users don’t see a blank screen.

IndexedDB for Offline Data Storage

For data (especially user-generated data or API results) that must survive offline, use IndexedDB. This is a built-in NoSQL database accessible from the main page or service worker.

According to MDN, “IndexedDB provides asynchronous access to persistent storage for PWAs and can be accessed from the main thread, web workers, and service workers”.

It’s ideal for complex or structured data (forms, lists, etc.), whereas Cache Storage is better for Request/Response pairs.

Creating and Using an IndexedDB Database

Here’s a basic setup.

Open a database and create an object store. We do this in the page or SW script:

let db;
const request = indexedDB.open('myAppDB', 1);

request.onupgradeneeded = event => {
  db = event.target.result;
  // Create an object store named 'forms' with auto-incrementing keys
  if (!db.objectStoreNames.contains('forms')) {
    db.createObjectStore('forms', { autoIncrement: true });
  }
};
request.onsuccess = event => {
  db = event.target.result;
  console.log('IndexedDB ready');
};
request.onerror = event => {
  console.error('IndexedDB error:', event.target.error);
};

This code opens (or creates) myAppDB version 1. In onupgradeneeded, we create an object store called forms to hold form data. Once onsuccess fires, db is ready for transactions.

Storing Data Offline

To save data (for example, a form submission) into IndexedDB:

function saveFormData(formData) {
  const tx = db.transaction('forms', 'readwrite');
  const store = tx.objectStore('forms');
  store.add(formData);  // formData can be an object like { name: 'Alice', message: 'Hello'}
  tx.oncomplete = () => console.log('Data saved to IndexedDB');
  tx.onerror = () => console.error('Error saving to IndexedDB');
}

This opens a transaction on the ‘forms’ store and add()s the data object. It’s async; the onsuccess or onerrorcallbacks indicate when it’s done.

tip

In a real world scenario, you might call saveFormData() inside a fetch error handler (when offline) to queue the request instead of sending it.

Retrieving and Syncing Data

Later, when online, we can read all saved items and send them to the server. For example:

function getAllFormData() {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('forms', 'readonly');
    const store = tx.objectStore('forms');
    const request = store.getAll();
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

// Example usage: send data and then clear the store
async function syncFormsToServer() {
  const allData = await getAllFormData();
  for (const data of allData) {
    // e.g., send via fetch to API
    await fetch('/api/submit', { method: 'POST', body: JSON.stringify(data) });
  }
  // Optionally, clear object store after successful sync
  const tx = db.transaction('forms', 'readwrite');
  tx.objectStore('forms').clear();
}

This reads all entries (getAll()) and, in our hypothetical syncFormsToServer, sends them to the server one by one. After success, we clear the store. Using IndexedDB like this ensures no data is lost. While offline, user actions are queued in local storage; when online, we retrieve and sync them.

bonus

To simplify IndexedDB, consider the idb library, which wraps IndexedDB in promises. But it’s good to know the vanilla API for understanding.

Background Sync

Background Sync lets a PWA defer actions until the network is available. For example, a user might submit a form or message offline; with background sync, the service worker can automatically retry when back online.

quote

As MDN explains: “Suppose a user composes an email and presses ‘Send’. In a traditional website, they must keep the tab open until the app has sent the email… Background sync… is the solution to this problem for PWAs”.

Reference: Offline and background operation - Progressive web apps | MDN

Registering a Sync Task

First, the main app must register a sync task. This is done while the app is open (you can’t register sync after the user closes the page). For example, after queueing data in IndexedDB, call:

// Call registerSync() after saving data when offline.
async function registerSync() {
  const registration = await navigator.serviceWorker.ready;
  try {
    await registration.sync.register('sync-forms');
    console.log('Background sync registered');
  } catch (err) {
    console.error('Sync registration failed:', err);
  }
}

This tells the browser: “When the network returns, wake up the SW and fire a sync event with tag sync-forms”. The background sync enables the app to ask its service worker to perform a task on its behalf. As soon as connectivity returns, the browser will restart the service worker and fire an event named sync in the service worker’s scope.

No extra user permission is needed for this API.

Handling the Sync Event in the Service Worker

In service-worker.js, listen for sync events and handle your tagged tasks:

self.addEventListener('sync', event => {
  if (event.tag === 'sync-forms') {
    event.waitUntil(syncFormsToServer());
  }
});

Here, syncFormsToServer() is the function we defined earlier to read from IndexedDB and post to the server. We wrap it in event.waitUntil(), which tells the browser to keep the SW alive until the promise settles.

tip

MDN warns that the browser imposes limits (e.g., tasks must finish in ~5 minutes in Chrome). If the promise is rejected, the browser may retry later.

Putting it together, a simple flow:

  • The user tries to submit data (e.g., a form) but is offline.
  • The app saves the data to IndexedDB and calls registerSync().
  • The page closes or stays idle. When the network returns, Chrome restarts SW and fires the sync event.
  • The SW handler catches event.tag === 'sync-forms', reads stored data, and sends it to the server (using fetch).
  • On success, clear the data from IndexedDB.
note

This happens in the background, even if the user closes the app window. Background Sync is powerful for making offline actions reliable as it asks the browser not to stop the service worker until the promise has settled - effectively resuming the task once online.

Reference: Offline and background operation - Progressive web apps | MDN

Best practices

  • Use a clear tag name (like 'sync-forms').
  • Keep the task short (split large uploads into chunks or consider background fetch for big files).
  • Always call waitUntil() with a promise so the browser knows to keep the SW alive during the work.
  • Remember that sync can only be registered when online (you can’t queue it if completely offline) and that the browser may limit retries.

Push Notifications

Push Notifications let a server send messages to users’ devices even when the PWA isn’t open, significantly improving re-engagement. Users see alerts or banners like native app notifications. This can significantly enhance user engagement and overall experience.

To implement push, you need user permission, a push subscription, and a server to send messages via a push service.

Ask Notification Permission

Before subscribing, request the user’s permission with the Notification API:

if ('Notification' in window && navigator.serviceWorker) {
  Notification.requestPermission().then(permission => {
    if (permission === 'granted') {
      console.log('Permission granted for notifications');
      // proceed to subscribe for push
    } else {
      console.error('Notifications permission denied');
    }
  });
}

This shows a browser prompt. If granted, proceed; if denied, you must stop (browsers won’t prompt again easily). Good UX is to explain why you want permission before calling requestPermission.

Subscribe for Push

Once you have permission and a registered service worker, call PushManager.subscribe():

// Utility to convert the VAPID public key base64 to Uint8Array
function urlBase64ToUint8Array(base64String) {
  // ... conversion code (omitted for brevity) ...
}

// The VAPID public key obtained from your server
const vapidPublicKey = 'YOUR_PUBLIC_VAPID_KEY_FROM_SERVER';

navigator.serviceWorker.ready.then(registration => {
  return registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
  });
}).then(subscription => {
  console.log('Push Subscription:', JSON.stringify(subscription));
  // Send subscription to your server to store it
  return fetch('/save-subscription', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(subscription)
  });
}).catch(err => {
  console.error('Push subscribe failed:', err);
});

We pass userVisibleOnly: true (required) and our application’s VAPID public key as applicationServerKey.

This returns a PushSubscription object containing an endpoint URL and cryptographic keys. Send this JSON to your server and save it to target this user later.

Generate and Use VAPID Keys

VAPID (Voluntary Application Server Identification) authenticates your server with the push service. You must generate a public/private key pair for your app.

The easiest way is with the web-push Node library:

npm install web-push
npx web-push generate-vapid-keys

This outputs a JSON with publicKey and privateKey. Copy these.

The public key goes in the client code (as applicationServerKey above). The private key stays on your server. This offers an extra layer of security for your app.

On your server (e.g., a Node/Express backend), use web-push like this:

const webPush = require('web-push');
const vapidPublicKey = 'YOUR_PUBLIC_KEY';
const vapidPrivateKey = 'YOUR_PRIVATE_KEY';

// Setup VAPID details
webPush.setVapidDetails(
  'mailto:you@example.com',
  vapidPublicKey,
  vapidPrivateKey
);

// Later, when sending a notification:
const pushSubscription = /* the saved subscription object from user */;
const payload = JSON.stringify({ title: 'Hello!', body: 'You have a new message.'});

webPush.sendNotification(pushSubscription, payload)
  .then(() => console.log('Push sent'))
  .catch(err => console.error('Push error', err));
This will send a push message to the browser's push service, which then delivers it to the device.
1. Handle Push Events in Service Worker
In service-worker.js, listen for push events. When a push arrives, show a notification:
self.addEventListener('push', event => {
  let data = { title: 'New!', body: 'You have a push message.' };
  if (event.data) {
    data = event.data.json();  // If you sent JSON payload
  }
  const title = data.title;
  const options = {
    body: data.body,
    icon: data.icon || '/icon.png'
  };
  // Show the notification
  event.waitUntil(self.registration.showNotification(title, options));
});

Here, we parse events.data (if any) and call showNotification(). We pass the promise to event.waitUntil() so the SW stays alive until the notification is shown.

note

web.dev emphasizes that a push event “must display a notification before the promise you passed in has settled”, since browsers require push messages to be user-visible. In other words, every push must result in a showNotification() (or other visible effect) before the service worker can terminate.

Reference: Push events | Articles | web.dev

When your server sends a push via web-push, the browser will wake up the SW and fire the push event, and the code above will display a notification. Tapping the notification can also be handled with self.addEventListener('notificationclick', ...) if you want to open a page.

Best Practices for Performance, Reliability & UX

  • Always use HTTPS. Service workers, push, and most PWA features require a secure origin (HTTPS).
  • Limit and version your caches. Remove old cache data when updating your app.
  • Handle failures gracefully. If a sync or push fails, retry later or log errors. Show user-friendly messages if actions are pending (e.g., “Message will be sent when back online”).
  • Keep the code simple. In the SW, heavy work like parsing extensive data or making big uploads is risky— the SW may get killed if it takes too long. For large uploads, consider the Background Fetch API (beyond this article’s scope).
  • Notify and educate users. Ask permission at an appropriate time and explain why (e.g., “Allow notifications to stay updated with your messages”).
  • Allow unsubscribing. Provide UI for users to revoke notification permission or unsubscribe from the service. Respect the user’s choice.
  • Test offline thoroughly. Use your browser’s dev tools (Application -> Service Workers) to simulate offline and inspect caches. Make sure your app still functions or shows an informative offline page.

Conclusion

Advanced PWA features like Offline Caching, Background Sync, and Push Notifications turn a simple web app into a resilient, app-like experience.

  1. Offline strategies (caching and IndexedDB) allow users to continue browsing or submitting data without connection.
  2. Background Sync automatically retries actions (like sending forms or analytics) when the device reconnects.
  3. Push Notifications let your app re-engage users with timely updates even when they’re away.

To recap, a sample workflow is to register your SW, implement caching strategies and IndexedDB for offline data, request notification permission, generate VAPID keys and subscribe for push, and handle sync/push events in your SW.

Each piece enhances the user experience: faster loads, no “lost” data, and live updates.

Now it’s your turn: try adding background sync or push to your PWA and testing it in various network conditions. You’ll find users staying longer and engaging more when your app “just works” offline and keeps them informed with push alerts.

References

For more details, use the MDN guides on Service Workers and the web-push GitHub docs. These and other sources informed the explanations here, and they contain deeper dives as you explore these features further.

All the best!

You may also be interested in

Automated Lighthouse audit dashboard showing CI/CD performance testing pipeline for Progressive Web Apps

Automate Lighthouse Audits for Performance Testing in CI/CD

June 29, 2019

Complete guide to automating Lighthouse audits for Progressive Web Apps. Learn to set up automated performance testing with Mocha and Chai, integrate with CI/CD pipelines, and maintain consistent web performance standards.

Read article
Core Web Vitals metrics dashboard showing performance optimization results

Core Web Vitals: Real-World Optimization Strategies

January 20, 2025

Master Core Web Vitals optimization for improved SEO and user experience. Learn practical strategies to optimize Largest Contentful Paint, Interaction to Next Paint, and Cumulative Layout Shift with real code examples.

Read article
GitHub Actions workflow for frontend projects

Automating Frontend Workflows with custom webhooks in GitHub Actions

May 23, 2025

Master GitHub Actions automation for frontend projects. Learn to set up CI/CD pipelines with custom webhooks for automatic deployments of Astro, React, and static sites when content changes.

Read article
Scalable React application architecture diagram showing Redux store structure with sagas and services

Scalable React Architecture: Redux, Sagas & Services Pattern

May 11, 2019

Build maintainable React applications with proper Redux architecture. Complete guide to organizing Redux store, sagas, services, and selectors for scalable enterprise React projects with best practices.

Read article
JSON-LD structured data code example with search engine rich results preview showing SEO benefits

JSON-LD Structured Data: Complete SEO Implementation Guide

February 14, 2024

Master JSON-LD structured data implementation for better SEO. Complete guide to Schema.org markup, rich snippets, and search engine optimization with practical examples and best practices.

Read article
React hooks useContext and useReducer implementation replacing Redux for state management

React State Management Without Redux: useContext + useReducer Pattern

May 19, 2019

Learn to manage complex React application state without Redux using useContext and useReducer hooks. Create a scalable, Redux-like state management pattern with zero external dependencies.

Read article