Progressive Web Apps: First impressions

The Fetch Event

I’ve lost count of the number of times I have created a simple page or session specific store to minimize the number of requests sent to the server. It has become so standard within my development that I can do it by rote memory at this point, copying previous implementations and modifying as needed to suit the current need. But what if that store instance could live across multiple page or tab lifetimes? Enter the service worker’s FetchEvent.

Due to a service worker sitting a layer removed from the page context, it can intercept every request and determine the appropriate strategy on how to respond. Each incoming request event provides the URL, method, headers and plenty of other goodies to help you fine-tune your caching strategy. At first glance, it seems that implementing a custom store should be a simple task. Let’s give it a shot.

self.addEventListener("fetch", event => {
event.respondWith(async () => {
const cache = await caches.open("v1");
const response = await cache.match(event.request);
if (response) {
return response;
} else {
const networkResponse = await fetch(event.request);
cache.put(event.request, networkResponse.clone());
return networkResponse;
}
});
});

Simple enough though a bit unintelligent. Essentially, it will cache every response, shortcut all repeated requests and reach out to the server for each new request.

Responses are stored in a cache implementation that makes storing a request-response pair incredibly simple. Wait for the response to come back from the server then store it in your cache using the provided request object as the key. This simplicity comes with a cost though. When utilizing caches, you are forced to provide a Request object as your key meaning you are at the mercy of the caches implementation regarding how the keys are treated in their uniqueness which is not very well documented. From what I have found, you can manipulate your cache query by using a few optional parameters sent to the keys method.

self.addEventListener("fetch", event => {
event.respondWith(async () => {
const cache = await caches.open("v1");
const keys = await cache.keys(event.request, {
ignoreSearch: true,
ignoreMethod: false,
ignoreVary: false
});
if (keys.length === 1) {
return await caches.match(keys[0]);
} else if (keys.length > 1) {
throw "Cache lookup returned too many results."
} else {
const response = await fetch(event.request);
cache.put(event.request, response.clone());
return response;
}
});
});

As you can see, things are getting a little more complicated. We’ve made it so we ignore any query parameters from any incoming request.

You’re probably wondering what this gains us; I had the same thought. The options provided to us out of the box are a bit lackluster. I’m finding it hard to come up with scenarios where I would use these filters in a meaningful way.
The ignoreMethod option is not very useful considering I would most likely only cache a response from a GET request. While ignoreSearch is probably the most useful of the three, it becomes a lot less so when incorporating a pretty routing framework (think http://host.com/entity/12 instead of http://host.com/entity?id=12). The ignoreVary option also seems to be of little benefit considering the vary header which for a given URL would be unlikely to change.

Let’s remove our cache match filtering for now and go to something a bit more practical. So far we have just trusted that all of the responses from the server have been successful. We have also allowed any HTTP action response to be cached as well. What if we wanted to check the status and method of the response before we decided to cache?

self.addEventListener("fetch", event => {
if (event.request.method !== "GET") {
event.respondWith(fetch(event.request));
} else {
event.respondWith(async () => {
const cache = await caches.open("v1");
const response = await cache.match(event.request);
if (response) {
return response;
} else {
const networkResponse = await fetch(event.request);
if (networkResponse.status === 200) {
cache.put(event.request, networkResponse.clone());
}
return networkResponse;
}
});
}
});

Easy enough. Our logic now ignores any non-GET request and will only cache the response if it returns with a successful status.

We have been able to keep this fairly uncomplicated, but we are treating every cache-able response with the same Always Cache strategy. If we kept this approach, we would be caching all responses including static assets. At first glance, this doesn’t seem all that bad until you realize you are storing the files in two locations: your service worker cache and the browser’s built-in cache. Even worse, the service worker cache (as currently implemented) does not honor any of the caching headers ensuring your users never get an updated value. Let’s try to be a little smarter with what we decided to cache.

self.addEventListener("fetch", event => {
if (event.request.method !== "GET") {
event.respondWith(fetch(event.request));
} else {
const url = new URL(event.request.url);
if (url.pathname.includes('frequentlyupdatedpath')) {
// Network with background cache
event.respondWith(async () => {
const cache = await caches.open("v1");
const cachedResponse = await cache.match(event.request);
const fetchPromise = fetch(event.request)
.then(fetchResponse => {
if (fetchResponse.status === 200) {
cache.put(event.request, fetchResponse.clone());
}
return fetchResponse;
});
return cachedResponse || fetchPromise;
})
} else if (url.pathname.includes('rarelyupdatedpath')) {
// Always cache
event.respondWith(async () => {
const cache = await caches.open("v1");
const response = await cache.match(event.request);
if (response) {
return response;
} else {
const networkResponse = await fetch(event.request);
if (networkResponse.status === 200) {
cache.put(event.request, networkResponse.clone());
}
return networkResponse;
}
});
} else {
// Always network (static assets, must be latest endpoints)
event.respondWith(fetch(event.request));
}
}
});

Wow. That blew up pretty quick considering how little functionality we added. It wouldn’t take long for this to grow to an undesirable size as we continue to add in more and more paths to specify their caching strategies. We could organize this a bit further to keep the code complexity to a minimum. For example, we could utilize regular expressions and a function to loop through and test for matches.

One of the great benefits of creating a page level cache and service layer is the amount of flexibility you have when it comes to its implementation. Typically I have an implementation for each of the CRUD operations associated with a given entity including a cache-able getAll method. Whenever utilizing any of the CUD operations, I insert/update the entity from the response into the cached list of entities to prevent invalidating the whole cache and an additional get entity request. This behavior is harder to implement when utilizing a Response.

The Response object is a stream; there is no way to directly see the contents of the response without converting to a specific format (e.g., JSON, blob). Considering its inaccessibility, the updating of your cached response becomes a bit more cumbersome but still very doable. Moreover, a Response is usable only once. If you aren’t careful when acting upon your response, you’ll end up seeing an error similar to “TypeError: Failed to execute ‘json’ on ‘Response’: body stream is locked”. To resolve this, make good use of the provided clone method.

async function updateCachedResponse(response, entityToInsert,
comparer) {
let json = await response.clone().json();
json = json.filter(function(x){
return !comparer(x,entityToInsert);
});
json.push(entityToInsert);
const newResponse = new Response(JSON.stringify(json), {
headers: response.headers,
status: response.status,
statusText: response.statusText
});
return newResponse;
}

Despite all the issues and complaints I presented, I am incredibly excited about service workers. This technology is powerful. So much so, if you aren’t careful, you can easily create scenarios where you significantly degrade or break the user’s experience. When used correctly though, it grants us a way to seamlessly provide a native-like experience without ever having to touch native code.

Keep an eye out for my next article where I’ll be covering Workbox from Google which aims to resolve the clutter and code complexity issues I’ve presented here.