Zum Inhalt springen

How to Implement a Service Worker with WorkBox in a Progressive Web App

Imagine having a web app that looks and feels just like a native mobile app. It launches from your home screen, runs in full-screen mode, and responds smoothly to your interactions. But here’s the surprising part: it wasn’t downloaded from an app store. It’s a Progressive Web App (PWA).

PWAs bring the power of the web to your fingertips with the experience of a mobile app. Even better? If you lose internet connection while on the go, the app can still function, showing your previously loaded data and getting updates once you’re back online.

In this tutorial, you’ll learn how to implement a service worker with WorkBox in a weather app using HTML, CSS, and JavaScript. We’ll start by understanding what a PWA is, the core components behind the scenes, especially service workers, and how to use Workbox to supercharge your app with offline capabilities.

Table of Contents

What We’ll Cover

  • Setting Up the Project: We’ll build a simple weather app using HTML, CSS, and JavaScript. This approach is perfect for this tutorial because it keeps things simple and accessible while focusing on core PWA concepts without the added complexity of frameworks like React or Vue.

  • Turning the App into a PWA: Next, we’ll walk through the concept of a Progressive Web App, covering the key features and best practices of PWAs.

  • Implementing Service Worker via WorkBox: Finally, we’ll dive deeper into how service workers function and explore why using Workbox simplifies the process.

Here’s what the final application will look like:

Weatherly app interface showing Tokyo weather with 24°C temperature, overcast clouds, city search functionality, and location services button

Audience

This tutorial is for web developers of all levels. Whether you’re new to Progressive Web Apps (PWAs) or just starting to explore service workers, this guide will walk you through the core concepts and demonstrate why using a Google-backed library like Workbox to implement service workers can be more efficient than manual implementation.

Prerequisites

Before you begin

  1. Get a free API key from the OpenWeatherAPI website

  2. Make sure you’re familiar with HTML, CSS, and JavaScript.

  3. If you’re new to PWAs, you might want to read some introductory articles to get a quick overview.

What is a Progressive Web App (PWA)?

A PWA is a web application that combines the best of web and mobile apps. It’s built using standard web technologies like HTML, CSS, and JavaScript, but it behaves and feels like a native mobile app on your phone or tablet.

Think of apps like Instagram Web, Twitter Lite, or Spotify Web Player. Even though you’re not using a native app from an app store:

  • You can still scroll your feed, view media, and send messages.

  • It works even on slow or unstable networks.

  • You can “install” it on your home screen and launch it like a regular app.

  • You even get push notifications just like a mobile app!

With PWAs, you get the reach of the web and the feel of an app without the heavy storage or installation process.

What Makes a Web App “Progressive”?

A PWA is not just any website. It’s built to progressively enhance the user experience, depending on their device and browser capabilities. Here are the core characteristics that define a PWA:

  • Responsive: Works on all screen sizes, that is, phones, tablets, and desktops.

  • Reliable: Loads instantly, even when offline or on poor networks.

  • Installable: Can be added to the home screen without needing an app store.

  • Engaging: Supports features like push notifications and background sync.

Components of a PWA

Before your web app can be considered a PWA, it must include the following:

A Web Application Manifest

The web app manifest is a JSON file that tells the browser about your web app, how it should appear, and behave when installed on a user’s device.

Think of it like your app’s business card. It includes details like:

  • App name and short name – How your app is labeled on the home screen or app list.

  • Icons – Images used for app icons on different screen sizes and resolutions.

  • Theme color and background color – Defines the look of your app’s UI and loading screen.

  • Start URL – The page that opens when the app is launched.

  • Display mode – Controls whether the app opens in a browser tab, fullscreen, or a native-like window.

  • Screenshots – Optional preview images that show how your app looks on different devices in app stores or installation prompts.

A Service Worker

This is a script that runs in the background. It handles offline behaviour, caching, background sync, and push notifications needed to make your PWA function.

More details about the service worker will be discussed later in this article.

HTTPS

PWAs must be served over HTTPS. This is not optional. Here’s why:

  • It protects users by ensuring secure data transfer.

  • It enables important features like service workers and push notifications.

  • Browsers won’t allow service workers to register on non-secure origins.

If you’re testing locally, you can use localhost (which is treated as secure), But for production, your site must have an SSL certificate.

What is a Service Worker in PWA?

In PWAs, a service worker is a JavaScript file that runs in the background, separate from your main app, and acts like a network proxy. It can:

  • Cache resources and serve them offline

  • Intercept network requests and apply caching strategies

  • Handle background syncs

  • Manage push notifications

Think of it as your app’s behind-the-scenes assistant—makes it load fast, works offline, and stays updated, even when you’re not looking.

Why Use Workbox Instead of Manual Service Workers?

Service workers are essential in creating a PWA, but getting started with them can be challenging. Writing service worker code from scratch can often be tedious and prone to errors. For example, you’d need to:

  • Manually configure caching strategies

  • Handle service worker updates

  • Write and maintain a lot of repetitive boilerplate code

Workbox, a library from Google, makes things easier by letting developers focus on what matters, without worrying about the complicated parts of service workers.

However, it’s still important to understand how service workers function, since they handle some complex tasks under the hood.

Here are key things a service worker (with or without Workbox) does:

  • Install event: Set up cache

  • Activate event: Clean up old caches

  • Fetch event: Intercept network requests and serve from cache

With Workbox, these are wrapped in easy-to-use functions.

Introduction to WorkBox

Workbox is a collection of libraries that helps developers build efficient service workers quickly, with best practices built right in. It supports strategies like:

  • CacheFirst: Load from cache, fall back to network

  • NetworkFirst : Try network, fall back to cache

  • StaleWhileRevalidate: Serve from cache and update in the background

Understanding Workbox Modules

Workbox is more than just a tool. It is a collection of powerful modules, each designed to simplify different parts of working with service workers. These modules are flexible and can be used in three key contexts:

  • Service Worker Context – Inside your service worker file, where you handle caching, routing, and other background tasks.

  • Window Context – Inside your main application (the client-side JS), where you register and communicate with the service worker.

  • Build Tools Integration – Tools like Webpack use Workbox to generate service worker files and precache manifests during your build process.

Let’s break down some of the most popular and essential modules Workbox offers:

  1. workbox-routing

This module handles routing network requests within your service worker. Think of it like a traffic director that listens for fetch events and decides what to do with them.

Use case: Route API requests to the network while routing static asset requests to the cache.

  1. workbox-strategies

This is where caching strategies like CacheFirst, NetworkFirst, and StaleWhileRevalidate are used. It provides a clean and consistent API for handling how your app responds to different requests.

Use case: Apply different caching behaviours for images, fonts, or dynamic data with minimal code.

  1. workbox-precaching

This module handles precaching by storing static assets during the service worker’s install phase. It makes it easy to cache files ahead of time and ensures that updates are managed efficiently.

Use case: Preload essential assets (like HTML, CSS, and logo images) so your app loads instantly, even offline.

  1. workbox-expiration

It is used as a plugin alongside caching strategies. This module adds smart cache expiration. You can automatically remove old or excessive items from the cache based on how long they’ve been stored or how many items exist.

Use case: Keep your cache size under control without manually tracking and deleting outdated files.

workbox-window

This module is designed for the browser (window) side of your app. It simplifies service worker registration and allows you to communicate with the service worker from your page easily.

Use case: Detect when a new service worker is available and prompt the user to refresh the app to update.

You can use WorkBox via:

  • npm

  • CDN (which we’ll use here for simplicity)

Project Setup

Let’s start by creating our project structure:

weather-pwa/
├── index.html
├── style.css
├── js/
│   ├── app.js
│   └── install.js
├── service-worker.js
├── images/
│   └── [your image files and folders here]
├── manifest.json
├── config.js  
└── offline.html

The HTML Structure

First, let’s build our index.html file:


<!DOCTYPE html>
<html lang="en">
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <link rel="icon" href="/images/logo.png" type="image/png">
        <meta name="description" content="Simple Weather Progressive Web App" />
        <link rel="stylesheet" href="/styles.css" />
        <title>Weatherly</title>
    </head>


<body>
    <header class="header">
        <img loading="lazy" class="logo" src="images/logo.png" alt="Weatherly Logo">
        <h1>Weatherly</h1>
    </header>

    <main class="main">
        <div class="weather-card">
            <div class="location-container">
                <input type="text" id="location-input" placeholder="Enter city name">
                <button id="search-btn">Search</button>
                <button id="locationBtn">📍 Use My Location</button>
                <button id="installBtn" style="display: none;">Install App</button>
            </div>

            <div id="offline-message" class="offline-message">
                You are currently offline. Weather data may not be up-to-date.
            </div>


            <div class="error">
                <p id="error-message"></p>
            </div>

            <div id="weather-container" class="weather-container">
                <h3>Your last searched location weather:</h3>
                <div class="location-info">
                    <h2 id="city"></h2>
                    <p id="date"></p>
                </div>

                <div class="current-weather">
                    <img loading="lazy" id="weather-icon" src="" alt="Weather icon">
                    <div class="temperature-container">
                        <h3 id="temperature"></h3>
                        <p id="weather-description"></p>
                    </div>
                </div>

                <div class="weather-details">
                    <div class="detail">
                        <img loading="lazy" id="humidity-icon" src="/images/humidity.png" alt="Humidity icon">
                        <span class="label">Humidity</span>
                        <span id="humidity" class="value"></span>
                    </div>
                    <div class="detail">
                        <img loading="lazy" id="wind-icon" src="/images/wind.png" alt="Wind icon">
                        <span class="label">Wind</span>
                        <span id="wind" class="value"></span>
                    </div>
                </div>
            </div>

            <!-- Your location weather -->
            <div class="location-weather">
                <h3>Your location's weather:</h3>
                <div class="weather-info" id="weatherInfo">

                </div>
            </div>
        </div>
    </main>

    <footer>
        <p>Made with ❤️ by <a href="www.linkedin.com/in/damilola-oniyide">Damilola Oniyide</a>
    </footer>
    <script type="module" src="/js/app.js" defer></script>
</body>
</html>

Creating the Offline HTML Structure

The offline.html is the page that users will see when they lose network connection and try to navigate to a page that isn’t cached.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta name="theme-color" content="#2196f3">
  <title>Weatherly - Offline</title>
  <link rel="stylesheet" href="/styles.css">
  <style>
    .offline-icon {
      font-size: 5rem;
      margin-bottom: 1.5rem;
      color: #2196f3;
    }

    .offline-message {
      font-size: 1.5rem;
      margin-bottom: 1.5rem;
    }

    .offline-subtext {
      font-size: 1rem;
      margin-bottom: 2rem;
      color: #666;
    }

    .retry-button {
      padding: 0.75rem 1.5rem;
      background-color: #2196f3;
      color: white;
      border: none;
      border-radius: 12px;
      font-size: 1rem;
      cursor: pointer;
      transition: background-color 0.3s;
    }

    .retry-button:hover {
      background-color: #2980b9;
    }
  </style>
</head>
<body>
  <header>
    <h1>Weatherly</h1>
  </header>

  <main>
    <div class="app-container">
      <div class="weather-card">
        <div class="offline-container">
          <div class="offline-icon">
            <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 16 16">
              <path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
              <path d="M7 6.5C7 7.328 6.552 8 6 8s-1-.672-1-1.5S5.448 5 6 5s1 .672 1 1.5zm-2.715 5.933a.5.5 0 0 1-.183-.683A4.498 4.498 0 0 1 8 9.5a4.5 4.5 0 0 1 3.898 2.25.5.5 0 0 1-.866.5A3.498 3.498 0 0 0 8 10.5a3.498 3.498 0 0 0-3.032 1.75.5.5 0 0 1-.683.183zM10 8c-.552 0-1-.672-1-1.5S9.448 5 10 5s1 .672 1 1.5S10.552 8 10 8z"/>
            </svg>
          </div>
          <h2 class="offline-message">You're offline</h2>
          <p class="offline-subtext">Please check your internet connection and try again.</p>
          <button class="retry-button" onclick="window.location.href='/'">Retry</button>
        </div>
      </div>
    </div>
  </main>

  <footer>
    <p>Made with ❤️ by Damilola Oniyide</p>
  </footer>
</body>
</html>

Styling with CSS

Now, let’s create our style.css file for a responsive and user-friendly design:

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

body {
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
    background-color: #f5f5f5;
    color: #333;
    line-height: 1.6;
}

.header {
    background-color: #2196f3;
    color: white;
    padding: 1rem;
    display: flex;
    justify-content: center;
    align-items: center;
    box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}

.header h1 {
    font-size: 1.5rem;
}


.header img {
    width: 55px;
    height: 55px;
    border: #ffff 1px solid;
    margin-right: 4px;
    border-radius: 10%;
}


.main {
    padding: 1rem;
    max-width: auto;
    margin: 0 auto;
}

.weather-card {
    background-color: white;
    border-radius: 8px;
    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
    padding: 1.5rem 3rem;
    margin-top: 1rem;
}

/* Location input styles */
.location-container {
    display: flex;
    margin-bottom: 1.5rem;
    justify-content: center;
}

#location-input {
    flex: 1;
    padding: 0.75rem;
    border: 1px solid #ddd;
    border-radius: 4px 0 0 4px;
    font-size: 1rem;
    max-width: 240px;
}

#location-input:focus {
    outline: none;
    border-color: #2196f3;
}
#location-input::placeholder {
    color: #999;
}   

#search-btn, #locationBtn {
    background-color: #2196f3;
    color: white;
    border: none;
    padding: 0.75rem 1rem;
    border-radius: 0 4px 4px 0;
    cursor: pointer;
    font-size: 1rem;
    margin-right: 2.5px;
}


#installBtn {
    background-color: #2196f3;
    color: white;
    border: none;
    padding: 0.75rem 1rem;
    border-radius: 4px;
    cursor: pointer;
    font-size: 1rem;

}

#search-btn:focus, #locationBtn:focus, #installBtn:focus {
    outline: none;
    box-shadow: 0 0 5px rgba(33, 150, 243, 0.5);
}
#search-btn:hover, #locationBtn:hover, #installBtn:hover {
    background-color: #1976d2;
}

.error, .loading {
    text-align: center;
    font-weight: bold;
    font-size: 14px;
    margin-top: 10px;
    display: none;;
}

.error-message {
    color: #d32f2f;

}
/* Weather display styles */
.weather-container {
    display: none 
}

#weather-icon {
    width: 1000px; 
    height: 100px;
  }

.current-weather{
    margin-bottom: 2rem;
    display: flex;
    justify-content: center;
}

.location-weather{
    margin-top: 2rem;
    display: flex;
    justify-content: center;
    flex-direction: column;
}


#weather-icon {
    width: 80px;
    height: 80px;
    margin-right: 1rem;
}

.location-info {
    margin-bottom: 1rem;
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
}

.location-info h2,  .current-weather h3, .weather-container h3, .location-weather h3 {
    font-size: 1.8rem;
    margin-bottom: 0.25rem;
}



.location-info p, .current-weather p {
    color: #666;
    font-size: 1.4rem;
}

.temperature-container {
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    margin-bottom: 1rem;
}

.temperature-container h3 {
    font-size: 2.5rem;
    margin-bottom: 0.25rem;
}

.temperature-container p {
    color: #666;
    text-transform: capitalize;
}

.weather-details {
    display: flex;
    justify-content: center;
    background-color: #f9f9f9;
    border-radius: 4px;
    padding: 1rem;
}

#humidity-icon, #wind-icon{
    width: 40px;
    height: 40px;
}

.detail {
    display: flex;
    flex-direction: column;
    align-items: center;
    margin: 0 1rem;
    text-align: center;
}

.label {
    font-size: 0.9rem;
    color: #666;
    margin-bottom: 0.25rem;
}

.value {
    font-size: 1.2rem;
    font-weight: 500;
}

/* Error and offline message styles */
.error-message {
    color: #d32f2f;
    text-align: center;
    margin-top: 1rem;
    display: none;
} 

.offline-message {
    background-color: #ffab91;
    color: #7f0000;
    padding: 0.75rem;
    text-align: center;
    margin-top: 1rem;
    border-radius: 4px;
    display: none;
}


/* 5 days forecast weather */
.forecast-container {
    display: flex;
    justify-content: space-around;
    gap: 1rem;
}

.forecast-item {
    background-color: white;
    border-radius: 8px;
    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
    padding: 1rem 4rem;
    text-align: center;
}



footer {
    background-color: #2196f3;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: .7rem 0;
}

footer p, footer a {
    color: #f9f9f9;
    font-weight: 500;
}
/* Responsive styles */
@media (max-width: 480px) {
    .header h1 {
        font-size: 1.2rem;
    }

    .location-container {
        flex-direction: column;
        align-items: center;
        gap: .6rem
    }


    .current-weather {
        flex-direction: column;
        justify-content: center;
        align-items: center;
    }

    .weather-container h3,  .location-weather h3, .forecast h3 {
        font-size: 1.5rem;
    }

    #weather-icon {
        margin-right: 0;
        margin-bottom: 1rem;
    }

    .forecast-container {
        flex-direction: column;
        align-items: center;
    }
}

How to Set Up app.js and config.js

Now, let’s create our app.js file to add functionality to the weather app. Before proceeding, ensure you’ve obtained your API key from OpenWeather. For best practice, store your API key in a separate file like config.js to keep things organized and avoid hardcoding sensitive data.

Here’s what your config.js should look like:

export const CONFIG = {
    WEATHER_API_KEY: "WRITE-YOUR-API-KEY-HERE",
};

Ensure you add the config.js file to .gitignore to avoid leaking sensitive information on a public platform like GitHub.

Now let’s move to app.js. This is where the main logic of your weather app will live. You can now reference your API key using Weather_API_KEY from the config.js file.

Below is the structure of your app.js file:

import { CONFIG } from './config.js';
const BASE_URL = `https://api.openweathermap.org/data/2.5/weather?&appid=${CONFIG.WEATHER_API_KEY}&units=metric&q=`;

const cityName = document.getElementById('location-input');
const searchButton = document.getElementById('search-btn');
const weatherIcon = document.getElementById('weather-icon');
const locationBtn = document.getElementById('locationBtn');
const weatherInfo = document.getElementById('weatherInfo');


function getWeatherIcon(condition) {
  switch (condition) {
    case "Clear":
      return "images/weather-icons/clear.png";
    case "Clouds":
      return "images/weather-icons/clouds.png";
    case "Drizzle":
      return "images/weather-icons/drizzle.png";
    case "Rain":
      return "images/weather-icons/drizzle.png";
    case "Mist":
      return "images/weather-icons/mist.png";
    case "Snow":
      return "images/weather-icons/snow.png";
    default:
      return "images/weather-icons/default.png";
  }
}
//Search for weather by city name
async function checkWeatherBySearch(city){
    if(city.length == 0) {
        document.getElementsByClassName('error')[0].style.display = 'block';
        document.getElementsByClassName('error')[0].innerHTML = "Please enter a city name!";
        document.getElementsByClassName('error')[0].style.color = 'red';
        document.getElementById('weather-container').style.display = 'none'; 
        return;
    }
    const response = await fetch(BASE_URL + city);
    document.getElementsByClassName('error')[0].style.display = 'block';
    document.getElementsByClassName('error')[0].innerHTML = "Wait a sec, your location's data will be displayed soon!";

    if (response.status == 404) {
        document.getElementsByClassName('error')[0].style.display = 'block';
        document.getElementsByClassName('error')[0].innerHTML = "City not found! Please enter a valid city name.";
        document.getElementsByClassName('error')[0].style.color = 'red';
        document.getElementById('weather-container').style.display = 'none';       
    } else {
      const data = await response.json();
      document.getElementById('weather-container').style.display = 'block';
      document.getElementsByClassName('error')[0].style.display = 'none';
      localStorage.setItem('lastCity', city);
      document.getElementById('city').innerHTML = data.name;
      document.getElementById('date').innerHTML = new Date(data.dt * 1000).toLocaleDateString();
      document.getElementById("temperature").innerHTML = Math.round(data.main.temp) + "°C";
      document.getElementById("humidity").innerHTML = data.main.humidity + "%";
      document.getElementById("wind").innerHTML = data.wind.speed + "m/s";
      document.getElementById('weather-description').innerHTML = data.weather[0].description;
      const weatherCondition = data.weather[0].main;
      weatherIcon.src = getWeatherIcon(weatherCondition);
    }
}

 // display next 5-day forecast by coordinates
function display5DaysForecast(forecast) {
   const fragment = document.createDocumentFragment(); 
    const forecastWrapper = document.createElement('div');
    forecastWrapper.className = 'forecast';

    const heading = document.createElement('h3');
    heading.innerHTML = "Your location's next 5 days forecast:";

    const container = document.createElement('div');
    container.className = 'forecast-container';

    const addedDates = new Set();
    const today = new Date().toDateString();

    forecast.forEach((entry) => {
      const entryDateObj = new Date(entry.dt * 1000);
      const entryDateStr = entryDateObj.toDateString();

      if (entryDateStr !== today && !addedDates.has(entryDateStr)) {
        addedDates.add(entryDateStr);
        if (addedDates.size > 6) return;


        const condition = entry.weather[0].main;
        const iconSrc = getWeatherIcon(condition);

        const forecastItem = document.createElement('div');
        forecastItem.className = 'forecast-item';

        const date = document.createElement('p');
        date.id = 'date';
        date.innerHTML = `<strong>${new Date(entry.dt * 1000).toLocaleDateString()}</strong>`;

        const icon = document.createElement('img');
        icon.loading = 'lazy';
        icon.id = 'weather-icon';
        icon.src = iconSrc;
        icon.alt = `${condition} icon`;

        const tempContainer = document.createElement('div');
        tempContainer.className = 'temperature-container';

        const temp = document.createElement('h3');
        temp.id = 'temperature';
        temp.innerHTML = `${Math.round(entry.main.temp)} °C`;

        const description = document.createElement('p');
        description.id = 'weather-description';
        description.innerHTML = `${entry.weather[0].description}`;

        tempContainer.appendChild(temp);
        tempContainer.appendChild(description);
        forecastItem.appendChild(date);
        forecastItem.appendChild(icon);
        forecastItem.appendChild(tempContainer);
        container.appendChild(forecastItem);
      }
    });

    forecastWrapper.appendChild(heading);
    forecastWrapper.appendChild(container);
    fragment.appendChild(forecastWrapper);
    weatherInfo.appendChild(fragment); 
}

// Fetch next 5-day forecast by coordinates
function get5DaysForecast(lat, lon) {
    fetch(
      `https://api.openweathermap.org/data/2.5/forecast?lat=${lat}&lon=${lon}&appid=${CONFIG.WEATHER_API_KEY}&units=metric`
    )
      .then(res => res.json())
      .then(data => {
        requestIdleCallback(() => {
          setTimeout(() => display5DaysForecast(data.list), 0);
        });        
      })
      .catch(() => {
        weatherInfo.innerHTML = 'Error fetching forecast data.';
    });
}

 // Display current weather data
function displayUserWeather(data) {
    const weatherCondition = data.weather[0].main;
    const iconSrc = getWeatherIcon(weatherCondition);

    weatherInfo.innerHTML = `
      <h2 id="city">${data.name}, ${data.sys.country}</h2>

      <div class="current-weather">
        <img loading="lazy" id="weather-icon" src="${iconSrc}" alt="Weather icon">
        <div class="temperature-container">
          <h3 id="temperature"> ${Math.round(data.main.temp)} °C</h3>
          <p id="weather-description">${data.weather[0].description}</p>
        </div>
      </div>

      <div class="weather-details">
        <div class="detail">
          <img loading="lazy" id="humidity-icon" src="/images/humidity.png" alt="Humidity icon">
          <span class="label">Humidity</span>
          <span id="humidity" class="value"> ${data.main.humidity}%</span>
        </div>
        <div class="detail">
          <img loading="lazy" id="wind-icon" src="/images/wind.png" alt="Wind icon">
          <span class="label">Wind</span>
          <span id="wind" class="value"> ${data.wind.speed} m/s</span>
        </div>
      </div>
    `;
  }

// Fetch weather by coordinates
function getWeatherByCoords(lat, lon) {
    fetch(
      `https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lon}&appid=${CONFIG.WEATHER_API_KEY}&units=metric`
    )
      .then(res => res.json())
      .then(data => {
        displayUserWeather(data);
        get5DaysForecast(lat, lon);
      })
      .catch(() => {
        weatherInfo.innerHTML = 'Please turn on your device&apos;s location to get weather data.';;
      });
  }

// Event listeners for search button and input field
cityName.addEventListener('keypress', (e) => {
    if (e.key === 'Enter') checkWeatherBySearch(cityName.value);
});

  // Search button click event
searchButton.addEventListener('click', ()=>{
    checkWeatherBySearch(cityName.value);
});

// Geolocation button
locationBtn.addEventListener('click', () => {
    if (navigator.geolocation) {
      navigator.geolocation.getCurrentPosition(
        pos => {
          const { latitude, longitude } = pos.coords;
          getWeatherByCoords(latitude, longitude);
        },
        () => {
          weatherInfo.innerHTML = 'Unable to retrieve location.';
        }
      );
    } else {
      weatherInfo.innerHTML = 'Geolocation not supported.';
    }
});


// Load last searched city
window.onload = () => {
    const lastCity = localStorage.getItem('lastCity');
    if (lastCity) {
        checkWeatherBySearch(lastCity);
    }

    if (navigator.geolocation) {
        navigator.geolocation.getCurrentPosition(
          pos => {
            const { latitude, longitude } = pos.coords;
            getWeatherByCoords(latitude, longitude);
          },
          () => {
            weatherInfo.innerHTML = 'Unable to retrieve location.';
          }
        );
      } else {
        weatherInfo.innerHTML = 'Geolocation not supported.';
      }
};

Now that we have our weather app. Let’s go further to make it a progressive web app.

How to Create a Manifest File

We need to create a manifest.json file, a critical part of making your app a PWA. We’ll also use pwa-asset-generator, a CLI tool that helps you to generate all the necessary icons and splash screens from a single image (like your logo). This tool also updates your manifest.json and optionally injects relevant <link> tags into index.html.

Below is the manifest.json file containing key properties that define how the Progressive Web App behaves and appears when installed.

{
  "name": "Weatherly",                      // The full name of your app that may be shown to users.
  "short_name": "Weatherly",               // A shorter name used when space is limited, like on the home screen.
  "description": "A simple weather Progressive Web App", // A short description of what your app does.
  "start_url": "/index.html",              // The page that opens when the app is launched from the home screen.
  "display": "standalone",                 // Makes the app look like a native app without browser UI (like address bar).
  "background_color": "#ffffff",           // The background color used when the app is loading.
  "theme_color": "#2196f3",                // The main color of the app’s UI, like the status bar.
  "orientation": "portrait",                // Locks the screen orientation to portrait mode.
   "screenshots": [                         //helps show users a preview of your app before installing it — especially in places like the "Add to Home screen" prompt on Android or in app stores that support PWAs.
        {
          "src": "images/screenshots/desktop-screenshot.png",
          "sizes": "1337x645",
          "type": "image/png",
          "form_factor": "wide"
        },
        {
          "src": "images/screenshots/mobile-screenshot.png",
          "sizes": "720x1417",
          "type": "image/png",
          "form_factor": "narrow"
        }
      ]
}

How to Generate Icons and Splash Screens

Inside your images folder, create a new folder called assets. This will store all the generated icons and splash screens. When your app is launched from the home screen, these splash screens will help improve the user experience on iOS devices.

Run the following command to generate PWA assets, update the manifest.json, and inject <link> tags into index.html

npx pwa-asset-generator logo.png ./images/assets -m manifest.json -i index.html

Injected Link Tags in index.html

Once the command runs successfully, a series of <link> and <meta> Tags will be automatically added to your index.html <head>. These tags ensure support for splash screens and icons across various Apple devices:

<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <!-- Other meta/link tags -->

  <link rel="apple-touch-icon" href="images/assets/apple-icon-180.png">
  <meta name="mobile-web-app-capable" content="yes">

  <link rel="apple-touch-startup-image" href="images/assets/apple-splash-2048-2732.jpg" media="(device-width: 1024px) and (device-height: 1366px) and (orientation: portrait)">
  <link rel="apple-touch-startup-image" href="images/assets/apple-splash-2732-2048.jpg" media="(device-width: 1024px) and (device-height: 1366px) and (orientation: landscape)">
  <!-- ...more splash screen tags for various devices... -->
</head>

Here’s how the manifest.json file should look like now:

{
    "name": "Weatherly",
    "short_name": "Weatherly",
    "description": "A simple weather Progressive Web App",
    "start_url": "/index.html",
    "display": "standalone",
    "background_color": "#ffffff",
    "theme_color": "#2196f3",
    "orientation": "portrait",
    "icons": [
        [
            {
              "src": "images/assets/manifest-icon-192.maskable.png",
              "sizes": "192x192",
              "type": "image/png",
              "purpose": "any"
            },
            {
              "src": "images/assets/manifest-icon-192.maskable.png",
              "sizes": "192x192",
              "type": "image/png",
              "purpose": "maskable"
            },
            {
              "src": "images/assets/manifest-icon-512.maskable.png",
              "sizes": "512x512",
              "type": "image/png",
              "purpose": "any"
            },
            {
              "src": "images/assets/manifest-icon-512.maskable.png",
              "sizes": "512x512",
              "type": "image/png",
              "purpose": "maskable"
            }
          ]
        ],
    "screenshots": [
        {
          "src": "images/screenshots/desktop-screenshot.png",
          "sizes": "1337x645",
          "type": "image/png",
          "form_factor": "wide"
        },
        {
          "src": "images/screenshots/mobile-screenshot.png",
          "sizes": "720x1417",
          "type": "image/png",
          "form_factor": "narrow"
        }
      ]
    }

You can then link your manifest file to your HTML file:

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

How to Add WorkBox to Your service-worker.js File

In this tutorial, WorkBox will be added to index.html via CDN. You can copy the import code below or visit WorkBox to get the link. You can then add it to the index.html file by placing the URL inside a <script> tag. You can copy the import code below or visit the WorkBox website for the latest link.

importScripts('https://storage.googleapis.com/workbox-cdn/releases/6.5.4/workbox-sw.js');

How to Create your Service Worker in the service-worker.js File

Here, we’ll implement the necessary functionalities needed to make the weather app a PWA

Step 1: Activate the New Service Worker Immediately

Add workbox.core.skipWaiting() to make the newly installed service worker activate right away instead of waiting for the old one to be removed in the service-worker.js file.

workbox.core.skipWaiting();

Step 2: Take Control of Open Tabs

Add workbox.core.clientsClaim() to ensure that the activated service worker takes control of all currently open pages, so the latest version of your app works immediately across all tabs after it becomes active.

workbox.core.clientsClaim();

Step 3: Check if Workbox is Loaded

Before using Workbox, make sure it has loaded properly.

if (workbox) {
  console.log('Workbox loaded successfully');
} else {
  console.log('Workbox failed to load');
}

This confirms that the workbox object is available and ready to use. If not, the fallback message in the else block will be shown.

We then proceed to create the functions inside the if block

Step 4: Pre-cache Core Files

Pre-cache essential files enable your app to work offline. This caches your app shell (HTML, CSS, JS), so it loads even without a network connection.

workbox.precaching.precacheAndRoute([
    { url: '/index.html', revision: '3' },
    { url: '/style.css', revision: '11' },
    { url: '/app.js', revision: '7' },
    { url: '/images/logo.png', revision: '3' },
    { url: '/manifest.json', revision: '5' },
    { url: '/offline.html', revision: '1' },
  ]);

The revision helps with updating cached files when changes are made.

Step 5: Cache API Responses Dynamically

Set up a route to cache data from your weather API using the NetworkFirst caching strategy. This tells Workbox to try fetching fresh data from the network first. If the network fails, it serves the cached version instead.

 // Cache API requests 
  workbox.routing.registerRoute(
    ({ url }) => url.origin === 'https://api.openweathermap.org',
    new workbox.strategies.NetworkFirst({
      cacheName: 'weather-api-cache',
      plugins: [
        new workbox.expiration.ExpirationPlugin({
          maxAgeSeconds: 24 * 60 * 60,
          maxEntries: 10,
        }),
      ],
    })
  );

Step 6: Dynamic Image Caching

This function enables dynamic caching for images using the StaleWhileRevalidate strategy. When a user requests an image, Workbox first serves it from the cache (if available) for faster load times, while simultaneously fetching an updated version from the network to refresh the cache. This ensures users get a quick response without missing out on updated content. It’s a smart way to handle images by balancing speed and freshness.

// Cache images
  workbox.routing.registerRoute(
    ({ request }) => request.destination === 'image',
    new workbox.strategies.StaleWhileRevalidate({
      cacheName: 'image-cache',
    })
  );

Step 7: Serve Cached Resources

The commonly used static files (like HTML, CSS, JS, fonts, and so on) are served quickly from the cache. It uses the CacheFirst strategy, meaning that the service worker will look in the cache first and only fetch from the network if the file isn’t already stored. The cache is named "static-cache" and it’s set to automatically remove items older than seven days using the expiration plugin. This helps keep the cache fresh and avoids taking up too much space.

  // Serve Cached Resources 
  workbox.routing.registerRoute(
    ({url}) => url.origin === self.location.origin,  
    new workbox.strategies.CacheFirst({
      cacheName: 'static-cache',  
      plugins: [
        new workbox.expiration.ExpirationPlugin({
          maxAgeSeconds: 7 * 24 * 60 * 60,  // Cache static resources for 7 days
        }),
      ],
    })
  );

Step 8: Cache HTML Pages with Offline Support

The index.html page will be handled using the NetworkFirst strategy. This means that the service worker tries to fetch the latest version from the network first. If the user is offline or the network fails, it falls back to the cached version. The cache is named "pages-cache" and the offline fallback page (offline.html) is returned when the requested page isn’t available. This ensures that users can still navigate the app even without an internet connection.

// Serve HTML pages with Network First and offline fallback
workbox.routing.registerRoute(
  ({ request }) => request.mode === 'navigate',
  async ({ event }) => {
    try {
      const response = await workbox.strategies.networkFirst({
        cacheName: 'pages-cache',
        plugins: [
          new workbox.expiration.ExpirationPlugin({
            maxEntries: 50,
          }),
        ],
      }).handle({ event });
      return response || await caches.match('/offline.html');
    } catch (error) {
      return await caches.match('/offline.html');
    }
  }
);

Step 9: Handle When Workbox Doesn’t Load

You should always provide a fallback in case something goes wrong. The if block will have an else block to catch issues during development and debugging.

else {
     console.log('Workbox failed to load');
}

Once the service worker finishes handling the different conditions in the if-else block, we add a general cleanup step to remove any outdated or unused caches.

Step 10: Clean Up Outdated Caches

During the service worker’s activation phase, old or unused caches are removed. It compares all existing cache names with a list of current ones (precache, weather-api-cache, image-cache, pages-cache, and static-resources). If a cache doesn’t match the current list, it gets deleted. This helps keep the app lightweight and ensures that outdated data doesn’t persist.

// Clean up old/unused caches during activation
self.addEventListener('activate', event => {
  const currentCaches = [
    workbox.core.cacheNames.precache,
    'weather-api-cache',
    'image-cache',
    'pages-cache',
    'static-cache'
  ];

  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames.map(cacheName => {
          if (!currentCaches.includes(cacheName)) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

This is what your service-worker.js file should look like:

importScripts('https://storage.googleapis.com/workbox-cdn/releases/6.5.4/workbox-sw.js');

// Force waiting service worker to become active
workbox.core.skipWaiting();
workbox.core.clientsClaim();

if (workbox) {
  console.log('Workbox loaded successfully');

  // Precache critical files with revisions (update revisions when files change)
  workbox.precaching.precacheAndRoute([
    { url: '/index.html', revision: '3' },
    { url: '/style.css', revision: '11' },
    { url: '/app.js', revision: '7' },
    { url: '/images/logo.png', revision: '3' },
    { url: '/manifest.json', revision: '5' },
    { url: '/offline.html', revision: '1' },
  ]);

  // Cache API requests 
  workbox.routing.registerRoute(
    ({ url }) => url.origin === 'https://api.openweathermap.org',
    new workbox.strategies.NetworkFirst({
      cacheName: 'weather-api-cache',
      plugins: [
        new workbox.expiration.ExpirationPlugin({
          maxAgeSeconds: 24 * 60 * 60,
          maxEntries: 10,
        }),
      ],
    })
  );

  // Cache images
  workbox.routing.registerRoute(
    ({ request }) => request.destination === 'image',
    new workbox.strategies.StaleWhileRevalidate({
      cacheName: 'image-cache',
    })
  );

    // Serve Cached Resources 
  workbox.routing.registerRoute(
    ({url}) => url.origin === self.location.origin,  
    new workbox.strategies.CacheFirst({
      cacheName: 'static-cache',  
      plugins: [
        new workbox.expiration.ExpirationPlugin({
          maxAgeSeconds: 7 * 24 * 60 * 60,  // Cache static resources for 7 days
        }),
      ],
    })
  );

  // Serve HTML pages with Network First and offline fallback
workbox.routing.registerRoute(
  ({ request }) => request.mode === 'navigate',
  async ({ event }) => {
    try {
      const response = await workbox.strategies.networkFirst({
        cacheName: 'pages-cache',
        plugins: [
          new workbox.expiration.ExpirationPlugin({
            maxEntries: 50,
          }),
        ],
      }).handle({ event });
      return response || await caches.match('/offline.html');
    } catch (error) {
      return await caches.match('/offline.html');
    }
  }
);
} else {
  console.log('Workbox failed to load');
}

// Clean up old/unused caches during activation
self.addEventListener('activate', event => {
  const currentCaches = [
    workbox.core.cacheNames.precache,
    'weather-api-cache',
    'image-cache',
    'pages-cache',
    'static-cache'
  ];

  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames.map(cacheName => {
          if (!currentCaches.includes(cacheName)) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

How to Set Up App Installation

The code to install the app will be written in install.js following the steps below:

Step 1: Register the Service Worker

Register the service worker to activate and run it in your app.

if('serviceWorker' in navigator){
    window.addEventListener('load', () => {
      navigator.serviceWorker.register('/service-worker.js').then(reg => {
        reg.onupdatefound = () => {
          const newWorker = reg.installing;
          newWorker.onstatechange = () => {
            if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
              window.location.reload();
            }
          };
        };
      });
    })
 }

Step 2: Enable Custom Install Prompt

Next, we will allow users to install the weather PWA with a custom button. Inside the install.jsfile, add the beforeinstallprompt event which intercepts the default prompt and shows your install button instead. When clicked, it triggers the install prompt.


  let deferredPrompt;

document.addEventListener('DOMContentLoaded', () => {
  const installBtn = document.getElementById('installBtn');

  window.addEventListener('beforeinstallprompt', (e) => {
    e.preventDefault();
    deferredPrompt = e;

    // Show the button
    installBtn.style.display = 'block';

    installBtn.addEventListener('click', () => {
      // Directly triggered by user click
      installBtn.style.display = 'none';

      // Show the install prompt
      deferredPrompt.prompt();

      deferredPrompt.userChoice.then((choiceResult) => {
        if (choiceResult.outcome === 'accepted') {
          console.log('User accepted the install prompt');
        } else {
          console.log('User dismissed the install prompt');
        }
        deferredPrompt = null;
      });
    });
  });

The appinstalled event confirms successful installation.


window.addEventListener('appinstalled', () => {
    console.log('PWA was installed');
  });
});

Step 3: Add script tag to import install.js in index.html

Add the <script> tag for install.js inside the index.html file to include the installation logic.

 <script type="module" src="/js/install.js"></script>

How to Install the Weather App

You can choose to install the Weatherly app on your phone or desktop. Below is a demonstration on how to install it on your mobile phone:

Open the Weatherly app in your browser. You should see an “Install App” button, as shown in the image below. Click on the button to continue.

Weatherly app interface showing Install App button along with city search field, location services, and Tokyo weather history

After clicking, a preview of the app will appear along with an “Install” option, as shown below. Click the Install button.

Browser PWA installation dialog showing Weatherly app preview with Install button and app description.

Once the installation is complete, the Weatherly app will appear on your home screen, just like a native app. And that’s it! Your weather app is now a Progressive Web App (PWA).

Conclusion

Progressive Web Apps combine the best of web and native app experiences, and service workers are the backbone of that functionality. With tools like Workbox, you don’t have to worry about manually handling caching, offline support, or background sync. Its simple APIs and built-in strategies make it easier to build fast, reliable, and installable web apps. Whether it’s a small weather app like Weatherly or a more complex project, Workbox helps you deliver a seamless user experience.

You can check out the full project and assets on GitHub

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert