1. Journal Stack Home

One of the many perks of Service Workers is the ability to provide offline capabilities to a web app, giving your users access to your application even in their downtime. In this guide, we would be building just that, an offline-first application using Remix PWA.

Getting Started

For this guide, we would be utilizing the Epic Stack by Kent C. Dodds. The Epic Stack is a production-ready template for building full-stack applications with Remix, Fly and many more tools. The perfect victim for our offline-first application.

To get started, clone the Epic Stack repository, install dependencies and set up the project:

Terminal
git clone https://github.com/epicweb-dev/epic-stack.git

cd epic-stack

npm install

npm run setup

You should know!

Don't forget to set up envs as well

Once you've set up and verified the app works, we can now proceed to adding remix-pwa to our project.

Service Workers

In this section we would be setting up remix-pwa in our project:

npm install @remix-pwa/sw @remix-pwa/worker-runtime

npm install --save-dev @remix-pwa/dev

This installs the necessary packages for remix-pwa to work in our project. Next, we would need to add remix-pwa vite plugin to our app too. Within the vite.config.ts file, go ahead and add the following:

vite.config.ts
// other imports
+import { remixPWA } from '@remix-pwa/dev'

export default defineConfig({
plugins: [
  // other plugins
  remixPWA()
]
})

Finally, we scaffold our Service Worker with the following command:

npx remix-pwa sw

This creates a service worker in app/entry.worker.ts which would be our custom entry point for the service worker. If you want to change the location of the service worker, do so via the --dest/-d flag. Make sure to update the plugin in your vite config to reflect the changes!

If we run our app now, we should see the service worker message being logged to the console 🚀.

Setting Up

Custom Logger

Now that we have our service worker set up, we can go ahead and provide some basic setup. The first of which would be to replace our console statement with @remix-pwa/sw Logger. This step is skippable but it's a good way to ensure you can log in developments without worrying about anything leaking to production.

First of all, we create our own Logger instance. Creating our logging instance allows us to customise the logger to our taste.

import { Logger } from '@remix-pwa/sw'

const logger = new Logger({
  prefix: '[Epic Stack]',
})

The only thing we want to change is the prefix, the styles are pretty good and well-rounded. We can now replace our console statement with our new logger instance:

entry.worker.ts
self.addEventListener('install', event => {
 console.log('Service worker installed');
 logger.log('Service worker installed');

event.waitUntil(self.skipWaiting());
});

self.addEventListener('activate', event => {
 console.log('Service worker activated');
 logger.log('Service worker activated');

event.waitUntil(self.clients.claim());
});

After saving, nothing happens. When you reload though, our new, shiny logger pops up. But that doesn't seem efficient, we want it to detect the changes immediately. We would handle it soon enough :)

Cache

The big thing. For anything to be offline, we need to have an alternative location to serve from. In this case, the browser cache. Let's set up our caches:

entry.worker.ts
const version = 'v1'

const DOCUMENT_CACHE_NAME = `document-cache`;
const ASSET_CACHE_NAME = `asset-cache`;
const DATA_CACHE_NAME = `data-cache`;

const documentCache = new EnhancedCache(DOCUMENT_CACHE_NAME, {
  version,
  strategy: 'CacheFirst',
  strategyOptions: {
    maxEntries: 64,
  }
})

const assetCache = new EnhancedCache(ASSET_CACHE_NAME, {
  version,
  strategy: 'CacheFirst',
  strategyOptions: {
    maxAgeSeconds: 60 * 60 * 24 * 90, // 90 days
    maxEntries: 100,
  }
})

const dataCache = new EnhancedCache(DATA_CACHE_NAME, {
  version,
  strategy: 'NetworkFirst',
  strategyOptions: {
    networkTimeoutInSeconds: 10,
    maxEntries: 72,
  }
})

We create the base three caches: the pages (HTML), assets (CSS & JS) and finally data (whatever data we fetch). We also provided some basic options for each cache. You can tweak as you see fit, we would be probably updating our caches soon enough.

We also created a version constant. This is to ensure cache cleanup, and to enforce it as we see fit (for example, when we have a major update that requires just cleaning our caches).

You should know!

Read more about the EnhancedCache class here

Fetching

As we know, Service Workers intercepts requests from the browser. Remix PWA runtimes provides a way to handle these, but that doesn't mean we don't get to define basic fetch behaviour. Let's go ahead and define the default fetch handler within our service worker:

entry.worker.ts
import {
 EnhancedCache,
 isDocumentRequest,
 isLoaderRequest,
 Logger,
 type DefaultFetchHandler,
} from '@remix-pwa/sw'

// rest of our service worker

export const defaultFetchHandler: DefaultFetchHandler = async ({ context }) => {
 const request = context.event.request
 const url = new URL(request.url)

 if (isDocumentRequest(request)) {
  return documentCache.handleRequest(request)
 }

 if (isLoaderRequest(request)) {
  return dataCache.handleRequest(request)
 }

 if (self.__workerManifest.assets.includes(url.pathname)) {
  return assetCache.handleRequest(request)
 }

 return fetch(request)
}

We set some groundwork here. First of all, we check if the requests coming through fit any of our defined criterias (document/data/asset), if not we just return the request as is.

You might have noticed that even though defaultFetchHandler takes in a request object as a property of its lone parameter, we still went ahead to use event.request from the context. This is because the request object available via context is the raw request, without any modification, whereas the one available directly is a cleaned up version - the same as the one passed to loaders and actions. If we wanted to have access to the request as it was made, we would use the one from the context.

Another thing is that we utilise the __workerManifest object to access our assets. The reason for that is simply that Remix PWA plugin under the hood gathers all assets after scouring the entire Remix App, and then via @remix-pwa/worker-runtime, inject it into the service worker scope. You can see a breakdown on the worker manifest here.

You should know!

Fun fact, if you disable network right now and reload, you get an unstyled page, not an error. That's because some assets are missing (stylesheets, some logos located at resource routes, etc.) but the basics (documents, loader data, etc.) are still served.

And even more interesting fact is that if we were to build and then reload the page (after clearing assets), go offline and then reload, we get the landing page served as is (depending on the order you took, you might need to refresh twice before going offline or head to another route and come back before going offline).

The reason for this behaviour can be simply explained as: in dev, the service worker access to assets are limited. So for example, a fully offline app would only be testable in preview/production mode. Whilst the full thing gets pre-built in prod, so Remix PWA can access them easily.

Hooking Up

The last part of setting up, and that's adding the useSWEffect hook to the root and intercepting messages in the worker. The hook is used to alert the Service Worker to client-side navigation so it can cache documents accordingly.

import { useSWEffect } from '@remix-pwa/sw'

// within the `App` component - could also be the root component
useSWEffect()

The first thing we did was to simply add the useSWEffect hook to our root. We don't need to tamper with it as the defaults are setup perfectly for our needs. The second thing we did was to create a NavigationHandler instance and then listen for messages. You can check out exactly what it does and more here. In short, it fetches the document on each client navigation and caches that document if it isn't found.

Going Off The Grid

Funny, we've more or less accomplished that already. If you build, then preview your application, as long as you've previously navigated to a route, you can go offline and still access that route. In other words, if you deploy at this point, you have an app with full offline-support capabilities.

But we can do more.

Bonuses

Cache Versioning

Cache versioning allows us to keep some order within our caching system. It gives us the ability to clear out large chunks of stale data and avoid messing up newer data with old ones. I am re-deploying my app with some fancy, shiny features, but they are incompatible with the previous caching system I set up. I could either force the new system on the existing one and hope for the best, or I remove the existing one (in a controlled manner, of course) and bring my new one in its place. Say hello to cache versioning.

In service workers, versioning is unsurprisingly simple. When I install a new service worker into an existing application, I check if the existing version is different from the new one, and perform some actions based on the result. This is only possible if we actually kept track of the versions, which we easily did.

To introduce versioning into our existing service worker, I would recommend using the app a bit. Maybe navigate between authentication pages, this is to build up cache content, so you can actually see how easy it is to version despite data size.

Now we've done a few moving back and forth, let's go ahead and actually implement versioning. I would recommend not saving till the very end, that way you incorporate all the important bits and pieces whilst still learning the why. Firstly, update the version constant. Let's do v2, we have a new change we would like to introduce and that also means wiping out stale stuffs.

entry.worker.ts
const version = 'v1'
const version = 'v2'

Next up is to actually clean out the old caches on activation (activate event). The reason why we do this on activate, instead of install can be summarised as follows:

  • Avoid Breaking Current Pages: The install event is fired when the service worker is first installed and hasn't been activated yet. If you clear the cache during the install event, you really risk breaking any currently open pages that are still using resources from the old cache (something we don't want).
  • Waiting for the Right Time: The activate event is fired after the service worker has been installed and is just about to activate. At this point, all of the currently open pages that were using the old service worker have been unloaded, and it's safe to clear out the old cache.
  • Clean Up After the New Cache is Ready: During the install event, you should be caching the new resources that your application needs. Once this is complete and the new cache is ready, the activate event is triggered, allowing you to safely remove the old cache entries.

Replacing our activate listener with the following:

entry.worker.ts
import { clearUpOldCaches } from '@remix-pwa/sw'

self.addEventListener('activate', event => {
  logger.log('Service worker activated')

  event.waitUntil(Promise.all([
    clearUpOldCaches([DOCUMENT_CACHE_NAME,DATA_CACHE_NAME,ASSET_CACHE_NAME], version),
    self.clients.claim(),
  ]))
})

We can finally go ahead and save our service worker. We are done! If we go ahead and refresh the page, you should see every cache not ending with v2 suffix get deleted. Depending on your browser, it might not outright get deleted from the cache list, but if you check the content, the cache is totally empty. Mission successful!

UI Feedback

If you were looking at the logs in preview mode (npm run start), you would notice that there were no logs. If you were on a mobile device, it would even be impossible to check! This is because Logger only outputs in development mode. And we also can't be telling our users to keep an eye out for any new/interesting log coming in. We need to actually let them know when something goes wrong, an update comes in, connection goes off or on, etc. That's what we would be attempting in this section.

The amount of UI feedback possible is plenty. Gives your users better interaction with your app as well as reduce the chance of sudden "mishaps", we would be limiting ours to just two:

  • Network Connectivity
  • Service Worker Update

Network Connectivity

The premise for this one is simple: If the users goes offline, let them know. When they come back online, also let them know. This would come in form of a simple pop up. For this part, we would require @remix-pwa/client package. Go ahead and install it via: npm install --save @remix-pwa/client

Within our root (we want to show the popup everywhere), preferably in the App component, we add the following:

root.tsx
import { useNetworkConnectivity } from '@remix-pwa/client'
import { toast } from 'sonner'

// within our `App` component
useNetworkConnectivity({
  onOnline: () => {
    const id = 'network-connectivity'
    const title = 'You are back online'
    const description = 'Seemed your network went for a nap, glad to have you back!'
    const type = 'message'

    toast[type](title, { id, description })
  },

  onOffline: () => {
    const id = 'network-connectivity'
    const title = 'You are offline'
    const description = 'Seems like you are offline, check your network connection'
    const type = 'warning'

    toast[type](title, { id, description })
  }
})

Now, if we were to head back to the browser and open our network tab, using the thottle option to switch connectivity, we see the message pop up. If you are on a mobile device, you would see a toast message pop up. This is a simple way to let your users know when they are offline or online.s

You should know!

One thing to note, the service worker has a offline and an online event. Combine that with the main client thread useNetworkConnectivity hook, you can easily set up a more sophisticated offline/online detection + action system.

Service Worker Update

This one is also another simple one that alerts the user to updates within your app. If you push an update midway through usage, there are a few possible scenarios:

  • Force the new update to take over immediately. This also includes Service Worker changes. This might not be advisable (based on requirements) as it can lead to disruptive situations (if I am in the middle of filling a form and then cache gets wiped because of an update, I would get pissed)
  • Not do anything. Till the user re-opens my site, nothing happens. They keep using the old version, even if that means for weeks.
  • Alert them about an update, prompting them to update or not.

'Update' in this context refers to new Service Workers being deployed. Since a Service Worker is integral to your app flow, you can term it update.

In our case, we would be doing a simple mini-prompt.

First, we need to head to our service worker and remove the skipWaiting method in the install event. The reason we yeet it out is that the skipWaiting method forces the new service worker to take over immediately as soon as it gets detected. We still leave the clients.claim method in the activate event as that allows the new service worker to take control of the clients as soon as possible.

entry.worker.ts
self.addEventListener('install', event => {
 logger.log('Service worker installed')

 event.waitUntil(Promise.all([
  assetCache.preCacheUrls(
    self.__workerManifest.assets.filter(url => !url.endsWith('.map') && !url.endsWith('.js'))
  ),
  self.skipWaiting(),
 ]))
 event.waitUntil(assetCache.preCacheUrls(
   self.__workerManifest.assets.filter(url => !url.endsWith('.map') && !url.endsWith('.js'))
  ))
})

Now, if we make a change to our service worker, reload - we need to reload for the browser to fetch the new service worker and detect the change - and then check the 'Service Worker' tab in the 'Application' tab of the browser dev tools, we would see a new service worker waiting to take over. You can skip waiting and activate it manually, but that's not the point of this section.

Going back to our root, using the usePWAManager hook, we can create a mini-prompt that alerts the user to a new update. This hook is part of the @remix-pwa/client package. Go ahead and install it via: npm install --save @remix-pwa/client if not present. Within our App component, we add the following:

root.tsx
import { usePWAManager } from '@remix-pwa/client'
import { sendSkipWaitingMessage } from '@remix-pwa/sw'

// within `App`
const { swUpdate } = usePWAManager()

// below `EpicProgress` component
{swUpdate.isUpdateAvailable && (
  <div className='bg-background text-foreground fixed bottom-6 right-6'>
    <p>Update available</p>
    <button onClick={() => {
      sendSkipWaitingMessage(swUpdate.newWorker!)
      window.location.reload()
    }}>
      Reload
    </button>
  </div>
)}

A very simple prompt that serves our purpose within the root so it is available on all pages. Breaking this down, the swUpdate object contains two properties:

  • isUpdateAvailable: A boolean that tells us if there is a new service worker waiting to take over.
  • newWorker: The new service worker that is waiting to take over or null, if isUpdateAvailable is false.

If isUpdateAvailable is true, we display a prompt that tells the user an update is available. When the user clicks the 'Reload' button, we send a message to the service worker to skip waiting and then reload the page. Note, the sendSkipWaitingMessage function took in the new service worker as its lone argument instead of the current registration, that's because it is the new worker that is doing the skipping.

You should know!

In case you are wondering why we are going through the hassle of sending a message instead of skipping directly, it's because there's no way to directly skip waiting from the client thread. The service worker has to do it itself. So we send a message to the service worker to skip waiting and then reload the page.

The reason we reload after skipping, is because when the service worker activates and the clients.claim() method gets invoked, all other windows/tabs get claimed automatically except the current one (that detected the new worker), which needs to be reloaded to be claimed by the new worker.

No need to try this out yet, it doesn't work. If you noticed, nowhere have we actually implemented the skipping in the worker. Let's go ahead and do that now.

entry.worker.ts
import { SkipWaitHandler } from '@remix-pwa/sw'

// rest of the service worker

const skipHandler = new SkipWaitHandler()

self.addEventListener('message', (event: ExtendableMessageEvent) => {
  event.waitUntil(Promise.all([
    messageHandler.handleMessage(event),
    skipHandler.handleMessage(event),
  ]))
})

The SkipWaitHandler is a utility paired alongside the sendSkipWaitingMessage function. It listens for messages with the SKIP_WAITING message and then skips waiting. Now that's about it, if we go ahead and make a change (to the version, for example) and reload (we need to reload for the new service worker to be detected), we would see the prompt. Clicking the 'Reload' button would skip waiting and reload the page, bringing in the new service worker. You can keep updating versions and testing this out.

We now have a simple system for detcting updates and alerting the user. This can be expanded to include more features like a more sophisticated prompt, a notification system 🫣, etc. But for now, this is a good start.

Precaching

Now we technically don't want to do this in an app that has two distinct sides for authorised and not-authorised users. Precaching could be catastrophic if note dont right.

Precaching is the act of caching resources before they are requested. This is a great way to ensure that your users have the resources they need before they even ask for them. This can be especially useful for resources that are used on multiple pages, or for resources that are critical to the user experience. Or heavy assets that you would like out of the way before the user even gets to them.

Fortunately for us, we do have a lot of assets that can use some precaching. Let's go ahead and precache every non-JS asset present in epic stack. As you might have guessed, the magic takes place in the install event handler as this is before the service worker even gains control of pages. Allowing it to do its bidding behind the scenes before being handed the clients.

For this, you might want to make things a touch more sophisticated. Perhaps a precache-dedicated cache. Or even spread out the assets cache, one for images, another for stylesheets and fonts, etc. But for the sake of simplicity, we would just add them to the existing asset cache.

entry.worker.ts
self.addEventListener('install', event => {
  logger.log('Service worker installed')

  event.waitUntil(Promise.all([
    assetCache.preCacheUrls(
      self.__workerManifest.assets.filter(url => !url.endsWith('.map') && !url.endsWith('.js'))
    ),
    self.skipWaiting(),
  ]))
})

And that also wraps it up for this section. We can go ahead and clear our browser storage, reload the page and voila! Our asset cache becomes populated real quick. Couple that with the fact that our asset strategy is CacheFirst, meaning we attempt to fetch from cache first before falling back to the server, this means less round trip for assets. Oh, and by the way, cache validation still works. Meaning in 90 days, they become stale.


That's about it for this guide. Hopefully you picked up a thing or two. Service Workers are real fun, and as a side effect, powerful too. See you in the next guide 👋.