Previously, we looked at what PWAs are and why everybody seems to be excited about them. In this tutorial, we are going to take all this theoretical knowledge and take the next step. We are going to build a PWA together by taking an existing web app that you have and adding the PWA-specific stuff to it. The goal is to get all of this done in under 10 minutes to show you how easy it is, but I'm pretty confident we can get it all done in under 5 minutes actually. No pressure :P
Onwards!
In this example we'll serve the app over HTTPS, describe it with a web app manifest, and use a service worker to provide a small offline fallback. These pieces have different jobs. Browser installation criteria vary, and installation alone does not make our content available offline.
Serve the deployed app over HTTPS and check the browser's connection information for certificate errors. The connection icon varies between browsers; an older browser showed a padlock, as in this screenshot. Localhost is also treated as a secure development context for service-worker testing:

If your deployed site doesn't have working HTTPS, your host may provide tools to configure a TLS certificate. Let's Encrypt is one source of free certificates. Follow the current instructions for your host and verify the resulting HTTPS connection; the presence or absence of a particular padlock design is not the test.
The next thing we are going to do is define our web app manifest to provide more details to better describe our web app. In your web server’s home directory (aka the same directory your index.html or equivalent entry point lives), create a file called manifest.json. Inside this file, add the following data into it:
{
"short_name": "KIRUPA",
"name": "KIRUPA",
"icons": [
{
"src": "/images/orange_192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "/images/orange_512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": "/index.html?source=pwa",
"background_color": "#0099fe",
"display": "standalone",
"scope": "/",
"theme_color": "#0099fe"
}
Now, the content you see here is specific to this site. Don't use it as-is unless you want to confuse your users. Instead, change the short_name and name to suit your web app. The most complex part of all this is the icons. You need to provide two square sized images whose sizes will be 192px and 512px. If you have two icons of that size, then you are in great shape. If you don’t have an icon ready just yet, feel free to use the two I have provided below. The 192px icon (icon_192.png) is below:
![]()
The 512px icon (icon_512.png) is as follows:
![]()
Save and upload the icons, then update their paths in the manifest. Check the other deployment-specific fields too. start_url must name an actual launch page, and the manifest's scope describes the app's navigation boundary. The root paths in this example must change if your app lives under a subfolder. The manifest's scope does not set the service worker's registration scope.
Creating the manifest is only part of the job. Link it from the HTML pages where users may install the app, using the path where you actually deployed it. For this root-level example, add the following inside the head element:
<link rel="manifest" href="/manifest.json">
This simple line lets your browser know that there is a web application manifest that it should be aware of. And with this, we are done dealing with our manifest...for now!
Next we'll add a service worker for a small offline experience. It will try the network for a page navigation and use a cached fallback page if that request fails. This doesn't make the whole app work offline. Instead of a default browser error page like this...

...we can show a custom page instead. This screenshot shows the original bouncing-burger demo. The sample below uses a more general failure message, because a failed request alone doesn't prove that the user's device is offline:

We'll create a self-contained fallback page, then cache it during service-worker installation. The fallback can only help after a successful installation and when a worker controls the navigation. A first visit with no connection cannot download a page that was never cached.
In your home directory again, add a file called offline.html. Inside it, add the following HTML and CSS:
<!DOCTYPE html>
<html>
<head>
<title>We couldn't load this page</title>
<meta charset="UTF-8">
<style>
body {
padding: 50px;
}
#container {
background-color: #FFF;
text-align: center;
}
#burger {
animation: bobble 2s infinite;
font-size: 60px;
margin-top: 0;
}
@keyframes bobble {
0% {
transform: translateY(10px);
}
50% {
transform: translateY(40px);
}
100% {
transform: translateY(10px);
}
}
p {
font-family: "Helvetica Neue", Helvetica, Arial, "sans-serif";
text-align: center;
font-size: 20px;
color: #CCC;
line-height: 1.5;
}
p span {
font-weight: bold;
font-size: 24px;
color: #222;
}
a {
text-decoration: none;
}
</style>
</head>
<body>
<div id="container">
<p id="burger">🍔</p>
</div>
<p><span>We couldn't reach this page.</span><br/>Watch this burger bouncing in the meantime.</p>
<p> </p>
<p> </p>
<p><a href="https://www.kirupa.com">🗺️</a></p>
</body>
</html>
Save the file and open it in a browser. You should see the bouncing hamburger from the original demo, with the more general message from our updated sample. Keeping its styles and emoji in the document avoids extra network dependencies.
Now, it is time for the big one! It is time to add the service worker. In the same home directory we've been adding all of our files to so far, add a file called service-worker.js and place the following JavaScript into it:
/*
Copyright 2015 Google Inc. All Rights Reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Adapted for scoped caches, checked installation, and GET navigation fallback.
'use strict';
// Keep this example's caches separate for each service-worker scope.
const CACHE_PREFIX = 'kirupa-offline:' + self.registration.scope + ':v';
const CACHE_VERSION = 1;
const CACHE_NAME = CACHE_PREFIX + CACHE_VERSION;
const OFFLINE_URL = new URL('offline.html', self.location.href).href;
self.addEventListener('install', event => {
event.waitUntil((async () => {
const response = await fetch(OFFLINE_URL, {cache: 'reload'});
if (!response.ok) {
throw new Error('Offline page could not be cached: HTTP ' + response.status);
}
const cache = await caches.open(CACHE_NAME);
await cache.put(OFFLINE_URL, response);
})());
});
self.addEventListener('activate', event => {
event.waitUntil((async () => {
const names = await caches.keys();
await Promise.all(
names.filter(name => name.startsWith(CACHE_PREFIX) && name !== CACHE_NAME)
.map(name => caches.delete(name))
);
})());
});
self.addEventListener('fetch', event => {
if (event.request.mode !== 'navigate' || event.request.method !== 'GET') {
return;
}
event.respondWith((async () => {
try {
return await fetch(event.request);
} catch (error) {
console.warn('Navigation request failed; looking for the offline page.', error);
const cache = await caches.open(CACHE_NAME);
const fallback = await cache.match(OFFLINE_URL);
if (fallback) {
return fallback;
}
console.error('The offline page is missing from this worker cache.');
throw error;
}
})());
});
This example is adapted from Google's custom offline-page sample. It keeps the license notice, checks the download's HTTP status, and gives this worker's caches a prefix based on its scope. Cache Storage is shared across an origin, so deleting every cache except our current one could remove another app's data. We only delete older caches with this example's own prefix. Increment CACHE_VERSION when changing the fallback page so an updating worker prepares its own version.
In your home page's HTML, towards the bottom of your page (like...just above the closing body tag), add the following:
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('/service-worker.js').catch(error => {
console.error('Service worker registration failed.', error);
});
});
}
</script>
This registers the worker script; it does not prove that installation succeeded or that the current page is controlled. Registration errors are reported in the console. Check installation and activation separately, and reload an in-scope page after the initial activation. The default scope follows the worker script's directory, so adjust the script URL and intended scope for your deployment.
After you have completed all of these steps, all that remains is to make sure your PWA actually works. By simply loading your web app in the browser, it will be hard to see whether any PWA-ness will shine through. That is where the in-browser developer tools come in handy. Make sure you are running a recent version of Chrome and bring up the Developer Tools. Once you have done that, navigate to your PWA in the browser and click on the Applications tab.
You will see some navigation entries for Manifest and Service Workers:

When you click on the Manifest entry (it may be selected by default), you will see the values from our web app manifest populated. It should all look good, and you can also see the icons you specified getting displayed. This is just one extra sanity check to ensure the image paths you specified in the manifest resolve properly and everything loads without any issues.
The Service Worker entry will display details related to the service workers that are active on your site.

This older screenshot shows an activated worker. A worker can be active without controlling the page currently open, so check the controlled client as well. Once the page is controlled and the fallback is cached, switch to offline mode and reload. The screenshot below shows the original demo's message during that test:

If the fallback doesn't appear, check the worker's state, scope, controlled client, and cached response. Also test a fresh browser profile, a failed fallback download, and an update while an older page is still open. New workers normally wait while the old worker controls clients. Registration success, installation success, and control of a page are separate observations.
We now have a manifest describing the app and a service worker that can show a cached page when a controlled GET navigation fails. The fetch handler leaves API requests, images, and POST submissions alone; an HTML fallback would not satisfy all of those request contracts. A network failure rejects fetch, but an ordinary HTTP 404 or 500 response does not, so those responses pass through unchanged here. This is a starting point for an offline experience. It doesn't preserve unsent edits, synchronize data, or prove that the whole app works without a connection.
Just a final word before we wrap up. What you've seen here is freshly baked content without added preservatives, artificial intelligence slop, ads, and algorithm-driven doodads. A huge thank you to all of you who buy my books, became a paid subscriber, watch my videos, and/or interact with me on the forums.
Your support keeps this site going! 😇

Hot off the Forums
:: Copyright KIRUPA 2026 //--