Offline Service Workers

Offline Service Workers

Our customers expect the music never to stop.

Roaming cell connection? Music plays

In a tunnel? Music plays

On Mars? Music plays

Handling unreliable network conditions is crucial for uninterrupted music playback and enhanced application responsiveness. This post will discuss techniques for creating a persistence layer that is robust in handling assets and data with different caching requirements.

+----------------------+
|      Your App        |
| (Browser/Client UI)  |
+----------------------+
           |
           | Intercepts Requests
           v
+----------------------+
|    Service Worker    |
| (Runs in Background) |
+----------------------+
     |                |
     |                +------------+
     |                             |
     v                             v
+----------------------+      +----------------------+
|       Network        |      |    Offline Cache     |
|    (CDN / Server)    |      +----------------------+
+----------------------+      

What is an offline service worker?

A service worker is a script that runs in the background, connecting to one or more websites. The service worker script is registered to a domain which allows the worker to intercept and modify any request for that domain. This allows the script to control and cache the entire website, including the index html file, application js, and static assets.

Service workers are particularly useful for, but not limited to, Progressive Web Applications ( PWAs). A PWA is an application that wraps a website, potentially adding additional native capabilities.

An example of a good use of offline service workers is none other than our arch rival, Spotify.

Turn this:

Fig 1

Into this:

Fig 2

Launching an offline website

A Word of Caution ⚠

Before jumping right into how to create a service worker, a word of caution.

Service workers are registered to a domain and are updated independently of the application. Without effective update and cache-busting mechanisms, bugs introduced by outdated service workers may become extremely difficult to resolve.

My First Worker

A service worker is registered via navigator.register. This is called with the path to the service worker script and the scope to which it’s registered. The scope is by default ‘./’ which indicates the same directory for which the page is installed. For single page applications, this should typically be the top level '/' in order to allow different paths/pages to use the same worker.

Registering

async function initializeServiceWorker() {
    if ('serviceWorker' in navigator) {
        await navigator.serviceWorker
            .register(`/serviceWorker.js`, {scope: '/'})
            .catch((error) => {
                console.warn('Could not register service worker:', error)
            })
    }
}

You can see which service workers are registered to a website by going to the Application tab in Chrome dev tools.

Fig 3

⚠️ Important Note:
Once a service worker is registered, calling register again will have no effect. To make changes to a service worker, it must be Updated.

Updating

Without explicitly updating a service worker, it’s up to the browser when to check if there’s a new version of the script. This is typically once every 24 hours. Calling update on a service worker registration will tell the browser to check if there’s a new version. If the script hasn’t changed, it will not be re-installed.

To update a service worker, you can manually reload in your browser’s dev tools, or call ServiceWorkerRegistration#update.

async function initializeServiceWorker() {
    if ('serviceWorker' in navigator) {
        const registration = await navigator.serviceWorker
            .register(`/serviceWorker.js`, {scope: '/'})
            .catch((error) => {
                console.warn('Could not register service worker:', error)
            })
        if (registration) {
            if (registration.installing) {
                console.log('Service worker installing')
            } else if (registration.waiting) {
                console.log('Service worker installed')
            } else if (registration.active) {
                console.log('Service worker active')
                await registration.update().catch((error) => {
                    logWarn(
                        target,
                        'Could not update service worker:',
                        error
                    )
                })
            }
        }
    }
}

Remember that your service worker is separate from the application. If you change the service worker’s name in this example, you will have two (conflicting) service workers for your website. A disaster scenario would be to create a service worker with an indeterminate or changing name, losing control of all dangling workers, unable to terminate them.

  • Keep your service worker with a static, unchangeable name.

  • If there’s versioning, version the caches with a cleanup script.

Hello World

// serviceWorker.js

// Log when the service worker is installed
self.addEventListener('install', (event) => {
    console.log('Service Worker: Installed');
});

// Log when the service worker is activated
self.addEventListener('activate', (event) => {
    console.log('Service Worker: Activated');
});

// Log every fetch request the service worker intercepts
self.addEventListener('fetch', (event) => {
    console.log('Service Worker: Fetch intercepted for', event.request.url);

    // Let the request continue as normal
    event.respondWith(fetch(event.request));
});

Service Worker Lifecycle

A brief description of the service worker lifecycle events. More in-depth descriptions can be found:

install

Triggered when the browser detects a new or updated service worker file. ServiceWorkerRegistration#update may cause a new install if the js has changed.

Useful for adding offline files to the cache.

activate

Triggered when the worker registration acquires a new active worker.

Note that if a service worker is active, it doesn’t mean it has claimed control over a web page. Typically, if a webpage has loaded from the network, it will continue to load sub-resources from the network.

During the activate event, the worker can now “claim” uncontrolled clients or clean up outdated caches.

Example:

self.addEventListener("activate", (event) => {
    const cacheAllowlist = ["v2"]

    // While this is the first event where it's safe to delete old caches, it may be smart to defer 
    // this until after startup to avoid potentially expensive disk operations on load:
    event.waitUntil(
        void (async () => {
            for (const cacheKey of caches.keys()) {
                if (!cacheAllowlist.includes(cacheKey)) {
                    await caches.delete(cacheKey)
                }
            }
            // Take immediate control (read note of caution):
            await self.clients.claim()
        })()
    )


})

The advantage of taking immediate control is that your service worker can immediately begin storing responses to the cache, and the disadvantage is that if your service worker changes the responses based on whether it was loaded from network or disk, your worker will produce inconsistent responses.

Another action that can be done at this time is enabling navigation preloads.

fetch

Allows interception of a network fetch. This is not just when window.fetch is called, but any network request, whether it’s an image, document, css, script, etc.

self.addEventListener('fetch', (event) => {
    event.respondWith(
        (async () => {
            console.log('fetch intercepted!', event.request)
            return fetch(event.request)
        })()
    )
})

Typescript note: To strongly type self, add:

declare const self: ServiceWorkerGlobalScope

Caching

How many different ways are there to cache something in a browser?

So many!

  1. HTTP Cache - Temporary internet files for resources fetched over HTTP(s). Managed automatically by the browser based on HTTP headers like Cache-Control, Expires, ETag, and Last-Modified. How much storage is available is typically a set percentage of total disk space.

  2. Cache API (aka Service Worker Cache) - A Request/Response map controllable via JS, available to both the application and service worker. This cache gives you granular control and is designed for HTTP resources.

  3. IndexedDB - A low-level, asynchronous NoSQL database for storing large amounts of structured data (e.g., JSON, blobs, files). Good for storing complex, queryable data. Google’s Shaka playback engine uses this cache for offline support.

  4. Local Storage - A key-value storage mechanism with synchronous access. Not intended for large files, useful for storing basic JSON that can persist across page loads. We use this in music. amazon.com when saving customer authentication tokens.

  5. Session Storage - The same as local storage, but cleared when the browser tab closes.

  6. Cookies - Small pieces of data stored as text files in the browser, typically sent with every HTTP request. Useful for tracking user behavior across pages or sessions.

  7. Memory Cache - A temporary in-memory store for resources fetched during a session. Recently fetched resources are stored in RAM until the tab is closed. Not controllable by the application.

  8. WebSQL (Deprecated) - Use IndexedDB instead.

  9. Application Cache (Deprecated) - Use Cache API instead.

  10. Origin Private File System (OPFS) - A part of the File System Access API, useful for storing and manipulating large binary files. Adobe Photoshop for the web uses this for storing and modifying saved documents.

Cache API (Service Worker Cache)

For offline web pages, we’re going to only focus on the Cache API. It’s controllable and perfect for network responses. It automatically handles compression, it lets you use requests directly as keys, and has good browser compatibility. OPFS and IndexedDB are worth mentioning, but are overall more complicated without any gain for our use case.

Here’s a basic example of using the Cache API in a service worker:

const CACHE_NAME = 'offline-cache-v1'
const RESOURCES_TO_CACHE = [
    '/',
    '/index.html',
    '/styles.css',
    '/script.js',
    '/images/logo.png',
]

// Install event: Cache resources
self.addEventListener('install', (event) => {
    console.log('[Service Worker] Install event triggered')
    event.waitUntil(
        caches.open(CACHE_NAME).then((cache) => {
            console.log('[Service Worker] Caching resources')
            return cache.addAll(RESOURCES_TO_CACHE)
        })
    )
})

// Fetch event: Serve cached resources or fallback to network
self.addEventListener('fetch', (event) => {
    console.log('[Service Worker] Fetch event for:', event.request.url)

    event.respondWith(
        (async () => {
            if (event.request.method === 'GET') {
                caches.match(event.request).then((response) => {
                    if (response) {
                        console.log(`[Service Worker] Serving from cache: ${event.request.url}`)
                        return response
                    }
                    console.log(`[Service Worker] Fetching from network: ${event.request.url}`)
                    return fetch(event.request)
                })
            } else {
                return fetch(event.request)
            }
        })()
    )
})

When the service worker is installed, the offline files are requested and cached, when fetch is intercepted, the cache is first checked. When you intercept fetch in a service worker and resolve from the service worker cache instead of using window Fetch, you bypass the HTTP and Memory caches. For Single Page Applications, this can be acceptable, but note that the Memory cache will be the fastest possible cache.

It’s also important to consider that responses with Cache Control headers that allow caching will be cached to the HTTP cache when using window Fetch, and storing them in the service worker cache may double your disk usage. To avoid this, a possible solution would be to check the response and not use the offline cache if the response headers permit HTTP caching.

Tip:

A simple way to generate the manifest automatically, in your node.js build scripts you can use glob.

fs.writeFileSync(
    'dist/manifest.txt',
    glob
        .sync('**', {
            cwd: 'dist',
            nodir: true,
            ignore: [
                'manifest.txt',
                '*.map',
            ],
        })
        .join('\n'),
    'utf-8'
)

Cache Strategy

Taking full control over the browser’s caching strategy is both complicated and risky. There are a lot of questions to consider before building a solution.

  • What is the ideal offline experience?

  • What is the ideal poor-network experience?

  • How long should resources be cached?

    • Are service responses handled differently than static assets?

    • Can caching strategy be controlled by the server?

  • How to mitigate if there’s a defect in the caching layer.

  • When/how to use cache-first, network-first, network-only, or cache-only strategies.

The browser-managed HTTP Cache does a lot of things very well, and taking control over all caching means re-implementing a lot of the browser’s default behaviors, such as resource freshness checks and validation. The default HTTP cache generally cannot be used when offline unless resources were explicitly cached with permissive headers, and will not work for POST requests.

It’s first important to know how the HTTP cache works in order to know when the Service Worker Cache is needed.

Cache-Control

The Cache-Control response header tells the client how it should cache resources. Here are the directives specific to client-side caching:

  • max-age: The maximum amount of time (in seconds) a resource is considered fresh.

  • no-cache: Requires the resource to be revalidated with the origin server before serving it from the cache.

  • no-store: Prevents the resource from being cached by any mechanism.

  • must-revalidate: Ensures that stale responses cannot be used without successful revalidation with the origin server.

  • immutable: Indicates that the resource will not change, so it does not need to be revalidated.

  • stale-while-revalidate: Allows serving stale responses for the specified duration (in seconds) while asynchronously revalidating the cache.

  • stale-if-error: Allows serving stale responses (in seconds) if the origin server is unavailable or returns an error.

  • only-if-cached: Indicates the client only wants a response from the cache and will not make a network request.

When we implement our offline caching, a simple way is to have a strategy the client provides, like how the GQL Apollo client works: cache-first (default), cache-and-network, network-only, and cache-only. These are hopefully self-explanatory.

This may suit your needs, but doesn’t take into account nuances for how long to cache (freshness), when to revalidate, or allow the server to give hints for caching behavior.

A more robust strategy is to create a cache manager used by both the offline service worker and application for service requests, then provide a way for the application to set defaults and/or overrides to Cache-Control headers.

An example of how to replicate the Apollo cache-first strategy would be to override the stale-while-revalidate directive, and indicate that the (stale) cache should be used followed by an asynchronous revalidation.

When creating a cache manager, note that the Cache API (strangely) doesn’t provide a way to query when a response was put into the cache. Cache-control directives use seconds-from-now values instead of timestamps to indicate expiration. This means we need to add additional information before caching.

To do this, we can clone the response and add a new header:

export class CacheManager {
    /**
     * Caches a response with added expiry headers.
     */
    async put(cacheId: RequestInfo, response: Response): Promise<Response> {
        if (!response.ok) return response
        const clonedResponse = response.clone()

        // Add a timestamp header
        const headers = new Headers(clonedResponse.headers)
        headers.append(CACHED_TIMESTAMP_HEADER, Date.now().toString())

        const responseWithHeader = new Response(clonedResponse.body, {
            status: clonedResponse.status,
            statusText: clonedResponse.statusText,
            headers,
        })

        const cache = await this.cache
        await cache.put(cacheId, responseWithHeader)
        return response
    }

    // ... get/has/delete/clean
}

Now when we retrieve a response from the cache we can query the timestamp and use the cache control directives to get absolute timestamps.

This is less error-prone than maintaining a separate store of metadata for our cached entries because this way the metadata cannot get out of sync.

Conclusion

By integrating offline service workers effectively, we can deliver seamless user experiences across various network conditions while reducing operational costs through minimized network traffic.

Fig 4

References