Offline app recipes





Good day, friends!



I present to your attention a translation of Jake Archibald's excellent article "Offline Cookbook" devoted to various use cases of the ServiceWorker API and the Cache API.



It is assumed that you are familiar with the basics of these technologies, because there will be a lot of code and few words.



If not familiar, start with MDN and then come back. Here's another good article on service workers specifically for visuals.



Without further preface.



When to save resources?



The worker allows you to process requests independently of the cache, so we will consider them separately.



The first question is when should you cache resources?



When installed as a dependency






One of the events that occurs when a worker is running is the install event. This event can be used to prepare for handling other events. When a new worker is installed, the old one continues to serve the page, so handling the install event shouldn't break it.



Suitable for caching styles, images, scripts, templates ... in general, for any static files used on the page.



We are talking about those files without which the application cannot work like the files included in the initial download of native applications.



self.addEventListener('install', event => {
    event.waitUntil(
        caches.open('mysite-static-v3')
            .then(cache => cache.addAll([
                '/css/whatever-v3.css',
                '/css/imgs/sprites-v6.png',
                '/css/fonts/whatever-v8.woff',
                '/js/all-min-v4.js'
                //  ..
            ]))
    )
})


event.waitUntil accepts a promise to determine the duration and result of the install. If the promise is rejected, the worker will not be installed. caches.open and cache.addAll return promises. If one of the resources is not available, the

call to cache.addAll will be rejected.



When installed not as a dependency






This is similar to the previous example, but in this case we do not wait for the installation to complete, so it will not cancel the installation.



Suitable for large resources that are not required right now, such as resources for the later levels of the game.



self.addEventListener('install', event => {
    event.waitUntil(
        caches.open('mygame-core-v1')
            .then(cache => {
                cache.addAll(
                    //  11-20
                )
                return cache.addAll(
                    //     1-10
                )
            })
    )
})


We do not pass the cache.addAll promise to event.waitUntil for levels 11-20, so if it is rejected the game will still run offline. Of course, you should take care of possible problems with caching the first levels and, for example, try caching again in case of failure.



The worker can be stopped after processing events before levels 11-20 are cached. This means that these levels will not be saved. In the future, it is planned to add a background loading interface to the worker to solve this problem, as well as to download large files such as movies.



Approx. Per .: this interface was implemented at the end of 2018 and was called Background Fetch , but so far it works only in Chrome and Opera (68% according to CanIUse ).



Upon activation






Suitable for deleting old cache and migrations.



After installing a new worker and stopping the old one, the new worker is activated and we receive an activate event. This is a great opportunity to replace resources and delete old cache.



self.addEventListener('activate', event => {
    event.waitUntil(
        caches.keys()
            .then(cacheNames => Promise.all(
                cacheNames.filter(cacheName => {
                    //  true, ,     ,
                    //  ,      
                }).map(cacheName => caches.delete(cacheName))
            ))
    )
})


During activation, other events such as fetch are queued, so a long activation could theoretically block the page. So only use this stage for things you can't do with the old worker.



When a custom event occurs






Suitable when the entire site cannot be taken offline. In this case, we give the user the ability to decide what to cache. For example, a Youtube video, a Wikipedia page, or an image gallery on Flickr.



Give the user a Read Later or Save button. When the button is clicked, get the resource and write it to the cache.



document.querySelector('.cache-article').addEventListener('click', event => {
    event.preventDefault()

    const id = event.target.dataset.id
    caches.open(`mysite-article ${id}`)
        .then(cache => fetch(`/get-article-urls?id=${id}`)
            .then(response => {
                // get-article-urls     JSON
                //  URL   
                return response.json()
            }).then(urls => cache.addAll(urls)))
})


The caching interface is available on the page, just like the worker itself, so we don't need to call the latter to save resources.



While receiving a response






Suitable for frequently updated resources such as a user's mailbox or article content. Also suitable for minor content such as avatars, but be careful in this case.



If the requested resource is not in the cache, we get it from the network, send it to the client and write it to the cache.



If you're asking for multiple URLs, such as avatar paths, make sure it doesn't overflow the origin store (origin - protocol, host and port) - if the user needs to free up disk space, you shouldn't be the first. Take care of removing unnecessary resources.



self.addEventListener('fetch', event => {
    event.respondWith(
        caches.open('mysite-dynamic')
            .then(cache => cache.match(event.request)
                .then(response => response || fetch(event.request)
                    .then(response => {
                        cache.put(event.request, response.clone())
                        return response
                    })))
    )
})


To use memory efficiently, we only read the response body once. The above example uses the clone method to create a copy of the response. This is done in order to simultaneously send a response to the client and write it to the cache.



During the check for novelty






Suitable for updating resources that do not require the latest versions. This can also apply to avatars.



If the resource is in the cache, we use it, but get an update on the next request.



self.addEventListener('fetch', event => {
    event.respondWith(
        caches.open('mysite-dynamic')
            .then(cache => cache.match(event.request)
                .then(response => {
                    const fetchPromise = fetch(event.request)
                        .then(networkResponse => {
                            cache.put(event.request, networkResponse.clone())
                            return networkResponse
                        })
                        return response || fetchPromise
                    }))
    )
})


When you receive a push notification






The Push API is an abstraction over the worker. It allows the worker to run in response to a message from the operating system. Moreover, this happens regardless of the user (when the browser tab is closed). A page typically sends a request to the user for permission to perform certain actions.



Suitable for content that depends on notifications, such as chat messages, news feeds, emails. Also used to sync content such as tasks in a list or marks in a calendar.



The result is a notification that, when clicked, opens the corresponding page. However, it is very important to conserve resources before sending the notification. The user is online when the notification is received, but he may well be offline when clicking on it, so it is important that the content is available offline at that moment. The Twitter mobile app does this a bit wrong.



Without a network connection, Twitter does not provide notification-related content. However, clicking on the notification deletes it. Don't do that!



The following code updates the cache before sending the notification:



self.addEventListener('push', event => {
    if (event.data.text() === 'new-email') {
        event.waitUntil(
            caches.open('mysite-dynamic')
                .then(cache => fetch('/inbox.json')
                    .then(response => {
                        cache.put('/inbox.json', response.clone())
                        return response.json()
                    })).then(emails => {
                        registration.showNotification('New email', {
                            body: `From ${emails[0].from.name}`,
                            tag: 'new-email'
                        })
                    })
        )
    }
})

self.addEventListener('notificationclick', event => {
    if (event.notification.tag === 'new-email') {
        // ,   ,    /inbox/  ,
        // ,   
        new WindowClient('/inbox/')
    }
})


With background sync






Background Sync is another abstraction over the worker. It allows you to request a one-time or periodic background data synchronization. It is also independent of the user. However, a request for permission is also sent to him.



Suitable for updating insignificant resources, the regular sending of notifications about which will be too frequent and, therefore, annoying for the user, for example, new events in a social network or new articles in the news feed.



self.addEventListener('sync', event => {
    if (event.id === 'update-leaderboard') {
        event.waitUntil(
            caches.open('mygame-dynamic')
                .then(cache => cache.add('/leaderboard.json'))
        )
    }
})


Saving cache



Your source provides a certain amount of free space. This space is shared among all storages: local and session, indexed database, file system and, of course, cache.



Storage sizes are not fixed and vary by device and storage conditions. You can check it like this:



navigator.storageQuota.queryInfo('temporary').then(info => {
    console.log(info.quota)
    // : <  >
    console.log(info.usage)
    //  <    >
})


When the size of this or that storage reaches the limit, this storage is cleared according to certain rules that cannot be changed at this time.



To solve this problem, the interface for sending a permission request (requestPersistent) was proposed:



navigator.storage.requestPersistent().then(granted => {
    if (granted) {
        // ,       
    }
})


Of course, the user must grant permission for this. The user must be part of this process. If the memory on the user's device is full and deleting minor data does not solve the problem, the user must decide which data to keep and which to delete.



For this to work, the operating system must treat the browser stores as separate items.



Answering requests



It doesn't matter how many resources you cache, the worker won't use it until you tell him when and what to use. Here are some templates for handling requests.



Cash only






Suitable for any static resources of the current version of the page. You must cache these resources during the worker setup phase to be able to send them in response to requests.



self.addEventListener('fetch', event => {
    //     ,
    //      
    event.respondWith(caches.match(event.request))
})


Network only






Suitable for resources that cannot be cached, such as analytics data or non-GET requests.



self.addEventListener('fetch', event => {
    event.respondWith(fetch(event.request))
    //     event.respondWith
    //      
})


First the cache, then, on failure, the network






Suitable for handling most requests in offline applications.



self.addEventListener('fetch', event => {
    event.respondWith(
        caches.match(event.request)
            .then(response => response || fetch(event.request))
    )
})


Saved resources are returned from the cache, unsaved resources from the network.



Whoever had time, he ate






Suitable for small resources in pursuit of better performance for low memory devices.



The combination of an old hard drive, antivirus, and a fast internet connection can make fetching data from the network faster than fetching data from the cache. However, retrieving data from the network while the data is stored on the user's device is a waste of resources.



// Promise.race   ,   
//       .
//   
const promiseAny = promises => new Promise((resolve, reject) => {
    //  promises   
    promises = promises.map(p => Promise.resolve(p))
    //   ,    
    promises.forEach(p => p.then(resolve))
    //     ,   
    promises.reduce((a, b) => a.catch(() => b))
        .catch(() => reject(Error('  ')))
})

self.addEventListener('fetch', event => {
    event.respondWith(
        promiseAny([
            caches.match(event.request),
            fetch(event.request)
        ])
    )
})


Approx. Lane: Now you can use Promise.allSettled for this purpose, but its browser support is 80%: -20% of users is probably too much.



Network first, then, on failure, cache






Suitable for resources that are frequently updated and do not affect the current version of the site, for example, articles, avatars, news feeds on social networks, player ratings, etc.



This means that you are serving new content to online users and old content to offline users. If the request for a resource from the network succeeds, the cache should probably be updated.



This approach has one drawback. If the user has connection problems or is slow, he has to wait for the request to complete or fail instead of instantly fetching content from the cache. This wait can be very long, resulting in a terrible user experience.



self.addEventListener('fetch', event => {
    event.respondWith(
        fetch(event.request).catch(() => caches.match(event.request))
    )
})


First the cache, then the network






Suitable for frequently updated resources.



This requires the page to send two requests, one for the cache and one for the network. The idea is to return data from the cache and then refresh it when receiving data from the network.



Sometimes you can replace the current data when you receive new ones (for example, the rating of the players), but this is problematic for large pieces of content. This can lead to the disappearance of what the user is currently reading or interacting with.



Twitter adds new content above existing content while maintaining scrolling: the user sees a notification of new tweets at the top of the screen. This is possible thanks to the linear order of the content. I copied this template to display content from the cache as quickly as possible and add new content as it gets from the web.



Code on page:



const networkDataReceived = false

startSpinner()

//   
const networkUpdate = fetch('/data.json')
    .then(response => response.json())
        .then(data => {
            networkDataReceived = true
            updatePage(data)
        })

//   
caches.match('/data.json')
    .then(response => {
        if (!response) throw Error(' ')
        return response.json()
    }).then(data => {
        //      
        if (!networkDataReceived) {
            updatePage(data)
        }
    }).catch(() => {
        //      ,  -   
        return networkUpdate
    }).catch(showErrorMessage).then(stopSpinner)


Worker code:



We access the network and update the cache.



self.addEventListener('fetch', event => {
    event.respondWith(
        caches.open('mysite-dynamic')
            .then(cache => fetch(event.request)
                .then(response => {
                    cache.put(event.request, response.clone())
                    return response
                }))
    )
})


Safety net






If attempts to get the resource from the cache and the network fail, there must be a fallback.



Suitable for placeholders (replacing images with dummy), failed POST requests, "Not available when offline" pages.



self.addEventListener('fetch', event => {
    event.respondWith(
        //     
        //   ,   
        caches.match(event.request)
            .then(response => response || fetch(event.request))
            .catch(() => {
                //    ,  
                return caches.match('/offline.html')
                //       
                //    URL   
            })
    )
})


If your page submits an email, the worker can save it to an indexed database before submitting and notify the page that the submission failed, but the email was saved.



Creating markup on the worker side






Suitable for pages that are rendered on the server side and cannot be cached.



Server-side rendering of pages is a very fast process, but it makes storing dynamic content in the cache pointless, as it can be different for each render. If your page is controlled by a worker, you can request resources and render the page right there.



import './templating-engine.js'

self.addEventListener('fetch', event => {
    const requestURL = new URL(event.request.url)

    event.respondWith(
        Promise.all([
            caches.match('/article-template.html')
                .then(response => response.text()),
            caches.match(`${requestURL.path}.json`)
                .then(response => response.json())
        ]).then(responses => {
            const template = responses[0]
            const data = responses[1]

            return new Response(renderTemplate(template, data), {
                headers: {
                    'Content-Type': 'text/html'
                }
            })
        })
    )
})


Together


You don't have to be limited to one template. You will most likely have to combine them depending on the request. For example, trained-to-thrill uses the following:



  • Worker setup caching for persistent UI elements
  • Caching on server response for Flickr images and data
  • Retrieving data from the cache, and on failure from the network for most requests
  • Retrieving resources from cache and then from the web for Flick search results


Just look at the request and decide what to do with it:



self.addEventListener('fetch', event => {
    //  URL
    const requestURL = new URL(event.request.url)

    //       
    if (requestURL.hostname === 'api.example.com') {
        event.respondWith(/*    */)
        return
    }

    //    
    if (requestURL.origin === location.origin) {
        //   
        if (/^\/article\//.test(requestURL.pathname)) {
            event.respondWith(/*    */)
            return
        }
        if (/\.webp$/.test(requestURL.pathname)) {
            event.respondWith(/*    */)
            return
        }
        if (request.method == 'POST') {
            event.respondWith(/*     */)
            return
        }
        if (/cheese/.test(requestURL.pathname)) {
            event.respondWith(
                // . .:    -   ?
                new Response('Flagrant cheese error', {
                //    
                status: 512
                })
            )
            return
        }
    }

    //  
    event.respondWith(
        caches.match(event.request)
            .then(response => response || fetch(event.request))
    )
})


I hope the article was helpful to you. Thank you for attention.



All Articles