Sitemap
Chamodh Nethsara

Chamodh Nethsara

I write about things that fascinate me.

Learn PWAs by making one with HTML, CSS and Vanilla JavaScript

13 min read3 days ago

--

A Progressive Web App (PWA) is a type of web application that can be installed and run on devices as a standalone application. First introduced in 2016, PWAs have become quite popular among developers and users since they can be installed across many types of devices and with caching can be used even without an active internet connection.

In this article, I am not going to discuss the advantages and the disadvantages of using PWAs but rather look into how we can make a simple yet fully functional progressive web app (PWA) using pure HTML,CSS and vanilla JavaScript. I will not use any fancy JavaScript Libraries or frameworks for this and I hope this would help you see what happens behind the scenes and learn how a PWA actually works.

For this I decided to make a small news app, this app lets you read the latest news from a news server, cache them and read them offline.

1. Building a Simple News App

So let’s get started.

Note: To mimic an actual news server I am using a simple server written in Go. As it can also serve the static files, I follow the folder structure as shown in the screenshot below.

Inside the index.html we can write a minimal UI for our news app.


<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NewsApp</title>
<link rel="stylesheet" href="style.css">
</head>

<body>
<main>
<h1>News App </h1>
<p>Latest News</p>

<section id="news">

</section>
</main>

<script src="app.js"></script>

</body>
</html>

In this UI, we simply have the name of the app its tagline and news section to show the news items that we are going to fetch from our Go news server. I have linked the style.css and app.js files to style the UI and add JS code for our UI as well.

Let’s write our app.js file now.

To display the news, it should first fetch the news items from the server and create article element for each new item it received and put them in the news section.

const newsContainer = document.querySelector("#news");

async function loadNews() {
const response = await fetch("/api/news");
const news = await response.json();

displayNews(news);
}

function displayNews(news) {
newsContainer.innerHTML = "";

news.forEach(article => {
const articleElement = document.createElement("article");
articleElement.classList.add("article");

articleElement.innerHTML = `
<h2>${article.title}</h2>
<p>${new Date(article.published_at).toLocaleString()}</p>
`;

newsContainer.appendChild(articleElement);
});
}

loadNews();

To keep this article more PWA focused I will not explain the server implementation but I will share the code for that at the end of the article as well. The server gives the news articles in JSON format.

The server sends the following three hard-coded news so that we can test our app for now.

[
{
"id": 1,
"title": "Chrome adds a new PWA feature",
"published_at": "2026-10-07T09:30:00Z"
},
{
"id": 2,
"title": "A new JavaScript API lands",
"published_at": "2026-10-07T09:15:00Z"
},
{
"id": 3,
"title": "Web developers rejoice",
"published_at": "2026-10-07T08:30:00Z"
}
]

Now lets run our server and look how it works.

As you can see in the screenshot below our webpage shows the three articles sent by our Go server. The Go server always serves the static files and what you can see here is the index.html file we created.

Press enter or click to view image in full size

Now this is a normal webpage that depends on the server. when we shutdown our server so that we can mimic an offline situation it should no longer show the content. let’s try that as well.

Press enter or click to view image in full size

and yes, it no longer works.

Press enter or click to view image in full size

2. Making Our App Work Offline with Service Workers

So, now let’s make the content of our webpage available offline as well. To make the app offline, what we are going to do is to store it in the browser’s cache storage.

As you may already know we can cache information and retrieve them later using JavaScript in our app.js file.

But this alone is not enough to make our application work offline. If the browser cannot load the webpage in the first place, our app.js code will not get a chance to run. This is where Service Workers come in.

To solve this PWAs use the Service Workers.

A Service Worker is a JavaScript file that the browser can run in the background, independently of the webpage. It can handle events such as network requests and push messages, even when the webpage isn’t open.

But this doesn’t mean that the service workers run in the background all the time. They are event driven and can be started when needed.

It acts as a proxy between the webapp and the server and hence can intercept your requests and forward requests back to the server.

To use a service worker, we first need to register it with the browser and the browser handles its installation and activation. Let’s look into how we can do that.

As I mentioned before a service worker is just a JavaScript file, so let’s create a new JS file name serviceWorker.js in our frontend directory.

Press enter or click to view image in full size

We have created the service worker and our Go server serves that static file as well.

Press enter or click to view image in full size

Now we have to register our serviceWorker.js file as the service worker for our app. To do that, we use our app.js file. So once the browser gets our app.js it will register our service worker.

I added the following piece of code to the app.js file and it uses the navigator.serviceWorker interface to register our service worker. As the navigator interface represents the state and identity of the user agent, in this case the browser, it allows us to interact with the browser and install the service worker if it supports service workers.

if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/serviceWorker.js")
.then(() => {
console.log("Service worker registered");
})
.catch(error => {
console.error("Service worker registration failed:", error);
});
}

Now if you refresh our app and go to the Application section in the developer tools, you can see that the browser has identified our service worker and it tries to install the service worker.

Press enter or click to view image in full size

Now that we have registered our Service Worker, let’s see what happens during installation. The browser dispatches an installevent when a new service worker is being installed. We can listen for this event to perform tasks such as caching our application files.

Let’s try some simple console log to check how it works.

// this is a service worker 

self.addEventListener("install", event => {
console.log("I am installed")});

and now if you check you can see it is installed and running.

Press enter or click to view image in full size

and it has logged “I am installed” in the console as well.

Now instead of logging, let’s make it cache our app. The Cache interface allows us to cache our web app in request,response pairs. That means it can store what is returned for the request of getting the style.css file and the cached response will be our the actual style.css file. If we can store both our index.html file style.css file and the app.js file we should be able to see them even when we are offline.

To make this the article short, I will not explain the code I have written here but you can find all the necessary information about the Cache interface here: https://developer.mozilla.org/en-US/docs/Web/API/Cache

Now, I am going to add the request- response pairs in our cache storage with the following piece of code.

// this is a service worker 
const CACHE_NAME = "newsapp-cache";

const pathsToCache = [
"/",
"/style.css",
"/app.js"
];


self.addEventListener("install", event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(pathsToCache)));

console.log("I am installed")});

I have updated our install event listener to cache the responses for / , style.css and app.js but this is not enough. The cache.addAll() method fetches these resources and stores their responses n the cache. event.waitUntil() makes sure the caching operation completes before the service worker finishes installing.

We have to tell the browser that check if there is any matching response stored in the cache memory before asking the server.

We can do that by adding the following code.


self.addEventListener("fetch", event => {
event.respondWith(
caches.match(event.request)
.then(response => {
return response || fetch(event.request);
})
);
});

This is like intercepting your request before it goes to the network and return the cached response to you without relying on your internet connection.

Get Chamodh Nethsara’s stories in your inbox

Join Medium for free to get updates from this writer.

Now let’s check whether this works.

Press enter or click to view image in full size

It kind of worked. But our three news items are missing. This is because we only cached our requests to /, /style.css and /app.js but none of them really contain the news articles, as they are fetched by calling the /api/news endpoint. Now let’s try adding that endpoint to our pathsToCache array.

const pathsToCache = [
"/",
"/style.css",
"/app.js",
"/api/news"
];
Press enter or click to view image in full size

Now we can see the news articles when we are offline as well.

3. Keeping Our News Articles Up to Date

What if the server publishes a new, news article. we will not be able to see it as we always see the cached version after the first time. So, for the request for /api/news should first try to reach the server, and show the cached version only when it cannot reach the server, i.e. when you are offline.

we can do this by adding a small if statment to our exisiting code. Please see the updated code segment below, it does tbe same thing as before but just check whether it can reach the server before checking the cache. When it gets the response from the server it updates the cache pair and forward the response to the browser.

self.addEventListener("fetch", event => {
if (event.request.url.includes("/api/news")) {
event.respondWith(
fetch(event.request)
.then(response => {
const responseCopy = response.clone();

const cachingPromise = caches.open(CACHE_NAME)
.then(cache => {
return cache.put(event.request, responseCopy);
});

event.waitUntil(cachingPromise);

return response;
})
.catch(() => {
return caches.match(event.request);
})
);
} else {
event.respondWith(
caches.match(event.request)
.then(response => {
return response || fetch(event.request) ;
})
);
}
});

and remove /api/news from pathsToCache array.

Now we can see the latest news items once we are online. Then read them offline. So we have basically made our app available offline.

4. Updating Our App with Cache Versioning

But now we have a new problem. What if we want to make some new changes to the UI and wants to edit both index.html and style.css files, then even if we make the changes in our server, the user will continue to see the previously cached versions because we have followed a cache-first approach when fetching those files.

This leads us to the technique of cache versioning. Let’s quickly get our hands dirty with cache versioning.

The idea is simple. Remember initialy, in our serviceWorker.js file we had the CACHE_NAME constant? Whenever we do a major change to the cache-first resources, we change that constant to a newer version. For example we can name our new version as newsapp-cache-v1

// this is a service worker 
const CACHE_NAME = "newsapp-cache-v1";

When the browser checks for Service Worker updates, it compares the contents of the new service worker script with the existing one. If the script has changed, the browser attempts to install the new version.

When it detects this new service worker and install it, we can use an event handler to clear the previous cache version so that it has update the cache with the latest content.

Let’s look into how it is done.

<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NewsApp</title>
<link rel="stylesheet" href="style.css">
</head>

<body>
<main>
<h1>News App v2</h1>
<p>Latest News</p>

<section id="news">

</section>
</main>

<script src="app.js"></script>

</body>
</html>

I changed the heading of the app.

// this is a service worker 
const CACHE_NAME = "newsapp-cache-v1";

const pathsToCache = [
"/",
"/style.css",
"/app.js"
];


self.addEventListener("install", event => {
self.skipWaiting();
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(pathsToCache)));

});


self.addEventListener("activate", event => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (cacheName !== CACHE_NAME) {
return caches.delete(cacheName);
}
})
);}))});

self.addEventListener("fetch", event => {
if (event.request.url.includes("/api/news")) {
event.respondWith(
fetch(event.request)
.then(response => {
const responseCopy = response.clone();

const cachingPromise = caches.open(CACHE_NAME)
.then(cache => {
return cache.put(event.request, responseCopy);
});

event.waitUntil(cachingPromise);

return response;
})
.catch(() => {
return caches.match(event.request);
})
);
} else {
event.respondWith(
caches.match(event.request)
.then(response => {
return response || fetch(event.request) ;
})
);
}
});

Now this event handler for the “activate” event, checks for the available cache keys (the names) and if there are keys that don’t match to the current CACHE_NAME, it deletes that cache. The event handler for the “install” event already cache the new content, so we don’t have to cache them again.

That’s all we have to do and let’s check whether we can see the latest change to index.html

Press enter or click to view image in full size

It has been updated.

Note: The latest version of the service worker is not usually activated immediately when you refresh the app in an already opened tab. Closed the tab, and start the app in a new tab.

5. Making Our PWA Installable

When you visit a PWA webapp, the browser shows a button so that we can install it on our device. Let’s make that happen for our app as well.

To do this we first need to have a manifest file. According to MDN, a manifest file is the most common way to give information that a broswer need to know so that install our PWA. It is a json file with a single JSON objects and its top level keys are called members.so first I create a manifest.json file in the frontend directory.

and an icons folder as well as we need to have some icons for our app.

I generated the manifest file for our app using this website: https://webutils.io/tool/web-manifest but you can always do it manually as you wish.

Here is the manifest file I have created.

{
"name": "NewsApp - Weekly TechBytes PWA ",
"short_name": "NewsApp",
"start_url": "/",
"display": "standalone",
"theme_color": "#99c1f1",
"background_color": "#deddda",
"scope": "/",
"lang": "en",
"dir": "ltr",
"icons": [
{
"src": "/icons/icon192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}

It just gives the essential information about our app. Here the start_url means the URL that should be opened when the app is launched. The scope is the top-level URL path that contains the app’s pages and subdirectories. Pages within the scope provide an app-like interface and when users navigate to pages outside the scope, browser may display UI elements like URL bar to indicate the change in context.

Even though there are members dir lang are not implemented.

For this manifest.json file to work we have to link it with our index.html. All we have to do is to put this line between the <head> </head> tags of our html file.

<link rel="manifest" href="manifest.json" />

Now, we have changed a cache-first file and you have to start a new version in the serviceWorker.js file and close our app and restart it in a new tab.

const CACHE_NAME = "newsapp-cache-v2";

I named it newsapp-cache-v2.

Ta-Da. We can see our install button in the address bar.

Press enter or click to view image in full size
Press enter or click to view image in full size
Press enter or click to view image in full size

And this is how the installed app is shown on my device, just like the other native apps.

Press enter or click to view image in full size

It works when the device is offline as well.

Press enter or click to view image in full size

So we have successfully created a PWA using just HTML, CSS and vanilla JavaScript without using any special libraries or frameworks.

We started with a normal webpage that depended on our server, then introduced a service worker to cache our application files and news articles. We also looked into how to update the cached content, manage different cache versions and make our application installable using a web app manifest file.

But there is still something interesting we have not yet explored. When our news server publishes a new article while the app is closed, can we notify the user about the latest article? That’s where push notifications come in. I will explore how they work and how we can add push notifications to our PWA in the next article.

Go Server Implementation

As I mentioned earlier, I used a simple Go server to mimic an actual news server. I didn’t explain its implementation because I wanted to keep this article focused on PWAs.

The server serves our frontend files and provides a /api/news endpoint that returns a few hard-coded news articles in JSON format.

Here is the complete Go server implementation.

package main

import (
"encoding/json"
"log"
"net/http"
"time"
)

type Article struct {
ID int `json:"id"`
Title string `json:"title"`
PublishedAt time.Time `json:"published_at"`
}

var news = []Article{
{
ID: 1,
Title: "Chrome adds a new PWA feature",
PublishedAt: time.Now(),
},
{
ID: 2,
Title: "A new JavaScript API lands",
PublishedAt: time.Now(),
},
{
ID: 3,
Title: "Web developers rejoice",
PublishedAt: time.Now(),
},
{
ID: 4,
Title: "This is the latest news from Rotaract!",
PublishedAt: time.Now(),
},
}

func getNews(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(news)
}

func main() {
// API
http.HandleFunc("/api/news", getNews)

// Frontend
fs := http.FileServer(http.Dir("../frontend"))
http.Handle("/", fs)

log.Println("NewsApp is running at http://localhost:8000")
log.Fatal(http.ListenAndServe(":8000", nil))
}

You can run the server by navigating to the server directory and executing go run main.go. Once the server is running, you can access our news app at http://localhost:8000.

Also you can find the full source code here: https://github.com/chamodhk/NewsApp

Think you’ve got it? 🔥

Read the blog carefully and then give the quiz a try!

https://rotaract.social/WTB-quiz-12

Earn points every week by answering correctly and climb the leaderboard for a chance to win Rs. 4,000 at the end of the month! 🏆

🔴 Terms & Conditions apply.

--

--

Chamodh Nethsara

Written by Chamodh Nethsara

I write about things that fascinate me.