Building resilient search experiences with Workbox

This codelab shows you how to implement a resilient search experience with Workbox. The demo app it uses contains a search box that calls a server endpoint, and redirects the user to a basic HTML page.

Measure

Before adding optimizations, it's always a good idea to first analyze the current state of the application.

  • Click Remix to Edit to make the project editable.
  • To preview the site, press View App. Then press Fullscreen fullscreen.

In the new tab that just opened, check how the website behaves when going offline:

  1. Press `Control+Shift+J` (or `Command+Option+J` on Mac) to open DevTools.
  2. Click the Network tab.
  3. Open Chrome DevTools and select the Network panel.
  4. In the Throttling drop-down list, select Offline.
  5. In the demo app enter a search query, then click the Search button.

The standard browser error page is shown:

A screenshot of the default offline UX in the browser.

Provide a fallback response

The service worker contains the code to add the offline page to the precache list, so it can always be cached at the service worker install event.

Usually you would need to instruct Workbox to add this file to the precache list at build time, by integrating the library with your build tool of choice (e.g. webpack or gulp).

For simplicity, we've already done it for you. The following code at public/sw.js does that:

const FALLBACK_HTML_URL = '/index_offline.html';
…
workbox.precaching.precacheAndRoute([FALLBACK_HTML_URL]);

Next, add code to use the offline page as a fallback response:

  1. To view the source, press View Source.
  2. Add the following code to the bottom of public/sw.js:
workbox.routing.setDefaultHandler(new workbox.strategies.NetworkOnly());

workbox.routing.setCatchHandler(({event}) => {
  switch (event.request.destination) {
    case 'document':
      return caches.match(FALLBACK_HTML_URL);
      break;
    default:
      return Response.error();
  }
});

The code does the following:

  • Defines a default Network Only strategy that will apply to all requests.
  • Declares a global error handler, by calling workbox.routing.setCatchHandler() to manage failed requests. When requests are for documents, a fallback offline HTML page will be returned.

To test this functionality:

  1. Go back to the other tab that is running your app.
  2. Set the Throttling drop-down list back to Online.
  3. Press Chrome's Back button to navigate back to the search page.
  4. Make sure that the Disable cache checkbox in DevTools is disabled.
  5. Long-press Chrome's Reload button and select Empty cache and hard reload to ensure that your service worker is updated.
  6. Set the Throttling drop-down list back to Offline again.
  7. Enter a search query, and click the Search button again.

The fallback HTML page is shown:

A screenshot of the custom offline UX in the browser.

Request notification permission

For simplicity, the offline page at views/index_offline.html already contains the code to request notification permissions in a script block at the bottom:

function requestNotificationPermission(event) {
  event.preventDefault();

  Notification.requestPermission().then(function (result) {
    showOfflineText(result);
  });
}

The code does the following:

  • When the user clicks subscribe to notifications the requestNotificationPermission() function is called, which calls Notification.requestPermission(), to show the default browser permission prompt. The promise resolves with the permission picked by the user, which can be either granted, denied, or default.
  • Passes the resolved permission to showOfflineText() to show the appropriate text to the user.

Persist offline queries and retry when back online

Next, implement Workbox Background Sync to persist offline queries, so they can be retried when the browser detects that connectivity has returned.

  1. Open public/sw.js for edit.
  2. Add the following code at the end of the file:
const bgSyncPlugin = new workbox.backgroundSync.Plugin('offlineQueryQueue', {
  maxRetentionTime: 60,
  onSync: async ({queue}) => {
    let entry;
    while ((entry = await queue.shiftRequest())) {
      try {
        const response = await fetch(entry.request);
        const cache = await caches.open('offline-search-responses');
        const offlineUrl = `${entry.request.url}&notification=true`;
        cache.put(offlineUrl, response);
        showNotification(offlineUrl);
      } catch (error) {
        await this.unshiftRequest(entry);
        throw error;
      }
    }
  },
});

The code does the following:

  • workbox.backgroundSync.Plugin contains the logic to add failed requests to a queue so they can be retried later. These requests will be persisted in IndexedDB.
  • maxRetentionTime indicates the amount of time a request may be retried. In this case we have chosen 60 minutes (after which it will be discarded).
  • onSync is the most important part of this code. This callback will be called when connection is back so that queued requests are retrieved and then fetched from the network.
  • The network response is added to the offline-search-responses cache, appending the &notification=true query param, so that this cache entry can be picked up when a user clicks on the notification.

To integrate background sync with your service, define a NetworkOnly strategy for requests to the search URL (/search_action) and pass the previously defined bgSyncPlugin. Add the following code to the bottom of public/sw.js:

const matchSearchUrl = ({url}) => {
  const notificationParam = url.searchParams.get('notification');
  return url.pathname === '/search_action' && !(notificationParam === 'true');
};

workbox.routing.registerRoute(
  matchSearchUrl,
  new workbox.strategies.NetworkOnly({
    plugins: [bgSyncPlugin],
  }),
);

This tells Workbox to always go to the network, and, when requests fail, use the background sync logic.

Next, add the following code to the bottom of public/sw.js to define a caching strategy for requests coming from notifications. Use a CacheFirst strategy, so they can be served from the cache.

const matchNotificationUrl = ({url}) => {
  const notificationParam = url.searchParams.get('notification');
  return (url.pathname === '/search_action' && (notificationParam === 'true'));
};

workbox.routing.registerRoute(matchNotificationUrl,
  new workbox.strategies.CacheFirst({
     cacheName: 'offline-search-responses',
  })
);

Finally, add the code to show notifications:

function showNotification(notificationUrl) {
  if (Notification.permission) {
     self.registration.showNotification('Your search is ready!', {
        body: 'Click to see you search result',
        icon: '/img/workbox.jpg',
        data: {
           url: notificationUrl
        }
     });
  }
}

self.addEventListener('notificationclick', function(event) {
  event.notification.close();
  event.waitUntil(
     clients.openWindow(event.notification.data.url)
  );
});

Test the feature

  1. Go back to the other tab that is running your app.
  2. Set the Throttling drop-down list back to Online.
  3. Press Chrome's Back button to navigate back to the search page.
  4. Long-press Chrome's Reload button and select Empty cache and hard reload to ensure that your service worker is updated.
  5. Set the Throttling drop-down list back to Offline again.
  6. Enter a search query, and click the Search button again.
  7. Click subscribe to notifications.
  8. When Chrome asks you if you want to grant the app permission to send notifications, click Allow.
  9. Enter another search query and click the Search button again.
  10. Set the Throttling drop-down list back to Online again.

Once the connection is back a notification will be shown:

A screenshot of the full offline flow.

Conclusion

Workbox provides many built-in features to make your PWAs more resilient and engaging. In this codelab you have explored how to implement the Background Sync API by way of the Workbox abstraction, to ensure that offline user queries are not lost, and can be retried once connection is back. The demo is a simple search app, but you can use a similar implementation for more complex scenarios and use cases, including chat apps, posting messages on a social network, etc.