Michal Zalecki
Michal Zalecki
software development, testing, JavaScript,
TypeScript, Node.js, React, and other stuff

Progressive Web Apps with Webpack

My first offline web app heavily depended on AppCache and it was painful experience. Don’t get me wrong, AppCache initially blow my mind, in a positive sense. Web App without a web? In the browser? Sounds awesome, isn’t it? My further experiments with AppCache convinced me that unfortunately it’s not the path I’d like to follow when it comes to building something serious.

AppCache makes many assumptions which is prone to many real-world loopholes and manifest file is nothing more than configuration file referenced in HTML. I didn’t know what can be the right way to deal with offline web apps at that time. Progressive Web App Dev Summit was quite a breakthrough for me. Service Workers gives developer a great power of delivering offline experience (programmatically) without the need of installing native app. How cool is that!

I don’t want to give you yet another introduction to Service Workers. There are many good tutorials, articles, recipes and even entire cookbook. I’d like to focus not on selling you the idea but creating not-trivial example of something which can be used in production today.

First things first, let’s define the basic requirements:

  • Long-term caching - Webpack gives us long-term caching for free
  • Lazy-loading and code splitting - progressive web apps are not only about offline but also reducing data usage
  • Talk to the API and cache only the most relevant part of this communication
  • Multiple caching strategies - depend on the request properties or user actions
  • Possibility to clear the user’s browser cache
  • Service Worker as an enhancement not as a requirement (shame on you IE and Safari)

Register and activate Service Worker

With requirements in mind we can start with registering Service Worker.

import swURL from "file?name=sw.js!babel!./sw";

if ("serviceWorker" in navigator) {
  // Service worker registered
  navigator.serviceWorker.register(swURL).catch(err => {
    // Service worker registration failed
  });
} else {
  // Service worker is not supported
}

Using file-loader to obtain swURL is a hack which allows for using Babel loader (and any other loader) for sw.js without adding sw.js as webpack’s entry point. I don’t want to set sw.js as an entry point as I’m adding hash for long-term caching to each entry point. If you worry about that sw.js can be cached you shouldn’t.

When the user navigates to your site, the browser tries to redownload the script file that defined the service worker in the background. If there is even a byte’s difference in the service worker file compared to what it currently has, it considers it new.

Google Developers

Awesome, we have Service Worker registered. I would like to cache couple required resources when Service Worker is installed. The next stumbling block is that I actually don’t know the name of the main entry file as its name contains a hash. The workaround I’ve came up with is using webpack-manifest-plugin to create a JSON file with assets path which can be fetched inside install event handler.

self.addEventListener("install", event => {
  console.log("Installed");

  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache =>
        fetch("assets-manifest.json")
          .then(response => response.json())
          .then(assets =>
            cache.addAll([
              "/",
              assets["main.js"],
              "http://api.example.com/articles",
            ])
          )
      ).then(() => self.skipWaiting())
  );
});

After caching all required resources I’m forcing waiting Service Worker to become active. The technique is called Immediate Claim.

In activate event handler I would like to remove old cache by deleting all not-whitelisted caches. After deletion worker is set as active for all clients.

self.addEventListener("activate", event => {
  const cacheWhitelist = [CACHE_NAME];
  event.waitUntil(
    caches.keys()
      .then(keyList =>
        Promise.all(keyList.map(key => {
          if (!cacheWhitelist.includes(key)) {
            return caches.delete(key);
          }
        }))
      )
      .then(() => self.clients.claim())
  );
});

Different strategies

Different problems require different solutions and it’s not any different when it comes to fetching resources. Before moving to fetch handler let’s focus on possible strategies.

Cacheable request failing to cache

Cacheable request failing to cache strategy is reasonable default for network-first apps. If connection is available we try to fetch the resource and then cache it. Keep in mind that there is no reason to pollute cache with failure responses.

function throwOnError(response) {
  if (response.status >= 200 && response.status < 300 || response.status === 0) {
    return response;
  }
  throw new Error(response.statusText);
}
function cacheableRequestFailingToCacheStrategy({ event, cache }) {
  return fetch(event.request)
    .then(throwOnError) // do not cache errors
    .then(response => {
      cache.put(event.request, response.clone());
      return response;
    })
    .catch(() => cache.match(event.request));
}

This strategy fails miserably in case of poor connection speed as request won't fail making user wait for resources to load.

Cache failing to cacheable request

Cache-first approach is a great default for modern, offline-first webapps. ServiceWorker fetches from cache and when request doesn't match anything stored in cache we perform a request which in case of success will be cached.

function cacheFailingToCacheableRequestStrategy({ event, cache }) {
  return cache.match(event.request)
    .then(throwOnError)
    .catch(() => fetch(event.request)
      .then(throwOnError)
      .then(response => {
        cache.put(event.request, response.clone());
        return response;
      })
    );
}

Request failing to cache

It’s possible you may not want to cache some set of responses by default, like articles. There is rather small chance that user is going to read the same article twice. On the other hand you can give your user an option to mark article as “read later”. In such case there is high probability that user will go back and it’s worth to cache that article.

function requestFailingToCacheStrategy({ event, cache }) {
  return fetch(event.request)
    .catch(() => cache.match(event.request));
}

I’m going to show how to cache only certain resources on user demand later in this article.

Request failing with 404 Not Found

Side effect requests (POST, PUT, DELETE) cannot be cached. In case of user being offline we can "respond" with 404 Not Found error with generic message which we can display to the user.

function requestFailingWithNotFoundStrategy({ event }) {
  return fetch(event.request)
    .catch(() => {
      const body = JSON.stringify({ error: "Sorry, you are offline. Please, try later." });
      const headers = { "Content-Type": "application/json" };
      const response = new Response(body, { status: 404, statusText: "Not Found", headers });
      return response;
    });
}

You can find more strategies in The offline cookbook.

Fetch event handler

Now we can combine defined strategies to deal with the set of different requests. It’s a good practice to define conditions as functions to make the code easier to read.

function isRequestForStatic(request) {
  return /.(png|jpg|jpeg|gif|css|js)$/.test(request.url);
}

function isSideEffectRequest(request) {
  return ["POST", "PUT", "DELETE"].includes(request.method);
}

function isRequestForAnArticle(request) {
  return request.url.match(/\/articles\/[1-9a-z-]+/);
}

self.addEventListener("fetch", event => {
  if (isSideEffectRequest(event.request)) {
    event.respondWith(requestFailingWithNotFoundStrategy({ event }));
    return;
  }

  if (isRequestForStatic(event.request)) {
    event.respondWith(
      caches.open(CACHE_NAME)
        .then(cache => cacheFailingToCacheableRequestStrategy({ event, cache }))
    );
    return;
  }

  if (isRequestForAnArticle(event.request)) {
    event.respondWith(
      caches.open(CACHE_NAME)
        .then(cache => requestFailingToCacheStrategy({ event, cache }))
    );
    return;
  }

  event.respondWith(
    caches.open(CACHE_NAME)
      .then(cache => cacheableRequestFailingToCacheStrategy({ event, cache }))
  );
});

Lazy-loading and caching on enter

We are able to combine router callback for loading page file/component with webpack code splitting so we have this powerful feature basically for free. I choose react-router for this example but it could be any decent router with lazy loading support.

const getAboutPage = (nextState, cb) => {
  require.ensure(
    [],
    require => cb(null, require("src/about/containers/AboutPage").default),
    "about-page",
  );
};

const cacheOnEnter = nextState => {
  const { pathname } = nextState.location;
  caches.open(CACHE_NAME).then(cache => {
    const request = new Request(pathname);
    fetch(request).then(throwOnError).then(response => cache.put(request, response.clone()));
  });
};

ReactDOM.render(
  <Router history={browserHistory}>
    <Route path="/" component={App}>
      {/* some routes... */}
      <Route path="about" getComponent={getAboutPage} onEnter={cacheOnEnter} />
    </Route>
  </Router>
, document.getElementById("app"));

Caching on demand

As you will see, and probably already know, it’s possible to access Cache API outside of the Service Worker. Although it’s possible, it’s hard to maintain. We can easily move that responsibility to Service Worker by using postMessage. To keep our code clean let’s start with creating a bus for passing messages to Service Worker.

// swMessageBus.js
import { EventEmitter } from "events";
export default  swMessageBus = new EventEmitter();

swMessageBus.on("message", message => {
  navigator.serviceWorker.controller.postMessage(message);
});

As is was said when it came to strategies, we are going to allow user to choose which article he would like to cache.

handleClick() {
  const url = `http://localhost:3000/articles/${this.props.params.id}`;
  swMessageBus.emit("message", { type: "READ_OFFLINE", payload: url });
}

Last piece is message event handler in Service Worker. It’s very similar to what we did to cache lazy-loaded chunk.

self.addEventListener("message", event => {
  const command = event.data;
  switch(command.type) {
    case "READ_OFFLINE": {
      const request = new Request(command.payload);
      fetch(request).then(throwOnError).then(response => {
        caches.open(CACHE_NAME).then(cache => cache.put(request, response));
      });
    }
  }
});

Conclusion

I really appreciate flexibility of Service Workers and Cache API. I’d like to point out (and share my concerns) that as I’m aware that there are tools which are drop-in, configuration only, solutions for offline but I’m septic. Plugins for offline for webpack seem to be based on the similar idea like AppCache, namely assumptions and conventions. I think there's big chance that you finally come across non conventional use case when building progressive web app.

Photo by Jungwoo Hong on Unsplash.