Learn to build Progressive Web Apps (PWAs) with JavaScript. Discover offline support, performance optimization, and native app-like features for a seamless user experience.
You build a site, it works great, then someone opens it on a train that dives into a tunnel and the whole thing falls over. That gap between “web page” and “app I can trust when the network flakes” is exactly what a Progressive Web App closes. Day 24 of our 30-day JavaScript journey is about building one: a real web app that installs to the home screen, loads instantly, and keeps working when the connection drops. We will cover what a PWA actually is, build a small one, and add offline caching plus some honest performance wins.
Table of Contents
- Understanding Progressive Web Apps (PWAs)
- Creating a PWA with JavaScript
- Offline Support and Performance Optimization
- Conclusion
Understanding Progressive Web Apps (PWAs)
What Are Progressive Web Apps?
A PWA is a regular website that uses a few extra browser APIs to behave like an installed app. Same URL, same HTML and CSS, but it can be added to the home screen, launched in its own window, and served from a local cache when you are offline. You are not shipping to an app store or maintaining a separate native codebase. You are teaching a website to survive a bad network.
Key Features of PWAs
Three things separate a PWA from a plain site:
- Progressive Enhancement: it works as a normal page in any browser, then layers on install and offline behavior where the browser supports it. Nobody gets a broken experience.
- Offline Functionality: a service worker caches your assets and can serve them with no network, so the app opens instead of showing the browser’s dinosaur.
- App-Like Interface: once installed, it runs in a standalone window from the home screen, no browser chrome around it.
- Push Notifications: you can re-engage users even when the app is closed. One honest caveat: on iOS, web push only works when the user has installed the PWA to the home screen, and it only arrived in iOS 16.4 (2023). Do not assume it everywhere.
- Served over HTTPS: service workers and most PWA APIs only run on a secure origin. That is HTTPS in production, and
localhostcounts during development.
Benefits of Using PWAs
The payoff is practical, not magic:
- Better engagement: an installed icon on the home screen gets opened more than a bookmark. That is the honest reason install matters.
- Faster repeat visits: cached assets skip the network, so the second load is close to instant.
- One codebase: you maintain a single web app instead of a website plus separate iOS and Android builds. That is the real cost saving.
- Secure by requirement: HTTPS is not optional here, so every request is encrypted whether you thought about it or not.
The PWA Checklist
To install and behave like an app, the essentials are:
- HTTPS: required for service workers and installability. Localhost is the dev exception.
- Responsive Design: it has to hold up on phone, tablet, and desktop.
- Web App Manifest: a JSON file with at least a name, a start URL, a display mode, and icons. This is what makes the browser offer “Install.”
- Service Worker: strictly speaking, offline is what needs it. Chromium used to demand a fetch handler for installability and has since relaxed that, but you want a service worker anyway, because offline is the whole point.
- Cross-Browser Testing: aim for the app to work across Chrome, Firefox, Safari, and Edge. Support is not identical, so test, do not assume.
Creating a PWA with JavaScript
Setting Up the Project
You need a plain web project: HTML, CSS, and JavaScript, plus two new files. We will build a small weather app that works offline. Here is the layout.
Project Structure:
my-pwa/
|-- index.html
|-- styles.css
|-- app.js
|-- manifest.json
|-- service-worker.jsCreating the Web App Manifest
The manifest is a JSON file that tells the browser how your app should look and launch once it is installed: its name, its icons, and how it opens.
Example:
{
"name": "My Weather PWA",
"short_name": "WeatherApp",
"description": "A simple weather app that works offline",
"start_url": "/index.html",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#4CAF50",
"icons": [
{
"src": "icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}Link this file from your HTML with <link rel="manifest" href="/manifest.json"> and the browser can read it. The display value of standalone is what drops the browser bar and makes the launched app feel native. The 192px and 512px icons are the ones Chromium wants for install.
Registering a Service Worker
The service worker is a script that runs in the background, separate from your page. It sits between your app and the network, which is what lets it cache responses and serve them later. First you register it.
Example:
/**
* Registers a service worker to enable offline functionality.
*/
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker
.register("/service-worker.js")
.then(registration => {
console.log("Service Worker registered with scope:", registration.scope);
})
.catch(error => {
console.error("Service Worker registration failed:", error);
});
});
}The "serviceWorker" in navigator check keeps this from throwing in browsers that do not support it, so your page degrades gracefully. We wait for load so registration does not compete with your initial render.
Implementing the Service Worker
Now the worker itself. It caches your core files on install, then answers future requests from that cache when it can.
Example:
/**
* Caches assets during the install event for offline access.
*/
const CACHE_NAME = "weather-app-cache-v1";
const urlsToCache = [
"/",
"/index.html",
"/styles.css",
"/app.js",
"/icons/icon-192x192.png",
"/icons/icon-512x512.png"
];
self.addEventListener("install", event => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => {
return cache.addAll(urlsToCache);
})
);
});
/**
* Intercepts fetch requests and serves cached assets when available.
*/
self.addEventListener("fetch", event => {
event.respondWith(
caches.match(event.request).then(response => {
return response || fetch(event.request);
})
);
});On install, it opens a named cache and stores the app shell. On every fetch, it checks the cache first and falls back to the network on a miss. Notice the version in CACHE_NAME: bump it to v2 when you change cached files, and clean up the old cache in an activate handler, or users get served stale assets forever.
Testing and Debugging Your PWA
Open Chrome DevTools and go to the Application panel. From there you can inspect the manifest, watch the service worker’s lifecycle, and tick “Offline” to confirm the app still loads with the network cut. If offline breaks, that panel is where you find out why.
Offline Support and Performance Optimization
Enhancing Offline Support
One caching rule rarely fits everything. Two common strategies: cache-first, which favors speed and works well for static assets, and network-first, which favors fresh data. Here is cache-first that also saves new responses as it goes.
Cache First Strategy:
/**
* Implements a cache-first strategy for fetching resources.
*/
self.addEventListener("fetch", event => {
event.respondWith(
caches.match(event.request).then(cachedResponse => {
return cachedResponse || fetch(event.request).then(networkResponse => {
return caches.open(CACHE_NAME).then(cache => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
});
})
);
});Cache hit returns instantly. Cache miss goes to the network, clones the response into the cache, and returns it. Use this for your shell and assets, not for data that has to be current, like a live weather reading.
Implementing Background Sync
Background Sync lets the app hold a failed request and retry it automatically once the connection is back. Good for saving user input made while offline. Worth knowing up front: this is a Chromium API, and Safari does not support it, so treat it as an enhancement and keep a fallback.
Example:
/**
* Handles background sync to retry failed requests when online.
*/
self.addEventListener("sync", event => {
if (event.tag === "sync-posts") {
event.waitUntil(syncPosts());
}
});
async function syncPosts() {
const posts = await getOutboxPosts();
for (const post of posts) {
await fetch("/api/posts", {
method: "POST",
body: JSON.stringify(post),
headers: {
"Content-Type": "application/json"
}
});
}
}You register the sync from your page, and when connectivity returns the browser fires the sync event. Here it drains an outbox of queued posts and sends each one to the server.
Performance Optimization Techniques
Offline is only half the story. A PWA still has to be fast on the first visit. A few reliable levers:
- Lazy Loading: load images and heavy resources only when they are about to be seen.
- Minification: strip whitespace and dead weight from CSS, JavaScript, and HTML to shrink downloads.
- Code Splitting: break your bundle into pieces the browser loads on demand instead of all at once.
- Prefetching: fetch a likely-next resource ahead of time so it is ready the moment it is needed.
Example of Lazy Loading:
/**
* Implements lazy loading for images to improve performance.
*/
document.addEventListener("DOMContentLoaded", () => {
const images = document.querySelectorAll("img[data-src]");
const loadImages = image => {
image.src = image.dataset.src;
};
const observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
loadImages(entry.target);
observer.unobserve(entry.target);
}
});
});
images.forEach(image => {
observer.observe(image);
});
});This watches each image with an Intersection Observer and swaps data-src into src only when it scrolls into view, then stops observing it. Off-screen images never download until they need to. For plain cases you can also just use the native loading="lazy" attribute and skip the JavaScript entirely.
Analyzing PWA Performance with Lighthouse
Lighthouse is a free, automated auditing tool built into Chrome DevTools, under the Lighthouse panel. Run it and you get scored reports for performance, accessibility, SEO, and best practices, with specific fixes to work through. It is the fastest way to find what is actually slowing your app down instead of guessing.
Implementing Web Vitals for PWA Optimization
Web Vitals is Google’s set of real-world quality signals, and the three Core Web Vitals cover loading, interactivity, and visual stability. As of 2024 those are LCP (Largest Contentful Paint), INP (Interaction to Next Paint), and CLS (Cumulative Layout Shift). You can measure them from your own code with the web-vitals library.
Example:
/**
* Tracks Core Web Vitals metrics for user experience optimization.
*/
import { getCLS, getFID, getLCP } from "web-vitals";
getCLS(console.log);
getFID(console.log);
getLCP(console.log);Heads up: this snippet is now dated. FID (First Input Delay) was retired and replaced by INP as a Core Web Vital in 2024, and current web-vitals versions renamed the functions to onCLS, onINP, and onLCP. On a fresh project you would write import { onCLS, onINP, onLCP } from "web-vitals" and track INP in place of FID. The idea is unchanged: measure the real numbers, then fix the worst one.
Conclusion
That is a working PWA: a manifest so it installs, a service worker so it caches and runs offline, and a caching strategy chosen to fit your data. None of it required a framework or a build step, just a handful of browser APIs used deliberately.
Then you make it fast and keep it fast: lazy load what is off-screen, split and minify what you ship, and let Lighthouse and Web Vitals tell you where the real cost is instead of guessing. Do that and your app holds up on a slow phone in a dead zone, which is the whole reason PWAs exist.
What’s Next?
In Day 25 we move to TypeScript, adding static typing on top of JavaScript so the compiler catches whole classes of bugs before your users do. It is the single biggest upgrade you can make to how reliable and maintainable your code feels.


