Offline-First Web With Service Workers

Lakshan Wijesekara
Aeturnum
Published in
7 min readMay 27, 2024

--

In the past, the web was driven by the assumption of constant and better connectivity. Even though technology is very advanced now, we still cannot guarantee constant connectivity every time and everywhere. These traditional online-first websites become unusable, halting progress and leaving users frustrated when the network drops. Nowadays, users expect web applications to function seamlessly, regardless of their internet connectivity.

This is where the concept of “offline-first” development comes in as a game changer. Offline-first web applications are designed to address this need by providing a consistent user experience even when the user is offline or has limited network connectivity. Service Workers play a vital role here and we can use Cache Storage and IndexedDB for storing the data in the process of building offline-first web applications.

Benefits of Offline-First Development

  • Enhanced user experience: Web applications can maintain functionality even in the absence of an internet connection. Users can persist in utilizing core features and accessing cached data, resulting in a more seamless and dependable experience.
  • Faster load times: Service workers can pre-cache essential resources like HTML, CSS, images, and JavaScript files. This allows for faster loading times, especially on subsequent visits and when the internet connection is slow.
  • Reduced data consumption: By serving cached content offline, service workers can minimize data usage, which is beneficial for users with limited data plans or on expensive mobile networks.
  • Burden of the server is reduced: Local handling of tasks reduces the server burden and it is leading to increased efficiency and resource conservation.
  • Increased engagement: Seamless interaction on the web regardless of the good or bad network connection will foster higher user engagement and it will positively impact user interaction and satisfaction.

What are service workers?

Service workers are sophisticated but essentially powerful tools that operate behind the scenes of web applications. They act as proxy servers that sit between web applications, the browser, and the network. The Service worker is an event-driven worker registered against an origin and a path.

The life cycle of service workers

  1. Registration
  2. Installation
  3. Activation

Registration

This is the first step of the life cycle of a service worker and it starts when a user first accesses a service worker-controlled page. In your main JavaScript file which is loaded in your main HTML page, you can use navigator.serviceWorker.register() to register the service worker as following code example.

// app.js
window.addEventListener("load", ()=>{
if("serviceWorker" in navigator){
navigator.serviceWorker.register('/sw.js').then((registration)=>{
console.log("Service worker registration is completed with scope :",registration.scope)
}).catch(()=>{
console.log("Service worker registration failed")
})

}else{
console.log("Service workers are not supported")
}
});

When the window’s “load” event is fired, it starts with checking if the browser supports service workers. If it supports, it will register the service worker file which is “sw.js” in this case. This sw.js file’s URL is relative to the origin and the register function will return a promise. Once the registration is successful, it will log the registration success message with the registered scope. If an error occurs during registration, the message “Service worker registration failed” will be logged. If the browser does not support service workers, the “Service workers are not supported” message will be logged. It is better to place this “sw.js” at the root level to ensure a consistent scope for the service worker across all pages on your site. If you place it in the deeper directory in the directory structure, it might not have control over all the pages.

Installation

After the registration event, the service worker fires the install event. It will fired only once unless there is an update on the service worker. So in this event, we can cache our assets and files which we will need to use in offline mode.

// in sw.js
const version = 1;
const staticCacheName = `staticCache-${version}`;

const filesToCache = ['/', '/js/app.js','404.html'];

self.addEventListener('install',(ev)=>{
// The waitUntil method extends the lifetime of the installation event
console.log("Service Worker Install Event Called");
ev.waitUntil(
caches.open(staticCacheName)
.then((cache)=>{
cache.addAll(filesToCache)
.then(()=>{
console.log("Files added to cache successfully");
})
.catch(()=>{
console.log("Error occurred while file adding to the cache");
})
})
)
})

As in the above code, we are listening to the install event in the sw.js file and once it is fired we can use caches.open() method to create a new cache and it is always better to version the cache as in the above. (The importance of the versioning of cache will be noticed later in the activate event.) Here it will create a cache as “staticCache-1”. Once the cache is created, it will call the cache.addAll(filesToCache) which takes an array of files you want to cache. These can include the main HTML file, CSS, scripts, images, or any other assets your web app needs. Here waitUnitl() is used to ensure the installation is not complete until all the resources in “filesToCache” are cached.

Activation

This event fires when the updated service worker is installed and the waiting phase ends. In this stage, the old service worker is discarded and the new service worker takes control and performs essential cleanup of the previously cached data. Having a versioning cache is helpful in this phase of cleanup.

// in sw.js
self.addEventListener("activate", (event) => {
// Extend the activation event's lifetime until cleanup is complete
event.waitUntil(
// Get all the cache names
caches.keys().then((cacheNames) => {
// Use Promise.all to wait for all cache cleanup tasks to complete
return Promise.all(
// Iterate through each cache names in "cacheNames"
cacheNames.map((cache) => {
// Check if the current cache or not
if (cache !== staticCacheName) {
console.log("Deleting old cache");
// Delete the old caches which is not the latest cache
return caches.delete(cache);
}
})
);
})
);
});

As in the above, we can take all the caches by using caches.keys() and then Promise.all() is used to handle multiple cache cleanup tasks within a Promise. Here every cacheName will be checked against the current cache name and if it does not match, delete it from the cache so it will only remain the latest version of cache.

Assume you have updated the service worker with version 2. If you check the cache storage after installation and right before the activate event fires, you will be able to see different versions of caches as in the following image. In this case, staticCache-2 is the latest version of the cache and once the activate event is triggered staticCache-1 will be deleted according to the above code.

before the new service worker’s activation

In the above image 1122 (old service worker) service worker is in control and the new service worker which is 1123 is waiting to activate. Once the activate event is fired it will be as following image and note that 1123 is changed to activate and running. Only staticCache-2 is available.

after the new service worker is activated

Fetch event

This event is essential in enabling the offline first web pages, which we used for intercepting network requests. This provides the respondWith() method, which allows us to prevent the browser’s default fetch handling and allows us to provide a promise for a Response ourselves.

// in sw.js
self.addEventListener("fetch", (event) => {
/* Prevents the browser's default fetch handling and
respond with the response from the cache or from the network */
event.respondWith(
// Check in the cache if we have cached response for the request
caches.match(event.request)
.then((cachedRes)=>{
// if cache has the response return it from cache or return it from the network response
return cachedRes ||
fetch(event.request.url)
.then((fetchedResponse)=>{
return caches.open(staticCacheName)
.then((cache)=>{
// Add the network response to the cache for future use.
/* Note :here we have to make a copy of the response to save it
cache since we can not save it in cache and return it same time */
cache.put(event.request,fetchedResponse.clone())
// Return the network response
return fetchedResponse
})
})
})
)
});

Here first we check the cache for a cached response for a request using caches.match() method and if there is a cached response, it will be returned. If there is no cached response for the request, a new network call will be made to the server using the fetch() method and if it is a successful response, it will be saved in the cache using cloning the response and then fethedResponse(response of the network call) will be returned. Now if you checked the site in offline mode it will load and it will be functional.

Additionally depending on the requirements, what functionalities, and what pages need offline support, we can decide what to save in cache or not. Saving everything in the cache is also not a good idea. So filter them according to the response types and using other parameters such as URL, we can decide what to store or not.

Trade-offs of Offline-first development

  • Storage Limitations: Due to the limitations of browser storage, storing data locally can be tricky. Limited local data storage capacity can impede extensive offline availability, especially for applications characterized by voluminous datasets. Developers need to carefully manage storage usage and should consider techniques like data eviction or compression to optimize storage space to avoid this constraint.
  • Implementation Challenges: Using service workers effectively requires a deep understanding of service worker functionalities. Such as caching mechanisms, and data synchronization techniques.
  • Security Considerations: Caching sensitive data can introduce security risks if not handled properly. Developers need to make sure that, avoid caching the sensitive data or implement proper security measures like encryption and access controls to protect when storing the sensitive data locally.
  • Data Synchronization: Maintaining data consistency between the local storage (on the device) and the server can be challenging. Implementing strategies to resolve conflicts and ensuring consistent data updates can be a headache for developers.

Conclusion

In conclusion, adopting offline-first development with Service Workers enables the creation of web applications that excel in any network environment. By prioritizing offline functionality, you accommodate users with unreliable internet access and significantly enhance the overall user experience. By leveraging service workers, we can unlock advanced caching capabilities, efficiently manage network requests to ensure offline functionality, and implement background data synchronization. These combined functionalities will lead to a dramatic increase in web application performance and result in resilient, reliable web applications that maintain user engagement, regardless of connectivity.

--

--