It's 2025. The line between "web app" and "native app" is thinner than a Safaricom data bundle lasting through the weekend.
Progressive Web Apps (PWAs) let your website act like a native app - installable on home screens, working offline, sending push notifications. All from one codebase, no app store approval needed.
Think of it like this: a PWA is to a regular website what M-PESA is to bank transfers. Same core function, but way more accessible and doesn't require you to physically go somewhere or download a 200MB app.
The best part? In 2025, PWA support is excellent across all platforms, including iOS (finally!). Let's build one.
Deciding between PWA and native? Read mobile-first, web-first, or both to understand the trade-offs. And regardless of platform, state management principles apply to PWAs just like any modern web app.
What Makes a PWA a PWA?
Three non-negotiable requirements:
- HTTPS - Security first (localhost exempt for testing)
- Web App Manifest - Tells browsers how your app looks
- Service Worker - Handles offline and caching magic
That's it. No complex build tools, no native code, just good old JavaScript, HTML, and CSS with some extra superpowers.
Step 1: The Manifest (manifest.json)
The manifest is like your app's ID card. It tells the device "I'm an app, treat me like one."
Create manifest.json in your root directory:
{
"name": "MWANGI Developer Blog",
"short_name": "MWANGI",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0a0a",
"theme_color": "#29b5ed",
"description": "I speak JAVA, Kotlin, Dart, Swift, Typescript, C#, and 13 other minor languages - like CSS.",
"icons": [
{
"src": "/assets/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/assets/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"screenshots": [
{
"src": "/assets/screenshots/mobile-home.png",
"sizes": "1080x1920",
"type": "image/png",
"form_factor": "narrow"
},
{
"src": "/assets/screenshots/desktop-home.png",
"sizes": "1920x1080",
"type": "image/png",
"form_factor": "wide"
}
]
}
2025 Pro Tips:
purpose: "any maskable"makes your icon look good on all Android shapes (circles, squares, rounded squares)screenshotsare now crucial - they give users a "rich install" preview (like an app store)display: "standalone"hides the browser UI, making it feel truly app-like
Link it in your HTML head:
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#29b5ed">
Step 2: Register Your Service Worker
Service workers are JavaScript files that run in the background, separate from your web page. They're like the matatu conductor who keeps working even when passengers are asleep.
Add this to your main JavaScript file:
// Register service worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('SW registered:', registration.scope);
})
.catch(error => {
console.error('SW registration failed:', error);
});
});
}
Simple. Clean. Works everywhere.
Step 3: The Service Worker (sw.js)
This is where the magic happens. We'll implement two caching strategies:
- Cache First for assets (CSS, JS, images) - instant loading
- Network First for HTML pages - fresh content with offline fallback
Create sw.js in your root:
const CACHE_NAME = 'mwangi-v1';
const ASSETS_TO_CACHE = [
'/',
'/index.php',
'/assets/css/main.css',
'/assets/js/main.js',
'/assets/img/mwangi-logo.png',
'/offline.html'
];
// Install: Cache core assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
console.log('Caching app shell');
return cache.addAll(ASSETS_TO_CACHE);
})
);
self.skipWaiting(); // Activate immediately
});
// Activate: Clean up old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) {
console.log('Deleting old cache:', cacheName);
return caches.delete(cacheName);
}
})
);
})
);
self.clients.claim();
});
// Fetch: Handle requests with smart strategies
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return;
const url = new URL(event.request.url);
// Network First for HTML (pages always fresh)
if (event.request.headers.get('accept').includes('text/html')) {
event.respondWith(
fetch(event.request)
.then((response) => {
// Cache the fresh copy
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
return response;
})
.catch(() => {
// Network failed, try cache
return caches.match(event.request).then((cached) => {
return cached || caches.match('/offline.html');
});
})
);
return;
}
// Cache First for assets (instant loading)
event.respondWith(
caches.match(event.request).then((cached) => {
if (cached) return cached;
return fetch(event.request).then((response) => {
return caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, response.clone());
return response;
});
});
})
);
});
What This Does:
- Pages load instantly from cache while fetching fresh content in background
- Assets load from cache immediately (zero latency)
- Offline mode shows cached version or custom offline page
- Old caches automatically cleaned up on updates
Step 4: Custom Install Button
Don't rely on the browser's default "Add to Home Screen" prompt. Create your own button for better UX:
<button id="install-btn" hidden>Install App</button>
let deferredPrompt;
const installBtn = document.getElementById('install-btn');
// Listen for beforeinstallprompt event
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
installBtn.hidden = false; // Show install button
installBtn.addEventListener('click', async () => {
installBtn.hidden = true;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
console.log('User installed the PWA');
}
deferredPrompt = null;
});
});
// Track successful installs
window.addEventListener('appinstalled', () => {
console.log('PWA installed successfully');
// Send to analytics
});
Step 5: Push Notifications (Optional)
Push notifications are powerful but easy to abuse. Only ask for permission when you have a good reason.
async function subscribeToPush() {
const permission = await Notification.requestPermission();
if (permission === 'granted') {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: 'YOUR_PUBLIC_VAPID_KEY'
});
// Send subscription to server
await fetch('/api/subscribe', {
method: 'POST',
body: JSON.stringify(subscription),
headers: { 'Content-Type': 'application/json' }
});
}
}
Handle push events in sw.js:
self.addEventListener('push', (event) => {
const data = event.data ? event.data.json() : {};
const options = {
body: data.body || 'New content available!',
icon: '/assets/icons/icon-192x192.png',
badge: '/assets/icons/badge.png',
data: { url: data.url || '/' }
};
event.waitUntil(
self.registration.showNotification(
data.title || 'MWANGI Blog',
options
)
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
});
Creating an Offline Page
Create /offline.html for when users are offline:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Offline - MWANGI</title>
<style>
body {
font-family: system-ui;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
background: #0a0a0a;
color: #fafafa;
}
.container {
text-align: center;
padding: 2rem;
}
h1 { color: #29b5ed; }
</style>
</head>
<body>
<div class="container">
<h1>You're Offline</h1>
<p>No worries! Check out cached articles below.</p>
<a href="/" style="color: #29b5ed;">Go to Home</a>
</div>
</body>
</html>
2025 Best Practices
- Maskable Icons: Use
purpose: "any maskable"in manifest - critical for Android - Screenshots: Add them to manifest for rich install UI
- Performance: Run Lighthouse audit - aim for PWA score of 100
- iOS Support: Add
<link rel="apple-touch-icon">for best iOS experience - Update Strategy: Version your cache name (
v2,v3) to force updates
Testing Your PWA
- Open Chrome DevTools → Application tab
- Check "Service Workers" - should show registered
- Check "Manifest" - validates your manifest.json
- Run Lighthouse audit (PWA category)
- Test offline: DevTools → Network → Offline checkbox
Common Issues
Issue: Service worker not updating Fix: Change CACHE_NAME to force update
Issue: Install prompt not showing Fix: Must be HTTPS, must have valid manifest, must wait 30 seconds after first visit
Issue: iOS not showing install prompt Fix: iOS requires user to manually "Add to Home Screen" from Share menu
The Result
Your website is now:
- ✅ Installable on any device
- ✅ Works offline
- ✅ Loads instantly (cache-first assets)
- ✅ Can send push notifications
- ✅ Feels like a native app
All with zero app store submission, zero native code, and one codebase for all platforms.
Welcome to the future of web development. It's been here, you just needed to enable it.
PWAs work best with API-driven architectures - learn API-first development to build backends that serve both PWAs and native apps from one API.
For deployment, host your PWA static assets on S3 + CloudFront CDN for global performance. Don't forget dark mode best practices and UX design principles to make your PWA feel truly native.
Today is a great day to make your website a PWA. Your users (and their data bundles) will thank you!