Web DevelopmentCaching single page applications

One of the key features of single page applications (SPAs) is that they serve the exact same html page to the browser irrespective of the URL you visit. This html defines the framework of the app, I'll refer to it as the app html. In the following example we analyse how caching of the app html would behave in a typical SPA, and then we look at what we can do to improve it.

Imagine that this week's fad is a website which allows you to watch videos of certain pets. Let us also assume that this website has been written as a SPA.

An email shows up in my inbox:

Hi, Check out tiddles: http://example.com/cats/tiddles  x

Of course I follow the link, and my cache being empty for this site I download the app html and all the resources linked from it.

Let's ignore the extra resources the app html requires as these can be versioned and cached outright at a fixed URL, they're not really an issue for subsequent visits. Our simplified situation looks like this.

1. Check cache for http://example.com/cats/tiddles - Cache empty 2. Download from server 3. Add to cache 4. Return to client

Now if I revisit that same URL, my browser can serve the app html directly from the cache, with possibly just a revalidaton request and 304 not modified response from the server, assuming the appropriate caching headers were set. However...

Later in the day I see a tweet:

Rover beats other dogs with this one weird trick. http://example.com/dogs/rover

I follow the clickbait. Now I've never been to this URL before, so there will be nothing in the cache and my browser will call off to the server and once again download the app html.

1. Check cache for http://example.com/dogs/rover - No match 2. Download from server 3. Add to cache 4. Return to client

In fact every time I visit some url in the SPA that I've not been to before, assuming I've not navigated from within the control of the SPA itself, the browser re-downloads the app html and caches it separately. Can't we save those extra bytes and milliseconds by not downloading it again?

Not the solution you're looking for

We could go back to doing our SPA URLs the oldschool way, so rather than having clean URLs to identify the state/position in the application, like

http://example.com/cats/tiddles

we could use URL fragments like

http://example.com/#!/cats/tiddles

This way there's only one URL as far as the server is concerned as the fragment is not sent to the server. As such we only download the app html on the first visit to our app, subsequent visits to any state in our app, for example to check out Rover, will be 304 not modified responses.

"But I can't give up my clean URLs" I hear you cry. So how can we get the same caching behaviour, but still keep our clean URLs?

Use a service worker

A service worker allows us to have full programmatic control over the network requests and responses as well as caching. So, using a service worker we can ensure that each of our requests to different urls serves the same app html from the cache, even if we've never been to that specific URL before.

So, in our app html we register the service worker.

navigator.serviceWorker.register('/sw.js');

Before you go any further make sure you use the correct caching headers for the service worker itself - it will always be requested, so we want a 304 not modified response whenever it hasn't changed.

Now, inside the service worker we need to take control of our cache. On install let's cache our app html.

self.addEventListener('install', event => {
    var installed = caches.open('cache-v1').then(cache => cache.addAll(['/']));
    event.waitUntil(installed);
});

Whenever a network request is made it is handled by the service worker, and so we can check to see if it is for a URL that requires the app html as a response. I've hidden the logic of this URL check away inside a function isPageRequest, as this logic is specific to your application's URL routing structure. If it is a request for the app html, let's mock up a request that will match the app html that is stored in the cache. Using this request we retrieve the response from the cache and send it back to the browser, no need to even go to the network.

self.addEventListener('fetch', event => {
    var request = event.request;
    if(isPageRequest(request)) {
        request = new Request('/');
    }

    var response = caches.match(request).then(resp => resp || fetch(request));
    event.respondWith(response);
});

Now, every page request, whether it's for /cats/tiddles, /dogs/rover, or whatever, they all get redirected to a request for /. As such they can all share the same cache response, even if you've never been to that specific URL before.