Build better. Earn faster. With modern tech– AI, code/no-code & beyond.

Progressive Web Apps: Complete Guide to Building Your First PWA from Scratch

Last Updated: 2025-12-05
Category: Web Development
Reading Time: > 20
Article-type: In-depth
Infographic: Power of PWAs aka Progressive Web Apps—offline access, fast speed, push notifications, and app-like experience.

Introduction to Progressive Web Apps (PWAs)

Progressive Web Apps or PWAs are a web developer's dream scenario. They are web applications that can offer native app-like experiences to users irrespective of the platform they're using - Android, iOS, Windows or Linux.

A native application installed on a user's device and a traditional web application have their own advantages and limitations.

The web application has the advantage of outreach, it can be accessed from any platform via a web-browser but it has limited access to the device's features.

A native app on the other hand, offers richer functionality and OS integration but cannot be multi-platform. An android app cannot run on an iphone or Windows. An App developer would need to create separate codebases in different languages to achieve that.

PWAs elegantly bridge the gap between the two and combine their strengths. They start as regular applications and can progressively enhance themselves.

The end-user can install such progressive web-applications without any app-store. They can be added to home screens with their own icons and splash screens, and can provide a full-screen immersive experience to users just like the native-applications.

The enhancements are not merely cosmetic. A PWA can send push notifications, work offline, and sync content in the background even when the app or browser is not open. They can also access device hardware features like the GPS, camera, and microphone. These are not possible with a traditional web application.

Even if the user chooses not to install the app, PWAs can still be accessed via browsers where they can still work as enhanced web-applications with many of the above features available!

For instance, X (formerly twitter), reduced their loading times to mere 4 seconds after deploying their PWA.

Similarly, many other companies like Starbucks, Pinterest, AliBaba and Uber have seen dramatic improvements in user engagement and conversion rates after launching their PWAs.

The beauty of PWAs lies in their deceptive simplicity. Since they are essentially still a web application, they are powered by the common web technologies like HTML, CSS and JavaScript. Thus, a web developer can transform their website into a PWA, with frontend coding skills they already possess.

In this how-to guide, we will be creating a full working PWA that will demonstrate features such as service workers, caching strategies, modern tools like CookieStore and how to make your app installable.

There are also some limitations that we will explore later in the conclusion.

So What We Gonna Build?

We'll build The Gist Today, a simple news-reader app that will fetch the latest news articles from a public API based on a user's interests and preferred language.

The inspiration for this app comes from Inshorts, which is one of my go-to apps for quick news updates. A news-reader app is also a good subject for a tutorial as it brings together various concepts and is "just-enough" complex to demonstrate the capabilities of a PWA.

The Gist Today logo with a Latest-News banner, a promotional image for our PWA news app tutorial.

Here are some features our app will have:

  1. Allow a user to select their interests and language preferences.
  2. Fetch and display 5 latest news articles, per interest category, from a public API in the user's preferred language. The API I'll be using is NewsData.io, which has decent free tier options. You can also use others, some good options are NewsAPI.org, GNews.io & WorldNewsAPI.com.
  3. It will be installable and come with its own set of custom icons and splash screens to provide a full-screen experience.
  4. Demonstrate the working and lifecycle of a service worker. A service worker can intercept network requests, cache content in advance and use different mechanisms to ensure fast loading and offline capabilities.

This will be a fully functional app on its own which you can install, use and also further build upon it with more features as per your liking.

We'll use only core web technologies — HTML, CSS, and vanilla JavaScript. A basic understanding of these is expected. Not using any other frameworks or third-party libraries will allow us to focus on modern ES6 features and the latest Web API features.

Now I'll be donning my teacher's hat for the rest of this guide, so expect some typical hand-holding along the way. If you're an experienced developer however, feel free to skim to the later sections.

The JS sections are more detailed to cover everything from basics to advanced pertaining to a PWA, without overwhelming a beginner.

File Structure

Here is the starting file structure of our app, we may add some more files as we go:

the-gist-today/
├── index.html
├── manifest.json
├── sw.js
├── js/
│   ├── app.js
│   └── utils.js
├── css/
│   └── stylesheet.css
└── assets/
    ├── icons/
    │   ├── favicon.png
    │   ├── icon-144.png
    │   ├── icon-180.png
    │   ├── icon-192.png
    │   ├── icon-512.png
    │   ├── icon-1024.png
    │   └── notification-badge.png
    └── screenshots/
        ├── desktop-home.png
        └── mobile-home.png

You might be wondering about so many icons, they are used by the underlying OS to pick the most suitable one based on different requirements — home icons, splash screens, notifications etc.

The most important thing to mention in the above structure is regarding the placement of the service worker (sw.js) file. As you might've noticed, unlike other js files, we are placing this file directly inside the root folder.

This allows the service worker to have a scope over the entire app. If we were to place it inside any other folder (like /js/), then its scope would be limited to that folder and its subfolders only.

Another important file is the manifest.json. This is required for making the app installable and contains all important metadata about our app. We'll write this file in a dedicated section later.

If you wish to download the starting project structure and icons, click here.

Building Our Application's Shell

It's time now to start writing the files. Let's begin with the HTML structure of our index file. Here is its barebone structure with explanation of some important parts below:

/index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Today's Gist</title>
    <link rel="stylesheet" href="css/stylesheet.css">
    <link rel="preconnect" href="https://newsdata.io">
    <meta name="theme-color" content="rgb(17, 0, 126)">
    <link rel="icon" type="image/x-icon" href="assets/icons/favicon.png">

    <!-- PWA -->
    <link rel="manifest" href="manifest.json">
    <meta name="mobile-web-app-capable" content="yes">

    <meta name="apple-mobile-web-app-capable" content="yes">
    <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
    <meta name="apple-mobile-web-app-title" content="Your Daily Digest">
    <link rel="apple-touch-icon" href="icons/icon-180.png">

    <meta name="msapplication-TileImage" content="icons/icon-192.png">
    <meta name="msapplication-TileColor" content="rgb(17, 0, 126)"> 
</head>
<body>
    <div id="app-section">
        <div id="app">
            <header>
                <h1>The Gist Today <span class="emoji">📰</span></h1>
                <p class="tagline">Personalize your news experience</p>
            </header>
            <main>
                <div id="user-preferences">
                    <h2>User Preferences</h2>
                    <form id="user-preferences-form">
                        <label class="user-language">Preferred Language: <select id="language-options" name="language">
                                <!-- Language options will be populated here from the javascript -->
                            </select>
                        </label>

                        <fieldset class="user-interests">
                            <legend>Interests:</legend>
                            <div id="interests-checkboxes">
                                <!-- Checkboxes will be created here from javascript -->
                            </div>
                        </fieldset>
                        <div class="user-notification-settings">
                            <label>Enable Notifications <span class="emoji">🔔</span><input type="checkbox" name="notifications" value="1"></label>
                        </div>
                        <input class="btn submit-btn" type="submit" value="Save Preferences">
                    </form>
                </div>

                <div id="display-news">
                    <h2>Latest News</h2>
                    <p>Last updated <span class="emoji">🕒</span>: <span id="last-updated"></span></p>
                    <div id="news-list">
                        <!-- News articles will be displayed here -->
                    </div>
                </div>
            </main>
            <div id="alert-box" class="hidden" role="alert">
                <p id="alert-message"></p>
                <button type="button" id="alert-close-btn" class="btn"><span class="emoji">⤫</span></button>
            </div>
        </div>
    </div>
    <script src="js/app.js" type="module" defer></script>
</body>
</html>

In the head section, if you're using some other news API, make sure to change the preconnect's href to reflect their url.

To make this app installable as a PWA, we need some meta tags in the head section.

  1. The following 2 lines are for all platforms. The manifest link points to our manifest.json file which we'll write later. The mobile-web-app-capable meta tag allows the app to be run in full-screen mode.
    <link rel="manifest" href="manifest.json">
    <meta name="mobile-web-app-capable" content="yes">
  2. Apple devices don't fully support the manifest.json file, rather they rely on some specific meta and link tags.
    1. Similar to the mobile-web-app-capable tag above, this line allows full-screen mode on iOS devices.
      <meta name="apple-mobile-web-app-capable" content="yes">
    2. This sets the color of the status bar (the top bar showing time, battery etc.) when the app is launched. Other possible values are default and black.
      <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
    3. The next two lines are for setting the app's name and icon on the home screen.
      <meta name="apple-mobile-web-app-title" content="The Gist Today">
      <link rel="apple-touch-icon" href="assets/icons/icon-180.png">
  3. For old Windows devices, these two tags set the app's tile image and background color when pinned to the start menu.
    <meta name="msapplication-TileImage" content="assets/icons/icon-144.png">
    <meta name="msapplication-TileColor" content="rgb(17, 0, 126)">

The body section consists of — i. a header with the app's name and a tagline, ii. a form for setting user preferences, and iii. a main section to display news articles fetched from the API.

There is also an alert box at the end which will float at the bottom of the screen and be shown only when needed. It will be used to communicate messages to the user in a non-blocking, app-like manner, unlike the traditional alert modal of browsers.

Designing Our App

Now that we've the basic structure of the app ready, let's create the stylesheet for it. Here is the complete CSS code (barring the news-list section which will be created dynamically via JavaScript and we will design it then):

/css/stylesheet.css

:root {
  --theme-color: rgb(17, 0, 126);
  --variant-color: #202aaf;
  --card-background: #1e0d8c;
  --ascent-color: #8a046c;
  --highlight-color: #8e3597;
  --descent-color: #65206e;
  --text-color: ivory;
}

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: Cambria, Arial, sans-serif;
  font-size: 1rem;
  line-height: 1.6;
  background-color: var(--descent-color);
  color: ivory;
  padding: 7px;
}

h1 {
  font-size: 2.1rem;
}

h2 {
  font-size: 1.5rem;
  margin-bottom: 10px;
}

h3 {
  font-size: 1.3rem;
}

.hidden {
  display: none;
}

.btn {
  background-color: var(--ascent-color);
  color: white;
  border: none;
  border-radius: 5px;
  cursor: pointer;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.15);
  transition: background-color 0.2s, box-shadow 0.2s, transform 0.2s;
}

.btn:hover {
  background-color: var(--highlight-color);
  transform: translateY(-2px);
  box-shadow: 0 6px 10px rgba(0, 0, 0, 0.2);
}

.btn:active {
  background-color: var(--descent-color);
  transform: translateY(1px);
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
}

#app-section {
  background: var(--theme-color);
  padding: clamp(10px, 3vw, 30px);
  border-radius: 7px;
}

#app {
  max-width: 1200px;
  margin: 0 auto;
}

header {
  background: linear-gradient(180deg, var(--variant-color) 37%, var(--ascent-color) 100%);
  padding: clamp(10px, 2.5vw, 20px);
  text-align: center;
  border-radius: 5px;

  .tagline {
    font-size: 1.3rem;
    font-style: italic;
  }

  .emoji {
    font-size: smaller;
  }
}

main {
  margin: 20px auto;
  padding: 10px;
}

#user-preferences {
  form {
    display: flex;
    flex-direction: column;
    gap: 10px;
    margin: 15px 0;
    align-items: start;

    * {
      font-size: 1rem;
      font-family: Arial;
    }

    /* Covers checkboxes and select dropdown */
    label {
      display: flex;
      align-items: center;
      gap: 5px;
    }

    .user-language {
      width: 100%;
      gap: 10px;

      select {
        font-size: smaller;
        flex: 1;
        padding: 5px;
        border: 1px solid #fff;
        border-radius: 5px;
        background: transparent;
        color: white;

        option {
          background-color: var(--theme-color);
        }
      }
    }

    .user-interests {
      width: 100%;
      border: 1px solid #fff;
      padding: 10px;
      border-radius: 5px;

      legend {
        font-weight: bold;
      }

      /* Creating a grid of max 4 columns */
      #interests-checkboxes {
        display: grid;
        grid-template-columns: repeat(auto-fit, minmax(max(150px, (100% - 15px) / 4), 1fr));
        gap: 5px;
      }
    }

    .submit-btn {
      margin: 10px auto;
      padding: 10px 50px;
    }
  }
}

#display-news {
  display: flex;
  flex-direction: column;
  gap: 10px;

  p {
    margin-bottom: 10px;
  }
}

#alert-box {
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
  bottom: 20px;
  z-index: 6;
  background: black;
  width: 90vw;
  display: flex;
  justify-content: space-between;
  padding: 10px 20px;
  align-items: center;
  color: white;

  &.hidden {
    display: none;
  }
}

#alert-close-btn {
  padding: 1px 4px;
}

There is nothing PWA specific here, you style just like any other normal web-page. What is important is the design should be responsive and look good on any device.

In the above design, I've kept things simple. Only used common fonts- Cambria and Arial, which are widely available, a blue-purple color scheme, and a flexbox/grid based layout.

You can of course, customize the design as per your liking. Just make sure to keep the theme-color meta tag in the head section of index.html in sync with your design.

Here is how the initial scaffolding looks like:

The Gist Today initial UI

Adding Functionality with JavaScript

In the js folder, there are 2 javascript files - utils.js and app.js. The utils.js file will contain utility functions that can be used independent of the DOM. The app.js file will implement the interactivity with the DOM.

This is an important point which I want to stress here — the reason for doing so is not merely to maintain the separation of concerns, but also because a service worker cannot import functions from a module script that references the document (like app.js would in our case).

Even if the functions themselves don't interact with the DOM, if they are defined in a module that does at any place, you cannot import that module in a service worker.

Ideally, I'd have created more files in our js folder, like config.js (to store global constants) & api.js (for API operations). But that'd be overkill for a tutorial. So to keep things simple, we'll just write every non-DOM related operation on the utils.js.

Let's start with creating some global constants and important variables which store all interest categories and supported languages.

utils.js

// Base URL for the NewsData.io API endpoint
export const BASE_API_URL = "https://newsdata.io/api/1/latest";

// API key for authentication (replace with your own, however avoid hardcoding like this in production for security)
export const API_KEY = "";

// Number of articles to fetch per interest category
export const SIZE_PER_REQUEST = 5;

// Cookie expiration time in milliseconds (setting this to 365 days)
export const COOKIE_LIFE = 365 * 24 * 60 * 60 * 1000;

// Map of available news categories with labels and emojis for UI display
export const ALL_CATEGORIES = new Map([
    ["top", {label:"Trending (Default)", emoji:"📈"}],
    ["technology", {label:"Technology", emoji:"💻"}],
    ["health", {label:"Health", emoji:"🏥"}],
    ["business", {label:"Business", emoji:"💼"}],
    ["sports", {label:"Sports", emoji:"⚽"}],
    ["entertainment", {label:"Entertainment", emoji:"🎬"}],
    ["science", {label:"Science", emoji:"🔬"}],
    ["politics", {label:"Politics", emoji:"🏛️"}],
    ["tourism", {label:"Travel", emoji:"✈️"}],
    ["food", {label:"Food", emoji:"🍔"}],
    ["lifestyle", {label:"Lifestyle", emoji:"👗"}],
    ["environment", {label:"Environment", emoji:"🌱"}]
]);

// Map of supported languages for user preferences
export const ALL_LANGUAGES = new Map([
    ["en", "English (Default)"],
    ["es", "Español"],
    ["fr", "Français"],
    ["de", "Deutsch"],
    ["it", "Italiano"],
    ["pt", "Português"],
    ["hi", "हिन्दी"],
    ["bn", "বাংলা"],
    ["ta", "தமிழ்"],
    ["zh", "中文"],
    ["ja", "日本語"],
    ["ko", "한국어"],
    ["ar", "العربية"],
    ["sw", "Kiswahili"]
]);

// Default user preferences to fall back to if none are set
export const DEFAULT_PREFERENCES = {
    interests: ["top"],
    language: "en",
    notifications: false
};

Replace the API_KEY value with your own key from NewsData.io or your chosen API.

Next, we'll be building both app.js and utils.js side by side. Please pay close attention to the file-name mentioned above each code block.

app.js

import * as utils from "./utils.js";

// Populate the language selection dropdown using ALL_LANGUAGES Map from utils.js
function create_lang_options_ui() {
    const select = document.getElementById("language-options");
    
    // Loop through and create each language option
    for (const [language, label] of utils.ALL_LANGUAGES) {
        const option = document.createElement("option");

        // Set the value of the option to the language code (e.g., "en" for English)
        option.value = language;

        // Set the label
        option.textContent = label;  
        
        // Add the option to the select dropdown
        select.appendChild(option);
    }
}

// Populate the interests checkboxes using ALL_CATEGORIES Map from utils.js
function create_interests_checkboxes_ui() {
    const div = document.getElementById("interests-checkboxes");

    // Loop through and create each interest-category checkbox
    for (const [interests, { label, emoji }] of utils.ALL_CATEGORIES) {
        // Create a label element to wrap the checkbox and text
        const label_elem = document.createElement("label");
        
        // Set the inner HTML to include checkbox input and display text with emoji
        label_elem.innerHTML = `<input type="checkbox" name="interests" value="${interests}">${label} <span class="emoji">${emoji}</span>`;
        
        // Add the label (with checkbox) to the container div
        div.appendChild(label_elem);
    }
}

// Display an alert message to the user with auto-close functionality
function show_alert(alert_msg) {
    if (alert_msg.trim().length > 0) {
        document.getElementById("alert-box").classList.remove("hidden");
        document.getElementById("alert-message").textContent = alert_msg;
        setTimeout(close_alert, 10000); // Auto-close after 10 seconds
    }
}

// Hide the alert box
function close_alert() {
    document.getElementById("alert-box").classList.add("hidden");
}

// Handle form submission: validate input, request permissions, and save preferences
async function form_submit(evt) {
    evt.preventDefault();
    const data = new FormData(this);

    // Get the selected language (single value)
    const language = data.get("language");

    // Get all selected interests (multiple checkboxes can be selected)
    const interests = data.getAll("interests");

    // Validate that at least one interest is selected
    if (interests.length === 0) {
        show_alert("Select at least one interest. Otherwise, the default will be used.");

        // Use default "top" interest category if none selected (top is trending news)
        interests.push("top"); 
    }

    // Check if notifications are enabled (checkbox value is "1" when checked)
    let notifications = data.get("notifications") === "1";

    // If user wants notifications, prompt browser permission
    if (notifications) {
        const permission = await Notification.requestPermission();

        // Enable notifications only if permission to the browser is granted
        notifications = (permission === "granted");
    }

    // Create preferences object to save
    const preferences = {
        interests: interests,
        language: language,
        notifications: notifications
    };

    // Save preferences
    await utils.save_user_preferences(preferences);
    
    // Update news display section with the new preferences
    await update_news(preferences);
}

// Update the UI form fields based on saved user preferences
function update_ui(preferences) {
    document.getElementById("language-options").value = preferences.language;
    document.querySelectorAll("input[name='interests']").forEach(checkbox => {
        checkbox.checked = preferences.interests.includes(checkbox.value);
    });
    document.querySelector("input[name='notifications']").checked = preferences.notifications;
}

async function update_news(preferences) {
    // we'll write this next...
}

// This is the entry point - runs when the page loads
async function init() {
    create_lang_options_ui();
    create_interests_checkboxes_ui();
    const preferences = await utils.get_user_preferences();
    update_ui(preferences);
    await update_news(preferences);
}

// Register event listeners: form submission, alert's close button, and DOMContentLoaded
document.getElementById("user-preferences-form").addEventListener("submit", form_submit);
document.getElementById("alert-close-btn").addEventListener("click", close_alert);
document.addEventListener("DOMContentLoaded", init);

utils.js

// Save user preferences to browser cookies with expiration date
export async function save_user_preferences(preferences) {
    try {
        await cookieStore.set({ 
            name: "user-preferences", 
            value: JSON.stringify(preferences), 
            expires: (new Date(Date.now() + COOKIE_LIFE)) 
        });
    } catch (error) {
        console.error(`Error saving user preferences to cookies: ${error.message}`);
    }
}

// Retrieve user preferences from cookies, fallback to defaults if not found
export async function get_user_preferences() {
    let preferences;
    try {
        preferences = await cookieStore.get("user-preferences");
        preferences = JSON.parse(preferences.value);
    } catch (error) {
        console.error(`Error retrieving user preferences from cookies: ${error.message}`);
        preferences = DEFAULT_PREFERENCES;
    }
    return preferences;
}

I've extensively commented the code blocks, the following explanations will further make it clear about what each function does. Let's begin our walk through the init() in app.js, which is the entry point of our app:

  1. We first populate the GUI of the form we left off in the html. The code creates and inserts the language options for the select dropdown element and interests checkboxes, using their respective functions and Maps.
  2. It retrieves the (previously) saved user preferences using the get_user_preferences() function from utils.js. If there are no previously saved preferences, it falls back to the DEFAULT_PREFERENCES.
  3. The update_ui() function updates the form fields to reflect the retrieved preferences. This is important so that the user sees their previously saved settings when they open the app next time.
  4. Finally, it calls the update_news() function to fetch and display news articles based on the user's preferences. We haven't written this function yet, we'll do that next in the following section.

Another significant code is the form_submit() function. Most of what it does is explained in the comments. I'd just like to highlight the part about notifications.

We use the Notification API to request permission from the user to allow notifications. The Notification.requestPermission() method opens a modal in the browser that prompts the user to allow or deny notifications.

It returns a promise that resolves to the permission status, which can be 'granted', 'denied', or 'default'. The 'default' means the user dismissed the prompt without making a choice. We enable notifications only if the permission is 'granted'.

In the utils.js file, there are 2 functions created - save_user_preferences() and get_user_preferences() that uses the CookieStore API. It has very simple set() and get() methods to store and retrieve cookies respectively. Unlike the traditional document.cookie, it works with promises and is asynchronous.

Important: The CookieStore API is new, and though it is now supported in all the modern browsers, it may not be available in the older ones. As such, if you wish to explore alternatives, then the other lightweight storage option is the service worker cache using the Cache API. We will explore that in a later section to store the news articles.

If you want something even more advanced for storing things on the client's system, then use IndexedDB. Do not use the localstorage or the http cookies, if you wish to access the stored data from the service worker as they are incompatible.

The screenshot of the app's GUI constructed so far.

Adding Functionality with JavaScript-2: Fetching News Articles

In this section, we will make requests to our news API to fetch articles based on the user's preferences. But before writing the code, we need to first inspect the url structure the API expects and the response data it gives back.

Here is a sample of the expected URL, constructed after consulting their documentation. The apikey is fake here, used for demonstration only.

For 10 English articles in sports and technology categories:
https://newsdata.io/api/1/latest?apikey=pub_20298bfe1645444094eb01650af6684c&category=sports,technology&language=en&size=10

For 5 Español articles in lifestyle, business and environment categories but only from top sources that must have image:
https://newsdata.io/api/1/latest?apikey=pub_20298bfe1645444094eb01650af6684c&category=lifestyle,business,environment&language=es&size=5&image=1

We need to construct urls based on the user's preferences but in a format like above. We'd also need to truncate the responses to extract only what we need. Here are all the functions that achieve this in utils.js:

utils.js

// Fetch news articles for multiple categories and return them as a combined array
export async function fetch_news({ categories = ["top"], language = "en" } = {}) {
    const results = [];
    
    // Loop through each category to fetch articles 
    for (const cat of categories) {
        try {
            // Build the complete API URL with category and language parameters
            const url = construct_url({ category: cat, language: language });
            
            // Make the API request
            const response = await fetch(url);
            
            // Check if response contains JSON data
            if (response.headers.get("content-type")?.includes("application/json")) {
                const data = await response.json();
                
                // Process successful API responses with articles
                if (data.status === "success" && data.results?.length > 0) {
                    // Clean up the response data to keep only needed fields
                    const trunc_results = trunc_response(data.results);
                    results.push(...trunc_results); // Add articles to combined results
                    
                    // Update timestamp for when news was last fetched
                    await cookieStore.set({ 
                        name: "last-updated-news", 
                        value: new Date().toLocaleString() 
                    });
                }
            }
        } catch (error) {   
            console.error(error.message);
        }
    }
    
    // Return all articles from all categories as one array
    return results;
}

// Build the complete API URL with query parameters
function construct_url(opts = {}) {
    const query = new URLSearchParams({ 
        apikey: API_KEY, 
        size: SIZE_PER_REQUEST,
        prioritydomain: "top",
        image: 1,
        removeduplicate: 1,
        country: "wo",
        ...opts 
    }).toString();
    return `${BASE_API_URL}?${query}`;
}

// Extract only the needed fields from the raw API response
function trunc_response(data_results) {
    const trunc_results = [];
    
    for (const article of data_results) {
        const trunc_article = {
            title: article.title,
            link: article.link,
            author: article.creator?.join(', ') ?? "unknown",
            published: (article.pubDate && article.pubDateTZ) ? 
                (`${date_format(article.pubDate)} ${article.pubDateTZ}`) : 
                (date_format(article.pubDate) ?? "unknown"),
            description: article.description ?? "",
            category: article.category?.join(", "),
            image_url: article.image_url ?? "",
            source_icon: article.source_icon ?? "",
            source_id: article.source_id ?? ""
        };
        trunc_results.push(trunc_article);
    }
    
    return trunc_results;
}

// Format date strings into readable format based on user's locale
function date_format(date_string) {
    const d = date_string ? new Date(date_string) : new Date();
    if (isNaN(d)) 
        return null;
        
    return d.toLocaleDateString(undefined, { 
        weekday: 'short', 
        day: 'numeric', 
        month: 'short', 
        year: 'numeric' 
    });
}

The fetch_news() function accepts an object with categories and language properties, similar to the user preferences we stored in the CookieStore. If no argument is provided, it defaults to the "top" category (which is for trending news, as specified in the API documentation) and the English language.

The function loops through each category, constructs the appropriate API URL using the helper construct_url() function, and fetches the response from the API using that url.

The reason behind looping for each category, instead of sending all categories in one request, is to ensure every category gets an equal number of articles (as specified by SIZE_PER_REQUEST). Sending all categories in one request can make the API return articles based on relevance, which may lead to some interests getting all the results while others get none.

It then processes the response's JSON and uses trunc_response() to extract only the essential article data. Finally, it stores the truncated article data in the results array, which is returned at the end.

There is another entry we created in the CookieStore with the name last-updated-news. This stores the timestamp of the last successful news fetch, which will be used to inform the user when the news was last updated.

The date_format() function is a simple utility to format date strings into a more readable format based on the user's locale like "Wed, 17 Sep 2025".

JavaScript-3: Displaying News Articles

We earlier left off creating the update_news() function in the app.js. Since most of the heavy lifting is already done by the utils.js functions, creating this will be a breeze.

the wireframe design of the news card

Each news article will be displayed as a card as a clickable anchor element. The above plan shows the layout, we can now write the html and the css accordingly to match this design.

app.js

// Fetch news articles and update the news display section
async function update_news(preferences) {
    const news_list = document.getElementById("news-list");
    
    // Store current content as backup in case of errors
    const before_update = news_list.innerHTML;
    
    // Show loading message while fetching
    news_list.innerHTML = "<p>Loading latest news articles...</p>";

    try {
        // Fetch articles based on user preferences
        const articles = await utils.fetch_news({ 
            categories: preferences.interests, 
            language: preferences.language 
        });
        
        if (Array.isArray(articles) && articles.length > 0) {
            // Build HTML content from articles
            const news_fragment = get_news_fragment(articles);
            
            // Replace loading message with actual content
            news_list.innerHTML = "";
            news_list.appendChild(news_fragment);
            
            // Update the "last updated" timestamp display
            const last_updated = (await cookieStore.get("last-updated-news"))?.value;
            document.getElementById("last-updated").textContent = last_updated || "Unknown";
        }
    } catch (error) {
        // Show error to user and restore previous content
        show_alert(error.message);
        news_list.innerHTML = before_update;
    }
}

// Build HTML elements for news articles using DocumentFragment for performance
function get_news_fragment(news_articles) {
    // Validate that we have articles to display
    if (!news_articles.length) {
        throw new Error("No articles found for your selected interests. Try different interests or check back later.");
    }
    
    // Use DocumentFragment to build all articles efficiently (avoids multiple DOM reflows)
    const fragment = document.createDocumentFragment();
    
    for (const article of news_articles) {
        // Create clickable article container
        const article_link = document.createElement("a");
        article_link.href = article.link;
        article_link.target = "_blank"; // Open in new tab
        article_link.classList.add("news-link");

        // Build optional HTML for source icon and article image
        const source_icon_html = article.source_icon ? 
            `<img src="${article.source_icon}" class="source-icon" alt="${article.source_id} icon">` : '';
        const image_html = article.image_url ? 
            `<img src="${article.image_url}" class="news-image" alt="${article.title}">` : '';

        // Construct the complete article HTML structure
        article_link.innerHTML = `
            <h3>${source_icon_html}${article.title}</h3>
            <div class="news-category">
                <span><em>${article.author || "Unknown"} on ${article.published}</em></span>
                <span><strong>Category:</strong> ${article.category}</span>
            </div>
            ${image_html}
            <p>${article.description}</p>
            <div class="news-source">
                <span>Source: ${article.source_id || 'Unknown'}</span>
            </div>
        `;
        
        fragment.appendChild(article_link);
    }
    return fragment;
}

The update_news() function first stores the current content of the news-list section as a backup, in case there are any errors during the fetch. It then shows a loading message while it calls the fetch_news() function from utils.js with the user's preferences.

If articles are successfully fetched, it calls the helper get_news_fragment() function to build the HTML structure for all the articles using a DocumentFragment. This way, all the article-cards get appended to the DOM in one go, improving performance by avoiding multiple reflows.

Lastly, we also need to update our stylesheet to style the cards. styles.css should include styles for the new classes we added, such as news-link, news-image, news-category, and news-source.

styles.css

#news-list {
  display: flex;
  flex-direction: column;
  gap: 10px;
  font-family: arial;

  .news-link {
    text-decoration: none;
    color: inherit;
    margin: 20px auto;
    box-shadow: 10px 10px 20px rgb(0 0 0 / 28%);
    padding: 30px 30px 20px;
    border-radius: 20px;
    width: min(90vw, 90ch);
    max-width: 100%;
    background-color: var(--card-background);
  }

  h3 {
    margin: 5px 0;
  }

  p {
    margin: 20px 0 30px;
  }

  .source-icon {
    width: 24px;
    height: 24px;
    border-radius: 4px;
    flex-shrink: 0;
    margin-right: 10px;
    vertical-align: middle;
  }

  .news-category {
    display: flex;
    flex-wrap: wrap;
    gap: 10px;
    justify-content: space-between;
    margin: 10px 0;
    font-size: 15px;
    font-family: cambria;
  }

  .news-image {
    width: 90%;
    height: auto;
    max-height: 450px;
    object-fit: cover;
    border-radius: 8px;
    margin: 20px auto;
    display: block;
  }

  .news-source {
    font-size: 15px;
    text-align: right;
    font-family: Cambria;
    font-style: italic;
  }
}

Each news article is displayed in a card-like format as we designed earlier with a shadow, padding, and rounded corners.

the wireframe design of the news card

Phew! With this, our application's logic and core functionality is complete. It can fully work on its own now. However, to make it as a PWA, we still need a couple of things, namely a service worker and a manifest file. Let's do that next.

Writing manifest.json

The manifest.json file is a simple JSON file that provides important information about our web application — name, icons for different cases, start URL, theme color etc. This information is used by the browser and the underlying operating system when the app is installed on a user's device.

If you remember, we already added a link to the manifest file in the head of our HTML file. That step is crucial, now let's write the actual file.

manifest.json

{
  "name": "The Gist Today",
  "short_name": "Today's Gist",
  "description": "Stay informed with personalized news from around the world. A Progressive Web App that delivers news based on your interests and preferred language.",
  "id": "/",
  "start_url": "./",
  "scope": "./",
  "display": "standalone",
  "orientation": "portrait-primary",
  "lang": "en",
  "dir": "ltr",
  "theme_color": "rgb(17, 0, 126)",
  "background_color": "rgb(17, 0, 126)",
  "categories": ["news", "productivity", "lifestyle"],
  "icons": [
    {
      "src": "assets/icons/favicon.png",
      "sizes": "48x48",
      "type": "image/png",
      "purpose": "any"      
    },
    {
      "src": "assets/icons/icon-144.png",
      "sizes": "144x144",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "assets/icons/icon-180.png",
      "sizes": "180x180",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "assets/icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "assets/icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "assets/icons/icon-1024.png",
      "sizes": "1024x1024",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "assets/icons/icon-maskable-192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "maskable"
    },
    {
      "src": "assets/icons/icon-maskable-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable"
    }
  ],
  "screenshots": [
    {
      "src": "assets/screenshots/desktop-home.png",
      "sizes": "1280x720",
      "type": "image/png",
      "form_factor": "wide",
      "label": "Home screen showing news articles and preferences"
    },
    {
      "src": "assets/screenshots/mobile-home.png",
      "sizes": "720x1280",
      "type": "image/png",
      "form_factor": "narrow",
      "label": "Home screen showing news articles and preferences"
    }
  ],
  "related_applications": [],
  "prefer_related_applications": false,
  "display_override": ["window-controls-overlay", "standalone", "minimal-ui"],
  "launch_handler": {
    "client_mode": "focus-existing"
  },
  "handle_links": "preferred",
  "capture_links": "existing-client-navigate",
  "edge_side_panel": {
    "preferred_width": 400
  }
}

Here are the explanations for some of the important fields above-

  1. start_url: The page that opens when users launch the app from their home screen or app drawer.
  2. display: Controls how the app appears - "standalone" makes it full-screen and looks like a native app without any browser's UI.
  3. display_override: Fallback display modes if "standalone" isn't supported.
  4. scope: Defines which pages belong to our PWA - "./" means all pages in the app's directory.
  5. background_color & theme_color: Background shows during app launch; theme colors the browser/status bar of the device. We have chosen the same for both- a dark blue indigo, rgb(17, 0, 126), which is also the theme color in the css.
  6. orientation: Locks the app to portrait mode on mobile devices.
  7. icons: Different sized icons for various contexts (home screen, splash screen, etc.). "any" purpose icons are standard, while "maskable" ones adapt to different shapes (like Android adaptive icons).
  8. screenshots: These provide previews when a user installs a PWA. There are only 2 for this demo, but ideally you'd want to showcase different features of your app here.
    An installation wizard displaying the app's description and a screenshot below with install and cancel buttons.
  9. launch_handler: Controls what happens when users open the app multiple times. In our case here, it focuses the existing window instead of opening a new one.
  10. handle_links & capture_links: Manages how external links are handled. "preferred" means the app will try to open links itself if possible, while "existing-client-navigate" means links will open in the existing app window if it's already open.
  11. edge_side_panel: Microsoft Edge-specific feature for side panel display.

Different devices and platforms use manifest.json data in their own ways. For example, Android uses the icons for the home screen and splash screen, while Windows uses them for the Start menu and taskbar.

Similarly, iOS earlier did not support reading the manifest file, but now it does partially. However, it still relies on specific meta tags in the HTML for some features, which we covered in the HTML section.

Once you add the manifest file, you'll see a small install icon in the address bar of your browser when you visit the page. Clicking on it will prompt you for the installation with a nice looking widget, showing description and screenshots as created in the manifest.json file above.

A screenshot showing the install icon in the browser's address bar when the PWA is opened.

Make sure you're opening the page either from an https enabled server or from your localhost. Just opening the html file directly in the browser won't work. See the faqs section at the end if you need help.

Understanding Service Worker: The Basics

A service worker is the heart of a PWA. Essentially, it is another script that runs separately in the background, independent of the main application. It is this script that enables features that traditional web applications cannot have, such as offline functionality, background sync, and push notifications.

It can intercept and handle network requests. This allows us to implement many types of caching techniques to improve performance and reliability. The service worker can also work when there is no active tab or open window of the application. As such, it can make these network requests in the background and store them in the cache in advance.

This is what makes offline functionality possible: when a user loses their internet connection and tries to use the app, the service worker can step in and serve content directly from its cache.

Flowchart showing a request going from the app to the service worker, which, on a cache hit returns the response from there. On a miss, it falls back to the network while also caching the new response.
How a service worker can implement cache-first strategy by intercepting network requests.

Service workers are event-driven — we implement features by creating functions and registering them as event listeners for specific events. The browser calls these functions when the corresponding trigger or events occur.

Some common events are installation & activation which pertains to the lifecycle of a service worker, while others are related to specific functionalities.

Now, with enough background, let's start step-by-step with building our service worker.

Registration

First, we need to register our service worker script in the main app. This would be a one-time process and it is pretty straightforward:

app.js

// Register the service worker
async function service_worker_registration() {
    // Check if the browser supports service workers
    if ('serviceWorker' in navigator) {
        try {
            console.log("Registering service worker...");
            
            // Register the service worker file (sw.js)
            await navigator.serviceWorker.register("./sw.js", { 
                scope: "./",      // Control all pages in this directory
                type: "module"    // Allow ES6 imports in service worker
            });

            console.log("Service Worker registered successfully.");
        } catch (error) {
            // Service worker registration failed (maybe browser doesn't support it)
            console.error('Service Worker registration failed:', error.message);
        }
    }
    // If service workers aren't supported, the app still works without offline features
}

Though service workers are now widely supported in all modern browsers, we still check for their availability to be sure. If supported, the main code here is calling the navigator.serviceWorker.register() with the path of our script file (./sw.js), which registers it as the service worker.

The scope option determines which pages the service worker can control. By setting it to "./", we are giving it control over all pages at or below the directory where our sw.js is located, which in our case is the root. This means this service worker can manage the entire app.

The type "module" will allow us to import other JS modules, like utils.js, into our service worker script file.

Notice, we register the service worker in the app.js. We also need to update our init() function in the same file to call this registration function:

async function init() {
    //...existing code...
    await service_worker_registration();
}

Installation

The install event is fired on two circumstances:

  1. When registration happens for the first time.
  2. When there is a new version of the registered script file (sw.js). For any minor change in that file, like say, even a comma on a comment, the browser will consider it a new version and fire the install event.

We can attach an event listener to the install event and perform some initiating tasks, like caching static assets of our app.

Once the service worker is installed, it might have to wait for the older service worker, if present, to retire, before it takes charge. This happens when the user closes all of the active clients (tabs, windows, apps open in the user's device) running our PWA.

It is designed this way to ensure only one service worker can be active at a time.

However, we can force the browser to immediately activate our new service worker and retire the older one by calling self.skipWaiting() inside the install event listener.

Activation

Once the service worker is ready to take control, the browser fires the activate event. An event-handler here can perform cleanup tasks associated with the previous service worker- like deleting old caches that are no longer needed.

An important thing to note is, even when the new service worker is activated, the user still needs to refresh the existing open clients for the change to reflect. By calling self.clients.claim() we can force the service worker to take control of all existing clients immediately and silently in the background without any refresh.


Here is the code that implements all these concepts:

sw.js

// version 1

import * as utils from "./js/utils.js";

// Cache names with version numbers (increment when updating cached resources)
const STATIC_CACHE_NAME = 'static-v1';
const DYN_CACHE_NAME = `dynamic-v1`;

// App shell files that need to be cached for offline functionality
// These are the core files needed for the app to work offline
const STATIC_APP_SHELL = [
    '/',
    '/index.html',
    '/css/stylesheet.css',
    '/js/app.js',
    '/js/utils.js',
    '/manifest.json',
    '/assets/icons/icon-192.png',
    '/assets/icons/icon-512.png',
    '/assets/icons/icon-180.png',
    '/assets/icons/icon-144.png',
    '/assets/icons/icon-maskable-512.png',
    '/assets/icons/icon-maskable-192.png',
    '/assets/icons/notification-badge.png',
    '/assets/icons/favicon.png'
].map((url) => new URL(url, self.location.href)); // Convert to absolute URLs

// How long cached data stays fresh before needing update (24 hours)
const STALE_TIME = 24 * 60 * 60 * 1000;

// Maximum number of requests to store in the service-worker cache (you can safely increase this)
const MAX_CACHE_SIZE = 240;

// Register service worker event listeners: install, activate, fetch
self.oninstall = (evt) => evt.waitUntil(install_service());
self.onactivate = (evt) => evt.waitUntil(activate_service());
self.onfetch = (evt) => evt.respondWith(fetch_service(evt));

// Install event listener
async function install_service() {
    try {
        console.log("Installing service worker...");

        // Open/Create the static cache and store all app shell files
        const cache = await caches.open(STATIC_CACHE_NAME);
        await cache.addAll(STATIC_APP_SHELL);

        // Skip waiting phase to activate immediately
        await self.skipWaiting();

        console.log("Service Worker installed successfully.");
    } catch (error) {
        console.error("Error during SW installation:", error.message);
        // Re-throw to fail installation if critical files can't be cached
        throw error;
    }
}

// Activate service worker and clean up old caches
async function activate_service() {
    try {
        console.log("Activating service worker...");

        // Get all existing cache names
        const key_list = await caches.keys();

        // Find old cache versions that should be deleted
        const keys_to_delete = key_list.filter(key =>
            key !== STATIC_CACHE_NAME && key !== DYN_CACHE_NAME
        );

        // Delete all old caches in parallel
        await Promise.all(keys_to_delete.map(key => caches.delete(key)));

        // Take control of all existing clients immediately
        await self.clients.claim();

        console.log("Service Worker activated successfully.");
    } catch (error) {
        console.error("Error during SW activation:", error.message);
        throw error;
    }
}

First, the utils.js module is imported so that we can use its functions inside this service worker script.

We'll create 2 caches later- one for the static files (for our application's shell and also other 'static' assets like images) and one for the dynamic content (like the news articles).

The constants STATIC_CACHE_NAME and DYNAMIC_CACHE_NAME pre-define the names of these caches with version numbers which is a simple way to manage updates later.

The STATIC_APP_SHELL array contains all the essential barebone files that our app needs to function offline. The map on the array: map((url) => new URL(url, self.location.href)); converts the relative URLs to the absolute URLs.

For e.g. /index.html becomes https://your-site.com/index.html. This is because the assets are always requested with absolute URLs by the browser.

The events we registered: install, activate and fetch, are all extendable events. These events can be prolonged by calling evt.waitUntil(promise) inside the event listener.

The browser will wait for the promise to settle before it considers the event complete. This is useful because we can perform asynchronous tasks during these events and ensure they complete before moving on.

In the install event listener, the function cache.open() opens a cache with the given name or creates it if it doesn't exist. This cache can be used to store key-value pairs, where the key can be any string (often the request or its URL) and the value can be any data (often the response).

Here, we add all the application's shell files to the static cache using cache.addAll(). The self.skipWaiting(), as explained previously, is called to immediately activate the new service worker.

In the activate event listener, we first get all existing cache names using caches.keys(). We then filter out all except the current ones and delete them (the older ones, if present) using caches.delete().

This ensures there are no old caches taking up space. Finally, we call self.clients.claim() to take control of all existing clients without the user needing to refresh.

In the next and final section of building this application, we'll write the fetch event listener to intercept network requests and implement some more advanced capabilities.

Understanding Service Worker: The Advanced

The fetch event listener is where we can intercept all network requests made by our application. Based on the type of resource requested, we can employ different caching strategies here to optimize performance and reliability.

For our application, we'll implement 3 such strategies. Let's plan them one-by-one before we write the code.

  1. Cache First: The application will first check the cache for the resource. If it's not there, then it makes a network request to get the resource. It will also add a copy of the network response to the cache for the next time. This strategy is ideal for static assets like images, fonts, CSS, JS files etc.
  2. API Strategy (Stale-While-Revalidate): This will be for requests to the News API. The app will serve a cached version for recently fetched data (less than 24 hours old, as defined by STALE_TIME). Else, it will fetch a new response from the API and update the cache. This balances fast loading with up-to-date content.
  3. Network First: For any other requests. The app will try to fetch from the network first. If that fails (like when offline), it will fall back to the cache.

sw.js

// Continued from earlier... 
// Check if a URL represents a static resource that should be cached long-term
function is_static(url) {
    // Check if the URL matches any file in our app shell
    // Also treat common static file extensions as static resources
    return (
        STATIC_APP_SHELL.some((asset) => asset.pathname === url.pathname)
        ||
        (/\.(css|js|png|jpg|svg|ico)$/.test(url.pathname))
    );
}

// Validate if an API response is ok and not empty
async function validate_response(response) {
    // Opaque responses (from CORS) can't be inspected but may still be valid
    // OR
    // If the response is ok and has a non-zero body size
    return (
        (response.type === 'opaque')
        ||
        Boolean(response?.ok && (await response.clone().blob()).size > 0)
    );
}

// Keep cache size under control by removing older entries when limit is reached
async function limit_cache_size(cache) {
    const cached_requests = await cache.keys();

    // If cache is getting full, remove older half of entries
    if (cached_requests?.length >= MAX_CACHE_SIZE) {
        const older_requests = cached_requests.slice(0, MAX_CACHE_SIZE / 2);

        try {
            // Delete older entries in parallel with Promise.all() for better performance
            await Promise.all(older_requests.map(req => cache.delete(req)));
        } catch (error) {
            console.error('Error deleting old cache entries:', error);
        }
    }
}

// Store a response in cache with timestamp and size management
async function store_in_cache(url_string, response, cache_name) {
    // Validate the response first
    if (!(await validate_response(response))) {
        throw new Error(`Invalid or empty response for request: ${url_string}`);
    }

    try {
        const cache = await caches.open(cache_name);

        // Add timestamp to response headers for freshness tracking (if possible)
        if (response.type !== 'opaque') {
            const headers = new Headers(response.headers);
            headers.append("X-last-fetched", Date.now().toString());

            // Create new response with updated headers
            response = new Response(response.body, {
                status: response.status,
                statusText: response.statusText,
                headers: headers
            });
        }

        // Check Cache size and delete older entries before adding new one
        await limit_cache_size(cache);

        // Add the new one
        await cache.put(url_string, response);
    } catch (error) {
        console.error(`Error caching response for ${url_string}: ${error.message}`);
    }
}

async function cache_first_strategy(request, cache_name) {
    // Try to find the resource in cache first
    const cached_resp = await caches.match(request.url);
    if (cached_resp) {
        return cached_resp;
    }

    // If not in cache, fetch from the network and cache its clone for future use
    // Note: If an error occurs, it will be caught in the fetch event handler
    let fresh_resp;
    fresh_resp = await fetch(request);
    await store_in_cache(request.url, fresh_resp.clone(), cache_name);

    return fresh_resp;
}

// Network First Strategy: Try network first, fallback to cache if network fails
async function network_first_strategy(request, cache_name) {
    try {
        // try network first
        const fresh_resp = await fetch(request);
        await store_in_cache(request.url, fresh_resp.clone(), cache_name);
        return fresh_resp;
    } catch (error) {
        // Network failed - check if we have a cached version
        const cache_resp = await caches.match(request.url);
        if (!cache_resp) {
            throw error; // No cached fallback available
        }
        return cache_resp;
    }
}

// Stale-While-Revalidate Strategy: Serve cached content if fresh, otherwise fetch new
async function api_strategy(request) {
    let fetch_fresh_content = true;
    let fresh_resp;

    // Check if we have a cached version
    const cached_resp = await caches.match(request.url);
    if (cached_resp) {
        // Check if the cached content is still fresh (within STALE_TIME)
        const last_fetched = cached_resp.headers.get("X-last-fetched");
        fetch_fresh_content = !last_fetched || (Date.now() - Number(last_fetched) > STALE_TIME);

        // If fetching fresh content is not needed as per our requirement
        if (!fetch_fresh_content) {
            return cached_resp;
        }
    }

    // If we need fresh content, try to fetch from network
    if (fetch_fresh_content) {
        try {
            fresh_resp = await fetch(request);
            await store_in_cache(request.url, fresh_resp.clone(), DYN_CACHE_NAME);
            return fresh_resp;
        } catch (error) {
            // Network failed - use cached version if available, else throw error
            if (!cached_resp) {
                throw error;
            }
            return cached_resp;
        }
    }
}

// Fetch event handler: intercepts all network requests and applies caching strategies
async function fetch_service(evt) {
    let response;
    let request = evt.request;
    let url = new URL(request.url);
    let url_string = url.href;

    try {
        // Let other methods (POST, PUT, DELETE) go through normally
        if (request.method !== "GET") {
            return fetch(request);
        }

        // Route request to appropriate strategy based on resource type
        if (is_static(url)) {
            // Static resources (CSS, JS, images)
            response = await cache_first_strategy(request, STATIC_CACHE_NAME);
        }
        else if (url_string.startsWith(utils.BASE_API_URL)) {
            // News API calls
            response = await api_strategy(request);
        }
        else {
            // Other Dynamic content
            response = await network_first_strategy(request, DYN_CACHE_NAME);
        }
    } catch (error) {
        console.error(`Error in fetching ${url_string}: ${error.message}`);

        // If content cannot be served from either cache or network
        response = new Response(null, {
            status: 503,
            statusText: "Service Unavailable"
        });
    }

    return response;
}

There are many helper functions we have defined above to build our fetch event listener, fetch_service() and implement different types of caching. Here's the explanation of each:

The helper function is_static() checks if a given URL corresponds to a static resource - if the URL matches any of the files in STATIC_APP_SHELL or if it has common static file extensions like .css, .jpg, .png, etc.

The validate_response() function inspects the response to ensure it is ok and non-empty. Opaque responses (from CORS requests) cannot be inspected but may still be valid, so we allow them. For others, the blob size is checked to be greater than zero, though this is quite stringent. For a liberal approach, you can just check for response.ok.

The limit_cache_size() function ensures that the cache size remains under MAX_CACHE_SIZE. If the cache exceeds this limit, it deletes the older half of the entries to free up space.

The store_in_cache() function is used to put entries in the specified cache. Here, we also add a custom header "X-last-fetched" to track when the resource was last fetched. This helps us in determining its freshness with respect to STALE_TIME.

Note that the store_in_cache() function also checks for the validity of the response before caching it. Thus, we don't need to check for the validity anywhere else if we're routing through this function.

The "strategy" functions implement the 3 caching strategies we discussed earlier.

Finally, the fetch_service() function ties all these strategies together. It intercepts network requests and applies the appropriate caching strategy based on the request type and resource URL. It also returns a 503 response if no content can be served.

This intercepting network requests and implementing caching, is the most important task of any service worker. With this, our PWA is now feature-rich, installable and complete!

The Complete App

First, thank you for following this far! And congratulations on building a modern Progressive Web App.

We started from zero and first created a normal web application that fetches and displays news articles, and then enhanced it to become a PWA by adding essential features like a web app manifest and a service worker.

While we tapped into the most powerful capability of service workers—making it a perfect proxy between the application and the network—there are plenty more amazing features that can be enabled with service workers, like push notifications, background or periodic sync, etc.

For now, "The Gist Today" is good enough on its own. You can however, further build upon it to customize it to your liking and add more useful features.

You can download the complete PWA example here. Unzip and open index.html in a browser from an https enabled server or from your localhost to see it in action.

You'd also need to plug your own NewsAPI key in js/utils.js to fetch the news articles. You can get the free API key by signing up at newsdata.io.

Conclusion

PWAs solve real problems for both users and developers while providing actual business numbers as realised by many organisations.

In all these cases, as you noticed, the companies were able to pretty much increase their revenue by ~1.5 times just by enhancing their existing websites!

Nonetheless, there are some limitations of PWAs as well that we should touch upon to conclude this article.

Limitations of PWAs

A PWA primarily runs in a web browser. Even when you install the app and it runs full-screen, it is still running inside the browser engine from where it was installed.

As such, the app is ultimately tied to the browser. If the browser is uninstalled by the user, the app will also get removed. If a user cleans up the history and data (especially site or cache data) of the browser, the app's service worker cached data will also get deleted.

Whatever limitations or access to the device's features the browser has, the PWA will also have a subset of the same. On iOS, for example, PWAs can only be installed from Safari. Other browsers like Chrome or Firefox on iOS are essentially wrappers around Safari's engine and do not support PWA installation.

While there are many interesting modern APIs coming out, like Web Bluetooth, File System Access and WebXR (that aims to provide virtual & augmented Reality, VR-AR), their support is not universal among all the browsers, yet.

Access to features like advanced camera controls, NFC, or contact lists is also limited or non-existent, unlike the case with native apps.

Lastly, a user can install multiple versions of the same app from different browsers, with their own separate data. I don't know if this should be counted as a limitation, but this behaviour is also different from the native applications, which can have only one instance installed on the system.

There is a project Fugu, which is a joint initiative led by Google, Microsoft, and Intel, that aims to work upon many of these limitations in a secure manner. The APIs I mentioned above are a result of this project. You can read more about the project Fugu here.

Frequently Asked Questions (FAQs)

What are the prerequisites to build a PWA?

To build a Progressive Web App, you should have a basic understanding of:

  • HTML, CSS, and JavaScript
  • Responsive design principles
  • Service Workers and caching strategies
  • Web App Manifest and its properties

Do I need React or any JavaScript framework to build a PWA?

No. Since a PWA is fundamentally a website, you can build one using plain HTML, CSS, and JavaScript, just as we did in this tutorial. However, frameworks like React, Angular, or Vue can help organize your code and may simplify the development process for larger applications.

How do I make my PWA installable?

To make your PWA installable, you need to:

  • Include a valid web app manifest file with necessary properties like name, icons, start_url, display, and background_color.
  • Implement a service worker and register it.
  • Serve your app over HTTPS or localhost for security.

How do I force my service worker to update or re-install?

Browsers are designed to keep service workers running for stability. Here's how to force an update:

  • Through code: Update the service worker file (e.g., change its version number or cache version name).
  • Through the browser dev tools: Go to the Application panel, select your service worker, and click "Update on reload" or "Unregister" to force a re-install.
  • Clear site data: Clearing the browser's site data for your app will remove the service worker and its caches, forcing a fresh install on the next visit.

Can I list my PWA in app stores?

Yes, you can list your PWA in Microsoft Store and Google Play Store. Microsoft Store has native support for PWAs, while Google Play requires wrapping the PWA in a Trusted Web Activity (TWA) using Android Studio.

Why can I not install the PWA on google chrome in iOS?

On iOS, only Safari supports PWA installation. Other browsers like Chrome and Firefox on iOS are essentially wrappers around Safari's engine and do not support the installation prompt. To install a PWA on iOS, you need to use Safari and tap the "Share" button, then select "Add to Home Screen".

Can I use localStorage or sessionStorage in my service worker?

No, Service workers can't access localStorage, sessionStorage, or synchronous APIs. They operate in a separate context and can only use asynchronous APIs like IndexedDB, the Cache API or the CookieStore for storing data.

What is the storage capacity of a service worker cache?

Cache storage limits are large but not infinite, and they vary by browser. Instead of a fixed number of megabytes, browsers typically allocate a percentage of the available disk space (e.g., up to 60% for Chrome).

The most important thing to know is that this storage is temporary and can be cleared by the browser if the device runs low on space (a process called "origin eviction").

This is why it's important to have a cache cleanup strategy, like the limit_cache_size() function we implemented in this tutorial.