Skip to content

Illustration by unDraw

Placeholder image fallbacks for PWAs

Rendering an image placeholder in case the original image was not found in Progressive Web Apps.

Building a PWA can sometimes get a bit complex if you also want to add offline capabilities and whereas it takes time to build a good offline experience, you may spot more ways that you can enhance this offline experience for your users. One of these ways is to make sure that every image has a placeholder image as a fallback in case of a bug, cache invalidation, or bad network conditions.

Essentially this is what we would like to avoid:

Website showing broken layout while images have not been loaded
Broken layout after images failed to load

Cases where images will not be available:

  • Cached images have been expired
  • User is offline but no images are cached
  • User navigated to an offline page for the first time and images were not either pre-cached or cached during runtime
  • Original image was not found because of a renaming mistake
  • Image was accidentally deleted from the CMS and is still being used on the website

In order to fix this bad user experience, we will need to ‘protect’ our PWA against images not being available from either the Network or the Cache!

In this article, I will be showing you an easy way to include these placeholder images anywhere in your web app without even touching your original app code!

MakeMyTrip.com example placeholder images screenshot
MakeMyTrip.com example placeholder images screenshot

Enter Service Worker#

For this functionality to be simple and easy enough to implement without touching the app code, we will be using the Service Worker.

The reason why the Service Worker is useful here is because of its nature: The Service Worker acts as a proxy between the client (website) and both the Network and the Cache.

Diagram showing the service worker in the middle as a proxy between the Application and the Cache and Network
The Service Worker acts as a proxy between the client (website) and both the Network and the Cache.

Due to the HTTP request intercepting capabilities of the Service Worker, we will be adding the following logic to our application:

  1. As soon as the user lands on the page, register a Service Worker
  2. Pre-cache a placeholder image so that it is saved in user’s device
  3. Listen for client image requests (e.g. img's src attribute will trigger one)
  4. Inside of the Service Worker, check if the image exists in the Network
  5. If it doesn’t, check if it exists in the Cache
  6. If it doesn’t exist there either, respond with the pre-cached placeholder image

Alright, now how?#

Step 1: Register a Service Worker#

You can register the Service Worker either inline in your index.html file or in your app.js file. This will be the only change you will have to do in your app code if you don’t already register a Service Worker.

app.js
const SERVICE_WORKER_SCOPE = '/';
window.addEventListener('load', async () => {
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register(
      './service-worker.js',
      { scope: SERVICE_WORKER_SCOPE }
    );
  }
});

Step 2: Pre-cache placeholder image#

For pre-caching, it would be rather safer to use the Workbox tools to generate a precache manifest and make sure that your placeholder image is included in the manifest file. In either way, double-check that the URL is matching the correct image.

service-worker.js
importScripts('https://storage.googleapis.com/workbox-cdn/releases/6.1.5/workbox-sw.js');
const PLACEHOLDER_IMAGE_URL = '/img/placeholder-image.png';
workbox.precaching.precacheAndRoute(
  (self.__WB_MANIFEST || []).concat([ PLACEHOLDER_IMAGE_URL ])
);

Step 3: Listen for any image fetch event#

Select the images that you would like to make sure they should have a placeholder fallback image, depending on your requirements.

The <HANDLER> is normally where a Workbox Strategy would go but instead this time we will be using something a bit more custom.

service-worker.js
workbox.routing.registerRoute(
  /\.(?:webp|png|jpg|jpeg|svg)$/,
  /* <HANDLER> */
)

Step 4: Check for image in Network and Cache#

We will still be using the stale-while-revalidate strategy but only when the image was found in either the Cache or the Network. If it wasn’t found, we throw an Error which we catch and handle, as shown in the step after this.

service-worker.js
/* <HANDLER> */
async ({url, event, params}) => {
  const staleWhileRevalidate = new workbox.strategies.StaleWhileRevalidate();
try {
  // check for the image in Network and Cache
  const response = await caches.match(event.request) || 
                   await fetch(url, { method: 'GET' });
  if (!response || response.status === 404) {
    // image was not found, throw an error
    throw new Error(response.status);
  } else {
    // image was found, handle request using Workbox Strategy
    return await staleWhileRevalidate.handle({event});
  }
} catch (error) {
  // next step...
}

Step 5: Respond with placeholder image#

After catching the fetching error and we know that the requested image was not found either in Network or Cache, we can then go forward and respond with a placeholder image instead.

service-worker.js
// ...
} catch (error) {
  console.warn(`ServiceWorker: Image [${url.href}] was not found either in the network or the cache. Responding with placeholder image instead.`);
  // get placeholder image from Cache
  // else try form the Network as the last resort
  return await caches.match(placeholderImageURL) || 
         await fetch(placeholderImageURL, { method: 'GET' });
}

Putting it all together#

Putting everything from above together, we will end up with the following code inside your service-worker.js file, with no changes whatsoever in your app code:

service-worker.js
importScripts('https://storage.googleapis.com/workbox-cdn/releases/6.1.5/workbox-sw.js');
const placeholderImageURL = '/img/placeholder-image.png'; // precache this in __WB_MANIFEST file
workbox.precaching.precacheAndRoute(self.__WB_MANIFEST || []);

workbox.routing.registerRoute(
  /\.(?:webp|png|jpg|jpeg|svg)$/,
  async ({url, event, params}) => {
    const staleWhileRevalidate = new workbox.strategies.StaleWhileRevalidate();

    try {
      const response = await caches.match(event.request) || await fetch(url, { method: 'GET' });
      if (!response || response.status === 404) {
        throw new Error(response.status);
      } else {
        return await staleWhileRevalidate.handle({event});
      }

    } catch (error) {
      console.warn(`\nServiceWorker: Image [${url.href}] was not found either in the network or the cache. Responding with placeholder image instead.\n`);
      // * get placeholder image from cache || get placeholder image from network
      return await caches.match(placeholderImageURL) || await fetch(placeholderImageURL, { method: 'GET' });

    }
  }
);

I hope that this was a relatively easy and fast implementation, where your user wouldn’t have to look at misaligned boxes of missing images but instead provide a more native app-like feel throughout your PWA.

📚 Further reading#