Adding Modern Capabilities to Your PWA

Progressive web apps have continued to gain traction, with major players like Twitter, Uber, Tinder, Pinterest, and Forbes reporting strong results from their implementations. The appeal is clear: PWAs can lift conversion rates and user engagement while cutting development and operational costs. And contrary to what you might assume, building a quality PWA isn't reserved for companies with deep pockets — small and mid-sized teams can create them too.

You may already be familiar with the fundamentals of PWA construction. This guide goes further, covering how to bring contemporary features into your PWA: true offline operation, network-aware optimization, consistent cross-device behavior, solid SEO, and notification flows that don't drive users away.

What Makes a PWA Tick?

Google describes PWAs as applications built with modern web APIs to deliver enhanced capabilities, reliability, and installability, all while running on a single codebase that works anywhere, on any device. In practical terms, a PWA is a website that behaves like a standalone app — no app store installation required, and no restriction to mobile hardware, which is a common limitation of native apps.

Three building blocks form the foundation of every PWA:

The Web App Manifest

This configuration file is what allows your website to run as its own full-screen application. Through the manifest, you control the app's visual presentation, tailor it for various screen sizes, and set the icon users see once they install it.

Service Workers

These scripts sit between your app and the network, enabling offline access by serving cached content. When the network disappears, the service worker keeps the app usable and can inform the user of the connection loss. Once connectivity returns, it fetches fresh data to sync everything back up.

Application Shell Architecture

This is the user-facing skeleton of the app: the minimum HTML, CSS, and JavaScript required to render the interface. By caching these shell resources in the browser, you ensure that the app loads fast and works reliably even under spotty network conditions.

For complete instructions on setting up these core pieces, the Beginner's Guide to Progressive Web Apps remains a solid reference.

Modern Traits Worth Adding To A PWA

Google’s own PWA checklist goes beyond core requirements and calls out a set of modern traits that push the user experience further. These boil down to a handful of practical upgrades you can layer onto your existing app.

Working Offline In A Useful Way

Dropping users onto a static “you’re offline” page when connectivity disappears is technically compliant but rarely pleasant. A far better approach is to keep the app itself functional for as long as the absence of a network doesn’t make that impossible. The trick is to combine cached data with a mechanism for deferring user actions until the connection returns — and to show something meaningful in the UI while all that happens.

Cached content in IndexedDB. The in-browser NoSQL store is the natural home for the data your app needs offline. Because support is not universal, check for it first and bail out gracefully if it’s absent:

if (!('indexedDB' in window)) {
  console.log('This browser doesn\'t support IndexedDB');
  return;
}

Once you’ve confirmed browser support, the standard flow is to open a database and an object store, then put items into it:

var db;

var openRequest = indexedDB.open('test_db', 1);

openRequest.onupgradeneeded = function(e) {
  var db = e.target.result;
  console.log('running onupgradeneeded');
  if (!db.objectStoreNames.contains('store')) {
    var storeOS = db.createObjectStore('store',
      {keyPath: 'name'});
  }
};
openRequest.onsuccess = function(e) {
  console.log('running onsuccess');
  db = e.target.result;
  addItem();
};
openRequest.onerror = function(e) {
  console.log('onerror!');
  console.dir(e);
};

function addItem() {
  var transaction = db.transaction(['store'], 'readwrite');
  var store = transaction.objectStore('store');
  var item = {
    name: 'banana',
    price: '$2.99',
    description: 'It is a purple banana!',
    created: new Date().getTime()
  };

 var request = store.add(item);

 request.onerror = function(e) {
    console.log('Error', e.target.error.name);
  };
  request.onsuccess = function(e) {
    console.log('Woot! Did it');
  };
}

Background sync. A messaging app where sent messages just sit there until you reopen the app is a poor fit for offline use. With the Background Sync API, the user’s actions are queued in the service worker and released automatically once the network is back. Register the sync in your page code, for example when the user sends a message:

// Register your service worker:
navigator.serviceWorker.register('/sw.js');

// Then later, request a one-off sync:
navigator.serviceWorker.ready.then(function(swRegistration) {
  return swRegistration.sync.register('myFirstSync');
});

Then handle the actual synchronization logic inside your /sw.js by listening for the sync event:

self.addEventListener('sync', function(event) {
  if (event.tag == 'myFirstSync') {
    event.waitUntil(doSomeStuff());
  }
});

Skeleton screens. Waiting for a blank screen to fill in makes users perceive the app as stalled. A skeleton screen renders the structural layout of the UI immediately — the familiar rectangles and placeholders for future text or images — and content swaps in when it arrives. It avoids the impression of dead time even during a slow load.

Skeleton screen examples on Code My UI
Skeleton screen examples on Code My UI. (Large preview)

Speeding Up On Slow Networks

A PWA’s performance advantage over native is the default; you can make it more pronounced on weak connections by choosing smarter cache strategies, loading the most important assets first, and tailoring what you fetch to the actual network the user is on.

Cache-first delivery. Serving content straight from the cache before ever reaching for the network keeps your PWA snappy and functional even where coverage is poor. A service worker that intercepts requests and returns the cached response — falling back to the network only on a miss — is the pattern to implement:

self.addEventListener('fetch', event => {
  if (event.request.mode === 'navigate') {
    // See /web/fundamentals/getting-started/primers/async-functions
    // for an async/await primer.
    event.respondWith(async function() {
      // Optional: Normalize the incoming URL by removing query parameters.
      // Instead of https://example.com/page?key=value,
      // use https://example.com/page when reading and writing to the cache.
      // For static HTML documents, it's unlikely your query parameters will
      // affect the HTML returned. But if you do use query parameters that
      // uniquely determine your HTML, modify this code to retain them.
      const normalizedUrl = new URL(event.request.url);
      normalizedUrl.search = '';

      // Create promises for both the network response,
      // and a copy of the response that can be used in the cache.
      const fetchResponseP = fetch(normalizedUrl);
      const fetchResponseCloneP = fetchResponseP.then(r => r.clone());

      // event.waitUntil() ensures that the service worker is kept alive
      // long enough to complete the cache update.
      event.waitUntil(async function() {
        const cache = await caches.open('my-cache-name');
        await cache.put(normalizedUrl, await fetchResponseCloneP);
      }());

      // Prefer the cached response, falling back to the fetch response.
      return (await caches.match(normalizedUrl)) || fetchResponseP;
    }());
  }
});

Resource prioritization. Static assets are the easy win here because you can tell the browser to fetch them before they are strictly needed. In your HTML, use <link> to request resources whose engine you’re loading, for example. A typical target is web fonts, which otherwise block text rendering while they download:

<link rel=”preload” as=”font” href=”font.woff” crossorigin>

Adaptive loading. When your audience includes users hanging on to 2G or 3G connections, loading your full experience everywhere hurts everyone. Adaptive loading inspects the connection type and adjusts what gets fetched. You can detect the network context like this:

Adaptive loading illustration by Google.
Adaptive loading illustration by Google. (Large preview)

To control the caching strategy fully, use Workbox and write a custom plugin. It can decide, per request, whether your strategy should behave like cache-first or network-first:

const adaptiveLoadingPlugin = {
  requestWillFetch: async ({request}) => {
    const urlParts = request.url.split('/');
    let imageQuality;

    switch (
      navigator && navigator.connection
        ? navigator.connection.effectiveType
        : ''
    ) {
      //...
      case '3g':
        imageQuality = 'q_30';
        break;
      //...
    }

    const newUrl = urlParts
      .splice(urlParts.length - 1, 0, imageQuality)
      .join('/')
      .replace('.jpg', '.png');
    const newRequest = new Request(newUrl.href, {headers: request.headers});

    return newRequest;
  },
};

Once defined, pass that plugin to a cacheFirst strategy whose matcher only targets image URLs, for instance those containing /img/:

workbox.routing.registerRoute(
  new RegExp('/img/'),
  workbox.strategies.cacheFirst({
    cacheName: 'images',
    plugins: [
      adaptiveLoadingPlugin,
      workbox.expiration.Plugin({
        maxEntries: 50,
        purgeOnQuotaError: true,
      }),
    ],
  }),
);

Consistent UX Across Devices And Input Types

Android accounts for the biggest share of web traffic at 38.9%, but your PWA is not excused from working well on the rest. Two often-overlooked details matter most: preventing layout jumps during loading and accepting whatever input device the user happens to have — touch, mouse, or stylus.

No more content jumping. Lazy-loaded images are the usual culprits for layout shift because they arrive after text and push it around mid-read, a problem that gets worse on slow connections. Use a lightweight placeholder that occupies the same space while the heavy asset loads, done with a data-src attribute on a small preview image:

<img src='data/img/placeholder.png' data-src='data/img/SLUG.jpg' alt='NAME'>

Your app.js then swaps the placeholder for whatever data-src holds:

let imagesToLoad = document.querySelectorAll('img[data-src]');
const loadImages = (image) => {
  image.setAttribute('src', image.getAttribute('data-src'));
  image.onload = () => {
    image.removeAttribute('data-src');
  };
};

Once the swap is defined, you loop over all such images to apply it:

imagesToLoad.forEach((img) => {
  loadImages(img);
});

For every input method. The Pointer Events API unifies mouse, touch and pen interactions, saving you from duplicating event handling. As always, test browser support first:

if (window.PointerEvent) {
  // Yay, we can use pointer events!
} else {
  // Back to mouse and touch events, I guess.
}

With support confirmed, you can define a shared set of behaviors that any pointing device can trigger:

switch(ev.pointerType) {
  case 'mouse':
    // Do nothing.
    break;
  case 'touch':
    // Allow drag gesture.
    break;
  case 'pen':
    // Also allow drag gesture.
    break;
  default:
    // Getting an empty string means the browser doesn't know
    // what device type it is. Let's assume mouse and do nothing.
    break;
}

Rating Search Visibility

Because a PWA is a regular website underneath, its URLs can be indexed — something no native app can offer. Unique, descriptive titles and meta descriptions per URL are the starting point, alongside tools that expose how well you’re doing.

Run a findability audit. Google Search Console analyzes your URLs and reports on issues. Inside Chrome, Lighthouse gives the same kind of analysis: open developer tools with Control+Shift+J (or Command+Option+J on a Mac), choose the Lighthouse tab, tick “SEO”, and generate a report.

Google Chrome browser Lighthouse developer tools category SEO screenshot.
Google Chrome browser Lighthouse developer tools category SEO screenshot. (Large preview)

Structured data for context. Schema markup tells search engines what a page’s content means, rather than just what it says. Google describes it as “a standardized format for providing information about a page and classifying the page content; for example, on a recipe page, what are the ingredients, the cooking time and temperature, the calories, and so on.”

“Structured data is a standardized format for providing information about a page and classifying the page content; for example, on a recipe page, what are the ingredients, the cooking time and temperature, the calories, and so on.” — Google

Before writing your own markup, check Google’s documentation on common structured data errors so you don’t repeat them.

Notifications Users Thank You For

Permission popups and push messages are easy to get wrong. The fix isn’t just about writing better code — it’s about giving context before asking, and always providing the option to turn things off.

The double request pattern. A bare OS permission dialog offers no clue why you are asking. Placing a custom, in-page prompt ahead of the system dialog explains the value up front and dramatically improves opt-in rates. The default ask, without context, looks like this:

 Bad UX permission request example
Bad UX permission request example by Permission UX. (Large preview)

Adding custom context before that system dialog changes the experience entirely:

Good UX permission request example
Good UX permission request example by Permission UX. (Large preview)

Give users the unsubscribe button. Friction to disable notifications is a short path to abusing them. Wire a button in the UI so a user can revoke permission whenever they want. Begin by defining a click listener on the pushButton:

pushButton.addEventListener('click', function() {
  pushButton.disabled = true;
  if (isSubscribed) {
    unsubscribeUser();
  } else {
    subscribeUser();
  }
});

Then implement the function that does the actual work:

function unsubscribeUser() {
  swRegistration.pushManager.getSubscription()
  .then(function(subscription) {
    if (subscription) {
    // TODO: Tell application server to delete subscription
      return subscription.unsubscribe();
    }
  })
  .catch(function(error) {
    console.log('Error unsubscribing', error);
  })
  .then(function() {
    updateSubscriptionOnServer(null);

    console.log('User is unsubscribed.');
    isSubscribed = false;

    updateBtn();
  });
}

Success in the console, after the button is wired, confirms the toggle works and the user is in control:

Console.log example of a successful enable/disable notifications function
Console.log example of a successful enable/disable notifications function by Matt Gaunt. (Large preview)

Choosing What to Adopt

Optimizing a PWA doesn’t have to be an all-or-nothing effort. Each technique covered here can stand alone, so it’s worth starting with the options that address your current performance gaps or user-experience bottlenecks. You can layer in the rest as your needs evolve.

Learning Resources

If you’re looking to deepen your understanding, the official documentation from the major browser vendors remains a solid starting point. Google’s training materials cover the foundations, while web.dev provides modern, practical guidance. For a standards-focused perspective, Mozilla’s PWA documentation is a reliable reference.

  • Progressive Web Apps Training by Google
  • Progressive Web Apps by web.dev
  • Progressive web apps (PWAs) by Mozilla
  • Creating A Magento PWA: Customizing Themes vs. Coding From Scratch
  • How To Monitor And Optimize Google Core Web Vitals
  • How A Bottom-Up Design Approach Enhances Site Accessibility
  • Iconography In Design Systems: Easy Troubleshooting And Maintenance
Smashing Editorial