What Makes a Web App “Progressive”?
A Progressive Web App (PWA) is a web application that uses modern browser APIs to deliver an experience that feels like a native app. PWAs can be installed on a user’s home screen, work offline, send push notifications, and load instantly on repeat visits. The best part is that you build them with standard web technologies: HTML, CSS, and JavaScript.
Three core pieces make a PWA work: a web app manifest that describes the application, a service worker that handles caching and offline behavior, and HTTPS to ensure security. In this guide we will build each piece from scratch.
Step 1: The Web App Manifest
The manifest is a JSON file that tells the browser about your application. It controls how the app appears when installed: its name, icons, theme color, and how it launches.
{
"name": "ByteYogi Task Manager",
"short_name": "Tasks",
"description": "A lightweight task manager that works offline",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#2563eb",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icons/icon-512-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"screenshots": [
{
"src": "/screenshots/home.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
}
]
}
Link the manifest in your HTML head:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#2563eb">
<link rel="manifest" href="/manifest.json">
<link rel="apple-touch-icon" href="/icons/icon-192.png">
<title>ByteYogi Tasks</title>
</head>
<body>
<div id="app">
<h1>Task Manager</h1>
<div id="task-list"></div>
<form id="task-form">
<input type="text" id="task-input" placeholder="Add a task..." required>
<button type="submit">Add</button>
</form>
</div>
<script src="/app.js"></script>
</body>
</html>
Step 2: Registering the Service Worker
The service worker is a JavaScript file that runs in the background, separate from your web page. It intercepts network requests, manages a cache, and enables offline functionality. You register it from your main application script.
// app.js
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/'
});
console.log('Service Worker registered:', registration.scope);
// Check for updates periodically
setInterval(() => {
registration.update();
}, 60 * 60 * 1000); // every hour
} catch (error) {
console.error('Service Worker registration failed:', error);
}
});
}
// Simple task manager logic
const form = document.getElementById('task-form');
const input = document.getElementById('task-input');
const taskList = document.getElementById('task-list');
function loadTasks() {
const tasks = JSON.parse(localStorage.getItem('tasks') || '[]');
taskList.innerHTML = tasks.map((task, i) =>
`<div class="task">
<span>${task}</span>
<button onclick="deleteTask(${i})">Delete</button>
</div>`
).join('');
}
form.addEventListener('submit', (e) => {
e.preventDefault();
const tasks = JSON.parse(localStorage.getItem('tasks') || '[]');
tasks.push(input.value);
localStorage.setItem('tasks', JSON.stringify(tasks));
input.value = '';
loadTasks();
});
function deleteTask(index) {
const tasks = JSON.parse(localStorage.getItem('tasks') || '[]');
tasks.splice(index, 1);
localStorage.setItem('tasks', JSON.stringify(tasks));
loadTasks();
}
loadTasks();
Step 3: Building the Service Worker
The service worker lifecycle has three key phases: install (cache essential assets), activate (clean up old caches), and fetch (serve cached content or fall back to the network). Here is a complete service worker implementation:
// sw.js
const CACHE_NAME = 'byteyogi-tasks-v1';
const STATIC_ASSETS = [
'/',
'/index.html',
'/app.js',
'/styles.css',
'/icons/icon-192.png',
'/icons/icon-512.png'
];
// Install: pre-cache static assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => {
console.log('Caching static assets');
return cache.addAll(STATIC_ASSETS);
})
.then(() => self.skipWaiting())
);
});
// Activate: clean up old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => {
console.log('Deleting old cache:', name);
return caches.delete(name);
})
);
})
.then(() => self.clients.claim())
);
});
// Fetch: network-first strategy for API calls,
// cache-first for static assets
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
if (url.pathname.startsWith('/api/')) {
// Network-first for API requests
event.respondWith(networkFirst(request));
} else {
// Cache-first for static assets
event.respondWith(cacheFirst(request));
}
});
async function cacheFirst(request) {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
try {
const networkResponse = await fetch(request);
if (networkResponse.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
return new Response('Offline', {
status: 503,
statusText: 'Service Unavailable'
});
}
}
async function networkFirst(request) {
try {
const networkResponse = await fetch(request);
if (networkResponse.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
return new Response(
JSON.stringify({ error: 'Offline and no cached data' }),
{
status: 503,
headers: { 'Content-Type': 'application/json' }
}
);
}
}
Step 4: Advanced Caching Strategies
The Cache API is flexible enough to support several strategies. The two most common are cache-first (serve from cache, fall back to network) and network-first (try network, fall back to cache). A third useful strategy is stale-while-revalidate, which serves the cached version immediately while fetching an updated version in the background:
async function staleWhileRevalidate(request) {
const cache = await caches.open(CACHE_NAME);
const cachedResponse = await cache.match(request);
const fetchPromise = fetch(request).then((networkResponse) => {
if (networkResponse.ok) {
cache.put(request, networkResponse.clone());
}
return networkResponse;
}).catch(() => cachedResponse);
return cachedResponse || fetchPromise;
}
Testing Your PWA
Chrome DevTools provides excellent PWA debugging support. Open the Application tab to inspect your manifest, service worker status, and cached resources. The Lighthouse audit tool scores your PWA on installability, offline support, and performance.
Run a quick audit from the command line:
# Install Lighthouse CLI
npm install -g lighthouse
# Run a PWA audit
lighthouse https://localhost:3000 --only-categories=pwa --output=html --output-path=./pwa-report.html
Key Takeaways
Building a PWA from scratch requires just three ingredients: a manifest for installability, a service worker for offline caching, and HTTPS for security. The caching strategies you choose determine how your app behaves when the network is unreliable. Start with cache-first for static assets and network-first for dynamic data, then layer in stale-while-revalidate as your needs grow. PWAs remain one of the best ways to deliver fast, reliable, app-like experiences on the web without the overhead of native app development.