Advanced PWA Features: Offline Support, Push Notifications & Background Sync
Build Production-Ready Progressive Web Apps with Advanced Capabilities

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.
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
);
});
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.
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.
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.
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.
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.
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 (usingfetch
). - On success, clear the data from IndexedDB.
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.
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.
- Offline strategies (caching and IndexedDB) allow users to continue browsing or submitting data without connection.
- Background Sync automatically retries actions (like sending forms or analytics) when the device reconnects.
- 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!