How to Build a Progressive Web App: A Comprehensive Step-by-Step Tutorial for 2025

Introduction

Progressive Web Apps (PWAs) have fundamentally changed the way users interact with web applications by bridging the gap between traditional websites and native mobile apps. A PWA leverages modern web capabilities to deliver an app-like experience directly through the browser, offering offline functionality, push notifications, and the ability to be installed on a user’s home screen without the friction of app store downloads. For businesses and developers alike, building a PWA means increased engagement, improved performance, and broader reach across devices and network conditions. In this tutorial, you will learn how to build a fully functional PWA from scratch, covering every critical component from the web app manifest to service worker strategies, and ensuring your app meets the highest standards of reliability and speed.

Before diving into the code, it’s essential to understand the foundational requirements. You should be comfortable with HTML, CSS, and JavaScript, and have a basic understanding of how the browser works. You will need a code editor (such as Visual Studio Code), a local development server (like Live Server or http-server), and a modern browser (Chrome or Edge recommended for PWA debugging). All concepts will be explained in detail, so even if you are new to service workers or offline caching, you will be able to follow along. By the end of this guide, you will have a deployable PWA that loads instantly even on slow or unreliable networks and can be added to the user’s home screen.

Article illustration

The journey of building a PWA can be broken down into seven logical steps: setting up the project structure, creating the web app manifest, registering a service worker, implementing caching strategies, adding offline support and background sync, making the app installable, and finally testing and debugging. Each step builds upon the previous one, ensuring that your application becomes progressively more capable. We will also explore best practices for performance, security, and user experience, followed by a comprehensive FAQ section that addresses common questions developers have when venturing into PWA development. This tutorial is designed to be your one-stop reference for creating a production-ready Progressive Web App.

Step 1: Setting Up the Project Structure

The first step in any PWA project is establishing a clean and logical project structure. A well-organized directory makes it easier to manage assets, maintain your service worker, and scale your application later. Start by creating a root folder named pwa-tutorial (or any name of your choice). Inside this folder, create the following subdirectories and files: an index.html as the main page, a css folder for styles, a js folder for JavaScript files, an images folder for icons and images, and two critical top-level files: manifest.json and sw.js (the service worker). Your initial structure should look like this:

  • pwa-tutorial/
    • index.html
    • manifest.json
    • sw.js
    • css/style.css
    • js/app.js
    • images/icon-192x192.png
    • images/icon-512x512.png

Now, populate your index.html with a basic HTML5 boilerplate. Make sure to include a viewport meta tag for mobile responsiveness and a link to your manifest file. Your <head> should contain the following: <link rel="manifest" href="/manifest.json"> and a <meta name="theme-color" content="#317EFB">. In the <body>, add a simple heading and a paragraph that will later become the content of your app. Also, include a script tag pointing to app.js where you will handle service worker registration. This basic setup gives you a solid foundation; the rest of the steps will enhance it with PWA capabilities.

Step 2: Creating the Web App Manifest

The web app manifest is a JSON file that tells the browser how your PWA should behave when installed on a user’s device. It controls the app name, icons, start URL, display mode, and overall appearance. Without a valid manifest, browsers will not treat your application as a PWA, and the install prompt will not appear. Create your manifest.json file in the root directory with the following structure:

{
  "name": "My Progressive Web App",
  "short_name": "MyPWA",
  "description": "A short description of your PWA",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#317EFB",
  "icons": [
    {
      "src": "images/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "images/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}

The short_name is used when space is limited (e.g., under the app icon on the home screen), while the name appears during the install prompt. The display property can be set to fullscreen, standalone, minimal-ui, or browser. For a native app-like experience, standalone is recommended because it hides the browser’s address bar and navigation controls. The start_url determines which page loads when the app is launched. You can also add orientation and scope properties. Below is a reference table of essential manifest properties you should consider:

Property Description Required
name Full name of your app (used in install prompt) Yes
short_name Short version (displayed on home screen) Yes
start_url URL that loads when the app is opened Yes
icons Array of icon objects with src, sizes, type Yes
display Display mode: standalone, fullscreen, minimal-ui, browser Recommended
theme_color Color of the browser toolbar when the app is open Optional
background_color Background color displayed before styles load Optional
orientation Lock screen orientation (e.g., portrait, landscape) Optional
scope Navigation scope of the app context Optional

Ensure your icon files exist in the images folder with the exact dimensions specified. You can generate icons using tools like PWA Builder or create your own. The manifest must be linked correctly from the HTML <head>. After placing the link, you can verify the manifest by opening Chrome DevTools → Application → Manifest; any errors will appear there.

Step 3: Registering a Service Worker

A service worker is a JavaScript file that runs separately from the main browser thread and acts as a programmable network proxy. It is the core technology that enables offline support, push notifications, and background sync. However, registering a service worker is not automatic; you must write a small JavaScript snippet that checks for support and registers the file. In your app.js file, add the following code:

if ('serviceWorker' in navigator) {
  window.addEventListener('load', function() {
    navigator.serviceWorker.register('/sw.js')
      .then(function(registration) {
        console.log('Service Worker registered with scope:', registration.scope);
      })
      .catch(function(error) {
        console.log('Service Worker registration failed:', error);
      });
  });
}

It is crucial to register the service worker only after the page has fully loaded using the load event. This prevents the service worker installation from interfering with the initial page render. The scope parameter determines which pages the service worker controls. By default, a service worker placed at the root (/sw.js) controls all pages in that directory and its subdirectories. If you place it inside a subfolder, the scope is limited to that folder. For most PWAs, having the service worker in the root is ideal. Also note that service workers only work over HTTPS (or localhost for development). If you test on a live server without HTTPS, registration will fail silently. Always use a local server (e.g., http-server or the Live Server extension) when developing locally.

Once you save your files and reload the page, open Chrome DevTools → Application → Service Workers. You should see the registered service worker with a status of “activated” or “installing”. This confirms that your service worker is now active and ready to handle events. The next step will define what happens during the install and fetch events to provide offline capabilities.

Step 4: Implementing Caching Strategies

The service worker lifecycle includes three main events: install, activate, and fetch. During the install event, you typically pre-cache essential assets (HTML, CSS, JS, images) so that the app works offline from the very first visit. During the fetch event, you intercept network requests and decide whether to serve from cache, network, or a combination. Open your sw.js file and start with a cache version identifier and a list of files to pre-cache:

const CACHE_NAME = 'my-pwa-cache-v1';
const urlsToCache = [
  '/',
  '/index.html',
  '/css/style.css',
  '/js/app.js',
  '/images/icon-192x192.png',
  '/images/icon-512x512.png'
];

self.addEventListener('install', function(event) {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(function(cache) {
        console.log('Opened cache');
        return cache.addAll(urlsToCache);
      })
  );
});

The event.waitUntil method ensures the service worker is not considered installed until the caching operation completes. After installation, the service worker enters the waiting state until all pages controlled by the old service worker are closed. In the activate event, you should clean up old caches to avoid bloating the user’s storage:

self.addEventListener('activate', function(event) {
  const cacheWhitelist = [CACHE_NAME];
  event.waitUntil(
    caches.keys().then(function(cacheNames) {
      return Promise.all(
        cacheNames.map(function(cacheName) {
          if (cacheWhitelist.indexOf(cacheName) === -1) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

Now, the most critical part: handling fetch events. There are several caching strategies you can implement, depending on the nature of your resources. The table below outlines the most common strategies and when to use them:

Strategy Behavior Best Use Case
Cache First (Cache then Network) Serve from cache if available; otherwise fetch from network and cache the response. Static assets like CSS, JS, images
Network First (Network then Cache) Try network first; if fails, fall back to cache. API requests that need fresh data but can show stale data offline
Stale-While-Revalidate Serve from cache immediately, then update cache from network in the background. Content that changes often but can show slightly outdated version (e.g., news feed)
Network Only Always fetch from network, never cache. Non-critical data that must be real-time
Cache Only Serve exclusively from cache; no network request made. Pre-cached assets that never change

For simplicity, implement a Cache First strategy for your static assets. Add the following fetch event listener:

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.match(event.request)
      .then(function(response) {
        // Cache hit - return response
        if (response) {
          return response;
        }
        // Clone the request because it's a stream that can only be used once
        var fetchRequest = event.request.clone();
        return fetch(fetchRequest).then(
          function(response) {
            // Check if we received a valid response
            if (!response || response.status !== 200 || response.type !== 'basic') {
              return response;
            }
            var responseToCache = response.clone();
            caches.open(CACHE_NAME).then(function(cache) {
              cache.put(event.request, responseToCache);
            });
            return response;
          }
        );
      })
  );
});

This code first checks if the requested resource is in the cache. If yes, it returns the cached version instantly. If not, it fetches from the network, caches the response for future visits, and returns it. This pattern ensures that your app’s core assets are always available offline after the first visit.

Step 5: Adding Offline Support and Background Sync

While caching the main page and assets is essential, users may navigate to pages or trigger actions that are not yet cached. To provide a graceful offline experience, you should serve a custom offline page when the network is unavailable and the requested page is not in the cache. Create an offline.html file in your root directory with a friendly message. Then, modify your fetch event to include a fallback:

// Inside fetch event, after trying cache and network
.catch(function() {
  // If both cache and network fail, serve offline page
  return caches.match('/offline.html');
});

You must pre-cache the offline page in your install event as well by adding it to the urlsToCache array. Additionally, you can implement the Background Sync API to defer user actions (like form submissions or chat messages) when the device is offline and automatically resend them when connectivity is restored. Register a sync in your main script:

navigator.serviceWorker.ready.then(function(swRegistration) {
  return swRegistration.sync.register('my-sync-tag');
});

In the service worker, listen for the sync event:

self.addEventListener('sync', function(event) {
  if (event.tag === 'my-sync-tag') {
    event.waitUntil(/* function that sends queued data */);
  }
});

Background Sync is powerful for creating true offline-first applications. However, note that it requires a secure context (HTTPS) and user engagement. In practice, you would store pending data in IndexedDB within your web app and then, during the sync event, retrieve and send that data to your server.

Step 6: Making the App Installable

One of the hallmark features of a PWA is the ability for users to add it to their home screen, making it feel like a native app. Browsers automatically prompt users to install a PWA if certain criteria are met: the site is served over HTTPS, has a valid manifest with icons and a proper display mode, and a registered service worker with a fetch event that provides offline functionality. However, you can listen for the beforeinstallprompt event to intercept the prompt and customize when it appears (e.g., after a user clicks a custom “Install App” button).

In your app.js, add the following:

let deferredPrompt;

window.addEventListener('beforeinstallprompt', function(e) {
  // Prevent the mini-infobar from appearing on mobile
  e.preventDefault();
  // Stash the event so it can be triggered later
  deferredPrompt = e;
  // Show your custom install button (initially hidden)
  document.getElementById('installButton').style.display = 'block';
});

document.getElementById('installButton').addEventListener('click', function() {
  // Show the install prompt
  if (deferredPrompt) {
    deferredPrompt.prompt();
    // Wait for the user to respond to the prompt
    deferredPrompt.userChoice.then(function(choiceResult) {
      console.log(choiceResult.outcome);
      if (choiceResult.outcome === 'accepted') {
        console.log('User accepted the install prompt');
      } else {
        console.log('User dismissed the install prompt');
      }
      deferredPrompt = null;
    });
  }
});

You also need to handle the appinstalled event to know when the app was successfully installed and possibly send analytics. Add window.addEventListener('appinstalled', function() { ... }); to your code. Additionally, you can check if the app is already running in standalone mode by inspecting window.matchMedia('(display-mode: standalone)').matches. This allows you to tailor the user interface for installed vs. browser-only experiences.

Step 7: Testing and Debugging Your PWA

Once you have implemented all the core components, thorough testing is crucial to ensure your PWA behaves correctly under various network conditions and on different devices. The first tool you should use is Chrome DevTools: navigate to the Application panel and examine the Manifest, Service Workers, and Cache Storage sections. Verify that your manifest is error-free, the service worker is registered and active, and the cache contains the expected files. You can also simulate offline mode by checking the “Offline” checkbox under the Service Workers tab and reloading the page to see if your offline fallback works.

For automated auditing, use Google’s Lighthouse tool, which is built into Chrome DevTools (Audits tab). Run a “Progressive Web App” audit to get a score and a list of actionable improvements. Lighthouse checks for HTTPS, manifest presence, service worker registration, offline functionality (via a simulated offline load), and even performance metrics like time to interactive. Aim for a score of at least 90% across all categories. Another useful tool is the PWA Builder website, which can generate your manifest and service worker, and also validate your existing PWA.

Additionally, test on real mobile devices. Use a physical Android device with Chrome or a Windows/Mac machine with Edge. PWAs also work on iOS Safari (with limited features like no push notifications), so test on an iPhone as well. Use remote debugging tools (like Chrome DevTools Remote Debugging) to inspect service worker behavior on mobile. Common issues include incorrect scope, mismatched icon sizes, or missing MIME types. By systematically verifying each component, you can ensure your PWA meets the baseline requirements for installability and offline support.

Tips and Best Practices

Tip 1: Use Versioning for Your Cache

Always include a version number in your cache name (e.g., my-pwa-cache-v1). When you update the service worker file, you should change this version number and add new assets to the install event. The activate event handler we wrote earlier will automatically delete old caches, preventing users from seeing stale content. Without versioning, you risk serving outdated assets or accumulating orphaned caches that waste storage space. A good practice is to define the cache name as a constant at the top of sw.js and increment it with each deployment.

Tip 2: Keep the Service Worker File Small and Static

Service workers cannot be dynamically generated on the fly (they must be a static JavaScript file). Avoid putting too much logic inside the service worker; instead, keep it focused on caching and fetch handling. Use the service worker only for events where you need offline functionality. Complex business logic should reside in the main thread. Also, ensure the service worker file does not use importScripts() with external URLs that change frequently, as that will break the caching. Always test service worker updates using the Update on reload option in DevTools to simulate a new version.

Tip 3: Optimize Performance with Critical Path Caching

To achieve near-instant loading, pre-cache only the critical resources (HTML, CSS, essential JS, and key images). Use the Cache First strategy for these assets. For non-critical resources, implement Stale-While-Revalidate or Network-First depending on freshness requirements. Additionally, leverage preload and preconnect hints in your HTML to speed up resource discovery. Combine your PWA with modern performance techniques like code splitting, lazy loading of images, and responsive images. A performant PWA not only improves user retention but also positively impacts your Lighthouse score and search engine ranking.

Frequently Asked Questions

What exactly is a Progressive Web App (PWA)?

A Progressive Web App is a type of application software delivered through the web, built using common web technologies including HTML, CSS, and JavaScript. It is intended to work on any platform that uses a standards-compliant browser, including desktop and mobile devices. PWAs are characterized by their ability to be installed on the user’s device, work offline or on low-quality networks, send push notifications, and access device hardware. They are “progressive” because they enhance the user experience based on browser support — a user on a modern browser gets all the advanced features, while someone on an older browser still sees the basic site.

Do I need HTTPS to use service workers?

Yes, service workers require a secure context (HTTPS) because they have the power to intercept network requests and potentially expose users to man-in-the-middle attacks if served over HTTP. However, during local development, browsers allow service workers on localhost without HTTPS for convenience. When you deploy your PWA to a production server, you must ensure it is served over HTTPS. Many hosting providers (like Netlify, Vercel, GitHub Pages) offer free HTTPS with Let’s Encrypt.

Can PWAs access device features like the camera or GPS?

Absolutely. PWAs can use many native device APIs through the web platform, including Geolocation, Camera (via getUserMedia), Accelerometer, and Vibration API. With the advent of the File System Access API and Web Bluetooth, PWAs are becoming increasingly capable. However, access to these features requires user permission (just like native apps). Note that iOS Safari still lags behind in supporting some advanced APIs, so always check the caniuse.com database for compatibility.

How do I update the service worker when I deploy new code?

When you update your site files, you must also update your service worker file. Browsers check for updates to the service worker file every time the user navigates to a page controlled by it. If the new service worker has a single byte difference, it will be installed in the background, but it will not activate until all pages using the old service worker are closed. You can force the new service worker to take control immediately using self.clients.claim() in the activate event. Also, notify users there is an update and prompt them to refresh the page. A common pattern is to listen for the updatefound event and display a banner saying “New version available”.

Can I build a PWA without a framework?

Yes, you can build a PWA using plain HTML, CSS, and JavaScript. Frameworks like React, Angular, or Vue can simplify the process, especially for single-page applications, but they are not required. The core PWA technologies (manifest, service worker, cache API) are browser-native and work with any web stack. If you use a framework, many of them have community plugins to generate the service worker (e.g., Workbox for webpack or Create React App’s built-in service worker). However, understanding the raw concepts as we’ve covered here gives you full control and deeper insight into how your PWA operates.

Conclusion

Building a Progressive Web App is no longer a niche skill — it is a fundamental capability that every web developer should master. In this tutorial, we have walked through the complete process from project setup to testing, covering the manifest, service worker registration, caching strategies, offline support, background sync, and installability. By following these steps, you have created a PWA that loads instantly, works offline, and can be added to the user’s home screen with a single tap. Remember that a PWA is never truly “finished” because you must continuously update the service worker, refine caching policies, and audit performance with tools like Lighthouse. As browser APIs evolve, PWAs will only get more powerful, enabling features like Web Push, Periodic Background Sync, and even file system access. Start building your PWA today and deliver the fast, reliable, and engaging experience that modern users expect.

sarah antaboga
Author: sarah antaboga

Leave a Reply

Your email address will not be published. Required fields are marked *