If you’ve ever wondered how to build a progressive web app that installs like a native app, works offline, and loads in milliseconds, you’re in the right place. In this hands-on tutorial, we’ll turn a plain HTML/CSS/JS project into a fully working PWA. No frameworks required, no fluff, just code you can copy, tweak, and ship today.
By the end of this guide, you’ll have a working demo project with a valid manifest, a registered service worker, offline caching, and a custom install prompt.
What is a Progressive Web App (and why should you care in 2026)?
A Progressive Web App (PWA) is a website built with standard web technologies (HTML, CSS, JavaScript) that behaves like a platform-specific app. It can be installed on the home screen, launched in its own window, receive push notifications, and work offline thanks to a service worker.
PWAs are very much still a thing in 2026. Browser support is now universal, Apple has significantly improved iOS PWA capabilities, and Microsoft, Google, and Samsung stores all accept PWAs as regular apps. For most small-to-medium projects, a PWA replaces the need to maintain three separate codebases (web, iOS, Android).
PWA vs Native App: quick comparison
| Feature | PWA | Native App |
|---|---|---|
| Codebase | Single (web) | One per platform |
| Distribution | URL or app store | App store only |
| Install size | Usually less than 1 MB | 20 MB and up |
| Offline support | Yes (via service worker) | Yes |
| Update process | Automatic on next visit | Manual store update |
| Development cost | Low | High |

Prerequisites
- Basic knowledge of HTML, CSS, and JavaScript
- A code editor (VS Code recommended)
- Node.js installed (we’ll use it to serve files over HTTPS locally)
- A modern browser (Chrome, Edge, Firefox, or Safari)
Important: PWAs require HTTPS. localhost is treated as secure, so we can test locally without a certificate.
Step 1: Create the Demo Project Structure
Let’s build a tiny “Quick Notes” app that saves notes locally and works offline. Create the following folder structure:
quick-notes-pwa/
├── index.html
├── styles.css
├── app.js
├── sw.js
├── manifest.webmanifest
└── icons/
├── icon-192.png
└── icon-512.png
index.html
<!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.webmanifest">
<link rel="icon" href="icons/icon-192.png">
<link rel="stylesheet" href="styles.css">
<title>Quick Notes PWA</title>
</head>
<body>
<header>
<h1>Quick Notes</h1>
<button id="installBtn" hidden>Install App</button>
</header>
<main>
<textarea id="noteInput" placeholder="Type a note..."></textarea>
<button id="saveBtn">Save note</button>
<ul id="noteList"></ul>
</main>
<script src="app.js"></script>
</body>
</html>
styles.css
* { box-sizing: border-box; font-family: system-ui, sans-serif; }
body { margin: 0; padding: 1rem; background: #f5f5f5; }
header { display: flex; justify-content: space-between; align-items: center; }
textarea { width: 100%; min-height: 100px; padding: 0.5rem; }
button { background: #2563eb; color: white; border: none; padding: 0.6rem 1rem; border-radius: 6px; cursor: pointer; }
ul { list-style: none; padding: 0; }
li { background: white; padding: 0.75rem; margin: 0.5rem 0; border-radius: 6px; }
Step 2: Create the Web App Manifest
The manifest tells the browser how your app should appear when installed. Create manifest.webmanifest:
{
"name": "Quick Notes",
"short_name": "Notes",
"description": "A tiny offline-first notes app.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#ffffff",
"theme_color": "#2563eb",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
Key manifest properties explained
- name / short_name: Full name and home screen label
- start_url: The URL loaded when the app launches
- display:
standalonehides the browser UI - theme_color: Color of the OS toolbar
- icons: You need at least a 192×192 and a 512×512 icon for installability
Step 3: Register the Service Worker
A service worker is a JavaScript file that runs in the background, separate from your page. It intercepts network requests, enabling offline support and caching.
Add this to your app.js:
// Register the service worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(reg => console.log('SW registered:', reg.scope))
.catch(err => console.error('SW registration failed:', err));
});
}
// Basic notes logic using localStorage
const input = document.getElementById('noteInput');
const saveBtn = document.getElementById('saveBtn');
const list = document.getElementById('noteList');
function loadNotes() {
const notes = JSON.parse(localStorage.getItem('notes') || '[]');
list.innerHTML = notes.map(n => `<li>${n}</li>`).join('');
}
saveBtn.addEventListener('click', () => {
const text = input.value.trim();
if (!text) return;
const notes = JSON.parse(localStorage.getItem('notes') || '[]');
notes.unshift(text);
localStorage.setItem('notes', JSON.stringify(notes));
input.value = '';
loadNotes();
});
loadNotes();

Step 4: Write the Service Worker with Offline Caching
Now create sw.js. This is where the magic happens: we cache static assets on install and serve them from cache when the network is unavailable.
const CACHE_NAME = 'quick-notes-v1';
const ASSETS = [
'/',
'/index.html',
'/styles.css',
'/app.js',
'/manifest.webmanifest',
'/icons/icon-192.png',
'/icons/icon-512.png'
];
// Install: pre-cache the app shell
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(ASSETS))
);
self.skipWaiting();
});
// Activate: clean up old caches
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
)
);
self.clients.claim();
});
// Fetch: cache-first strategy with network fallback
self.addEventListener('fetch', event => {
if (event.request.method !== 'GET') return;
event.respondWith(
caches.match(event.request).then(cached => {
return cached || fetch(event.request).then(response => {
return caches.open(CACHE_NAME).then(cache => {
cache.put(event.request, response.clone());
return response;
});
}).catch(() => caches.match('/index.html'));
})
);
});
Caching strategies at a glance
| Strategy | Best for | Behavior |
|---|---|---|
| Cache-first | Static assets (CSS, JS, images) | Serve from cache, fall back to network |
| Network-first | API calls, dynamic content | Try network, fall back to cache |
| Stale-while-revalidate | Frequently updated content | Serve cache immediately, update in background |
| Cache-only | Pre-cached shell files | Only serve from cache |
Step 5: Add a Custom Install Prompt
Browsers fire a beforeinstallprompt event when your app is installable. Capture it and show your own button for better UX.
Append this to app.js:
let deferredPrompt;
const installBtn = document.getElementById('installBtn');
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
installBtn.hidden = false;
});
installBtn.addEventListener('click', async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
console.log(`Install outcome: ${outcome}`);
deferredPrompt = null;
installBtn.hidden = true;
});
window.addEventListener('appinstalled', () => {
console.log('PWA installed successfully');
});
Step 6: Test Your PWA Locally
Service workers won’t run from file://. Use a local HTTPS-friendly server:
- Open a terminal in your project folder
- Run
npx serve .(uses port 3000 by default) - Open
http://localhost:3000in Chrome or Edge - Open DevTools, go to the Application tab
- Check Manifest, Service Workers, and Cache Storage
To test offline mode, toggle Offline in the Network tab and refresh. Your app should still load.
Step 7: Audit with Lighthouse
Open Chrome DevTools, go to the Lighthouse tab, tick Progressive Web App, and click Analyze page load. You should see green checks for:
- Registers a service worker
- Web app manifest meets installability requirements
- Provides a valid
apple-touch-icon - Content is sized correctly for the viewport
- Uses HTTPS

Step 8: Deploy Your PWA
Any static host with HTTPS works. Popular free options:
- Netlify: drag and drop your folder
- Vercel: connect your Git repo
- Cloudflare Pages: fast global CDN
- GitHub Pages: free for public repos
Once deployed, visit the URL on your phone. On Android/Chrome you’ll see an install banner. On iOS Safari, users tap Share > Add to Home Screen.
Common Pitfalls to Avoid
- Forgetting to bump the cache version when you update assets. Users will keep seeing the old version
- Caching everything, including API responses that change. Use network-first for dynamic data
- Missing maskable icons. Android crops icons into circles/squircles
- Testing only on Chrome. Verify on Safari, especially iOS
- Registering the service worker at the wrong scope. Place
sw.jsat the root
Going Further
Once your basic PWA is live, you can add:
- Push notifications using the Push API and a service like Firebase Cloud Messaging
- Background sync to retry failed requests when connectivity returns
- IndexedDB for storing large amounts of structured data offline
- Workbox (by Google) to generate service workers with less boilerplate
- Web Share API for native-style sharing
FAQ
How much does it cost to build a progressive web app?
A simple PWA can be built for free using open web standards and hosted on free platforms like Netlify or Cloudflare Pages. Custom PWAs built by agencies typically range from $5,000 to $50,000 depending on complexity, but the underlying technology has zero licensing cost.
Is PWA still a thing in 2026?
Absolutely. Adoption has grown steadily, iOS support has improved, and major companies like Twitter, Starbucks, Pinterest, and Uber still use PWAs. For content-driven and productivity apps, PWAs are often the smartest technical choice.
Can any website become a PWA?
Almost any website can become a PWA if it is served over HTTPS, has a valid manifest, and registers a service worker. The bigger question is whether it should. PWAs shine for apps users return to often, not for one-off marketing pages.
Do PWAs work on iOS?
Yes. iOS supports installable PWAs, service workers, and offline caching. Some advanced features like push notifications require iOS 16.4 or later and adding the app to the home screen first.
Can I build a PWA with React, Vue, or Angular?
Yes. The principles in this tutorial (manifest, service worker, install prompt) apply to any framework. Vite, Next.js, Nuxt, and Angular all offer PWA plugins that automate most of the work.
How do I update a service worker without breaking users’ sessions?
Change the CACHE_NAME version string when you release new assets. In the activate event, delete old caches. Consider showing a “New version available, refresh” toast when a waiting service worker is detected.
Wrapping Up
You now know how to build a progressive web app from scratch, from the manifest to the service worker to the install prompt. The demo project we built is minimal on purpose so you can extend it in any direction: notes syncing to a backend, push notifications, a slick UI framework, whatever your product needs.
At coding4.net, we help teams ship performant, installable, offline-capable web apps every day. If you’d like a hand turning your existing product into a PWA, get in touch and we’ll take it from there.

