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();
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);
}
});
}
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)$/);
}
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();
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);
}
}
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
}
}
}
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!');
}
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);
}
})
);
});
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"]
}
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.
});
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)