Predictive offline support for assets you have not used yet

Preloading assets, a service and app worker love story

Tobias Uhlig
ITNEXT

--

In case you care about your application users navigating around different views in close to real-time, this article is meant for you.

Content

  1. Introduction
  2. The demo app
  3. Remote method access
  4. The love story
  5. The scoping dilemma
  6. Service Workers from JS modules
  7. Offline support
  8. Multi Page Apps
  9. Multi Window Apps
  10. The neo.mjs v4 release
  11. Online demos
  12. Final thoughts

1. Introduction

While Service Workers have been around for quite some time, I have mostly seen them inside the mobile scope. Caching assets which already have been loaded is a common practise, but the topic of using background downloads to fetch assets for the next navigation targets has not gotten the attention yet which it deserves.

2. The demo app

I tried my best to keep this demo app as easy as possible. This 2m video will help you to quickly understand the idea:

After loading our initial view, we fetch the image of Alice via a background download and store it inside our browser cache, since we do assume that a user will most likely navigate here next. Since we do use cached assets in case they are available, we can switch to the target view in close to real-time and it also works in case we disable our internet connection first.

Real world use case:
One more fitting example would be a web-based shop application. Imagine a user looking at a product overview page. Using analytics tools or machine learning, you probably already have the data about the most common user journeys, so you already know where your user will most likely navigate to next. After a delay to not slow down the loading-time of currently needed assets, you can now already fetch the assets for your x most popular products. Once the user does navigate there, the transition will happen almost instantly, since no further downloads of assets are required.

3. Remote method access

To make the communication with Service Workers as easy as possible, we want to be able to trigger relevant methods “directly”:

Obviously, Neo.ServiceWorker does neither exist inside a main thread nor the application worker. You can think about this technique like RPC:

The main difference is that we don’t need (or want) to block a thread while waiting for a response.

Inside the base class of our Service Worker implementation, we are exposing methods to a specific target realm (the app worker in this case) in the following way:

Once a new page connects to our Service Worker, it will receive the new API via a post message and register the namespaces inside the target thread.

Remote method access is not limited to SWs, but works for dedicated and shared workers as well as main threads too.

In case we call a remote method, we will receive the return value as a promise, since the internal post messages chain is async. You can think of this one like ajax calls, just way faster.

The target methods can optionally be async:

Here is a different example to show a non async method which will get exposed:

Be aware that calling it will stay async anyway:

Neo.main.DomAccess.getBoundingClientRect('myId').then(rect => {/* do stuff */})

You can take a look into the implementation here:
src/worker/mixin/RemoteMethodAccess.mjs

4. The love story

Imagine you would need to pass every private message to your beloved one through a person in the middle. I am pretty sure that you would not enjoy it too much due to privacy concerns as well as delays.

The same applies for the communication between our service worker and our (dedicated or shared) app worker. While we could pass the post messages through main threads, we want to avoid this for the exact same reasons.

We are in luck, since a MessageChannel API is available:

The first thing we do when we get a new connection is setting up our private channel:

Service Workers are not exactly the monogamous type (not judging), but this aspect makes them incredible powerful, since they can control multiple apps in parallel.

Even multiple instances of the same app can connect to it. Think about this like dating twins. Strongly not recommended from my personal experience :)

For our remote method access this means that we can expose methods from a SW to an app worker, but the other direction would be problematic, since we can not be sure to reach the app which we have in mind. To handle this scenario we would need to broadcast messages to all connected clients.

5. The scoping dilemma

While a SW has a scope parameter:

This param is limited to be a string. This is quite unfortunate: If we could pass an array of strings or regex, our lives would be easier.

Inside the neo.mjs framework itself, we have the following structure:

In theory we would like to put our SW file into the root folder, since we do want to control the apps and examples folders.

However, we also want to create minified versions using webpack for our dist/development and dist/production folders. Those folders have to be off limits for our dev mode SW version. A scope blacklist is sadly not available either.

It might be possible to pass the scope when initialising the SW for apps or examples , but I was afraid that this could result in unregistering the previous instance and causing delays. Not properly tested yet.

Instead, I created instances inside the two folders instead:

I did not add apps or examples into the SW namespace to ensure that our remote API stays the same and we can easily copy code from one of these folders to another without breaking anything.

In case we generate a new workspace using:

npx neo-app

inside our terminal, we will get the following structure:

The SW singleton file will get put into the apps folder of the workspace itself and our build process will pick up this file to generate the dist/development and dist/production versions.

The reason for this is that this allows us to extend and override our base class as we like to. In case you want to add more remote methods on your own, you can easily do it.

6. Service Workers from JS modules

Like for dedicated and shared workers, we can use the type: 'module' option, which we do for our development mode:

We will only lazy-load this main thread addon, in case you set useServiceWorker: true inside your neo-config.json file or add ServiceWorker into your mainThreadAddons array there (or both).

However, there is a catch:

While a non module based SW will update as soon as you change one byte of code (comments do count as well) inside your SW file, the same does not apply in case you change the code inside your base class which you do extend.

After every code-change, you need to change one byte inside the extended class as well. I am really hoping that browser vendors will fix this.

7. Offline support

As you have seen inside the demo video, our app can work offline. The reason for it is simple:

→ We are adding every file inside dist/production into the cache.

As seen in section 5, you can easily override the cachePaths inside your workspace to better fit your needs.

8. Multi Page Apps

Service Workers are actually great for MPAs. You probably want to place your SW file at the top level of your domain to enable it for multiple nested index files.

You can easily cache “static” assets like images and CSS files.

In case you want to cache JavaScript files like libraries and frameworks, there is a trade-off.

Normally, you will most likely use a build tool like webpack which will create bundles containing a mix of your application code and used 3rd party libs.

Since different pages might use different parts of a given library, you will need to specify it as an external dependency to ensure it will get build into an own file and not get mixed with your app related code. This way, multiple entry points can consume the same file, e.g. hosted on a CDN.

The trade-off here is that we will lose the tree shaking aspect for each page (app), so the initial loading time will increase. However, switching from one page to another will be significantly faster.

9. Multi Window Apps

Multi window apps are actually pretty similar to multi page apps. You have probably seen the multi window covid app demo, where we can just move the component tree of a helix into a different browser window:

(starting at around 2:30)

In case we do add a Service Worker into the mix here, we will cache the images and when adding the DOM into the new browser window, all images will show instantly.

Since a multi window app shares all JavaScript related assets inside a shared application worker, the only thing our framework needs to sync are delta CSS updates. These are in place as well.

10. The neo.mjs v4 release

Version 4 had the focus to add Service Workers into the mix:

As mentioned, remote method access and SW specific builds are already in place.

You are welcome to add feature requests (e.g. for push notifications) here:

Version 4 does not break any of the other APIs. Literally none. Migrating from version 3 should be a piece of cake.

Since our cache size is limited, we could use the Storage API to add checks:

The counterpart, a removeAssets method is already exposed as a remote method into the app realm. Feel free to add a feature request or PR for this one too.

11. Online demos

development mode:
neo.mjs/examples/preloadingAssets/index.html

dist/production:
neo.mjs/dist/production/examples/preloadingAssets/index.html

12. Final thoughts

The webkit (Safari) team has now finished the support for shared workers and it should get into the Safari Tech Preview very soon (I will write a blog post once I can test it).

The Firefox team is getting close with finishing the support for JS modules inside the worker scope.

Once both features go live, it should be prime time for the neo.mjs project.

Of course you can already use and learn the framework right now. My strongest recommendation to get a head start is to join the Slack Channel:

Best regards & happy coding,
Tobias

--

--