Initial Implementation of Service Worker with Workbox in the Phoenix Framework

Efficiently Implementing Service Workers in Phoenix Framework Using Workbox — Discover how to boost performance and user experience with a step-by-step guide to caching images with service workers and Workbox integration in Phoenix.

Oct 30, 2024 / 10 Mins Read
Shahryar Tavakkoli

Shahryar Tavakkoli

  • Initial Implementation of Service Worker with Workbox in the Phoenix Framework
Phoenix
LiveView
Elixir

Initial Implementation of Service Worker with Workbox in the Phoenix Framework


A service worker essentially provides a set of categorized tools offered by the browser to reduce the costs for both clients and servers in web applications. Additionally, it can enhance user experience by bringing certain capabilities closer to native apps, such as sending notifications and temporary data storage (caching), among other features.

In this brief post, we'll focus specifically on storing images in the user's browser, while also touching on caching strategies provided by Google's Workbox library.


Support Our Work

98% of Mishka's products are open source, and we need your support to keep growing stronger.

Every contribution makes a difference and is deeply appreciated! ❤️

The first question is: Why should we even do this?

Most modern browsers that users have been using since 2019 offer highly stable and reliable features, one of which is caching with your chosen strategy. For example, why should a user download the same repetitive assets of your website every time—assets that might not change for months or even years? Instead, with just a simple function, you can load them locally when the user revisits your application. This approach not only enhances load speed but also significantly improves the user experience.


Of course, it's worth mentioning that the capabilities of service workers go far beyond just this. They cover a wide range of modern web application needs. For example, just open Google Chrome, right-click on the page, and select Inspect . Among the tabs you see, click on Application , and then select Service Workers from the left-hand side.


So far, we've summarized why you might need a service worker. We won't delve further into the details in this post. If you'd like to dive deeper, I recommend checking out two great articles from Mozilla: Service Worker API and Web Workers API . Additionally, the Chrome Developers YouTube channel is an excellent resource, and I’ll be linking one or two of their best playlists below.


Cache First Strategy for Loading Images from the User's Local Storage:

As you may have noticed, I mentioned the strategy name in the title of this section. This is because the amazing Workbox library offers several strategies for managing internet connectivity and caching. Each of these strategies can be easily configured and customized to fit your needs.


But what is the Cache First strategy?

This strategy helps ensure that, for example, if an image is already stored in the user's browser, it doesn’t need to be downloaded from the server again. Instead, it will only be downloaded for the first time if it doesn’t already exist, and then it’s stored in the browser’s Cache Storage. The great part is that Workbox handles all of this for you in a very straightforward way, which we’ll explore further.


If you'd like to learn more about the strategies, click on workbox-strategies or check out this YouTube playlist: Unpacking the Workbox .


How to Enable a Service Worker?

Before we start, I need to explain a simple concept about service workers. Imagine you have a series of EventListeners in your code, each representing a stage of communication with the client (i.e., the browser). In practice, most of this is managed by Workbox, but not understanding these stages might make everything seem a bit 'magical', which could make this tutorial harder to follow.

  • install : This listener is activated when the service worker is installed for the first time. It is typically used for caching the initial resources of the application.
  • activate : This listener runs when a new service worker has been installed and is ready to take control. It is usually used to clean up old caches.
  • fetch : This listener is triggered every time the browser makes a network request. It is commonly used to handle cached responses and network requests.
  • message : This listener is activated when a message is sent from the web page to the service worker. It can be used for handling specific actions like triggering a skip waiting command.
  • push : This listener activates when the service worker receives a push notification from the server. It is used for displaying notifications.
  • sync : This listener is used for background synchronization and activates when an internet connection is established. It can be useful for sending data after a connection loss, for instance.

These listeners collectively enable service workers to effectively manage requests, caching, notifications, and data synchronization. However, thanks to Workbox, you don't need to write code for each of these aspects manually—it simplifies everything for you.

I've tried to keep the descriptions concise and clear while retaining the original points.


Let's write some code! First, create a file named sw.ts in the js folder of your project. Yes, we are going to use TypeScript. The path for the file will be assets/js/sw.ts .

/// <reference lib="webworker" />
import { clientsClaim } from "workbox-core";
import { registerRoute } from "workbox-routing";
import { ExpirationPlugin } from "workbox-expiration";
import { CacheFirst } from "workbox-strategies";

declare const self: ServiceWorkerGlobalScope;

clientsClaim();
self.skipWaiting();

In the code above, we imported some items and, to get started, called the following two functions during the activation and installation phases:


clientsClaim();

self.skipWaiting();


With just these two lines, you've completed all the steps required to activate the service worker in the browser—it's that simple. Also, one line of declare solves the TypeScript issue with calling self.

In the next step, we need to implement the Cache First strategy, specifically targeting only images.

registerRoute(
  ({ request, url }) => {
    const isImage = request.destination === "image";
    const isInImagesFolder = url.pathname.startsWith("/images/");
    const isNotServiceWorker = !url.pathname.includes("/sw.js");

    return isImage && isInImagesFolder && isNotServiceWorker;
  },
  new CacheFirst({
    cacheName: "images-cache",
    plugins: [
      new ExpirationPlugin({
        maxEntries: 50,
        maxAgeSeconds: 30 * 24 * 60 * 60,
      }),
    ],
  }),
);

In a simple way, we've registered a route that initially targets only images, ensuring that it starts with /images/ because that's where the images we want to cache are located. We also added an extra control line (which is a good practice) to prevent caching /sw.js , as this file needs to always be executed and serves as the cache controller itself.


Finally, if all these conditions are met, we move on to the next step, which is applying the CacheFirst strategy . In the line cacheName: images-cache , we give this section a name to keep it well-organized in the browser storage. After that, we call the ExpirationPlugin plugin to manage the cache's expiration date.


This is a feature of Workbox that manages both the expiration time and the maximum number of cached images in the user's browser automatically. Imagine you've cached 50 images, and you visit a page with a new image—let's say image 51. The library will remove the oldest image to make room for the new one. Isn't that amazing?


Our work with the sw.ts file is now complete, but it's not yet implemented in Phoenix. Before moving on to the configuration, I need to mention a few things. The goal of this tutorial is to avoid adding node_modules . In other words, we won't be introducing dependencies into our release version—we'll keep everything in the production phase. So, if you want to take a few extra steps, you'll need to install NodeJS and npm during the Docker build created by Phoenix, and then download and use package.json during the build process.


For instance, when might you need this extra setup? One scenario where I think you might need it is when working with the Precaching strategy. This is because you need to adjust some settings in esbuild and also create a manifest file. However, for the strategy we're currently using, this isn't necessary.


Step one is to navigate to the project's Phoenix assets directory. You can either install these items as development dependencies or have the script installed globally on your system. The path to look for is assets/package.json .

{
  "dependencies": {
    "workbox-build": "^7.1.1",
    "workbox-cli": "^7.1.0",
    "workbox-precaching": "^7.1.0"
  }
}

All of the above should only be in the dev environment, as there's no need for them in production. The service worker must remain very low-level, avoiding the use of new JavaScript features, and should be completely isolated and independent.


After creating the package.json file, you can install it using bun i, npm i, or similar commands. Personally, I recommend using bun.sh. At this point, you've set up nearly all the dependencies you need for the production environment. Now, it's time to transpile the sw.ts file into JavaScript at the appropriate location: priv/static/sw.js .


The first step is to find the def static_paths function in your project and add sw.js to its list. For example:

def static_paths,
  do: ~w(assets fonts images favicon.ico robots.txt sitemap.xml.gz sitemap-00001.xml.gz sw.js)

This allows you to, for example, serve this file directly from your domain, like https://mishka.tools/sw.js . Try not to change the path, as doing so can lead to a multitude of errors related to CSP settings and the service worker itself. While you may have access to new features, the core nature of service workers remains based on older structures, including strict mode.


At this point, you've defined the path, but now you need to set up a build process. First, open the config.exs file and find the settings related to esbuild. For example, in the latest version of Phoenix, it looks something like this:

config :esbuild,
  version: "0.17.11",
  mishka: [
    args:
      ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
    cd: Path.expand("../assets", __DIR__),
    env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
  ],
  sw: [
    args:
      ~w(js/sw.ts --bundle --target=es2017 --outfile=../priv/static/sw.js --platform=browser --minify --define:process.env.NODE_ENV="production"),
    cd: Path.expand("../assets", __DIR__),
    env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
  ]

The difference compared to your web application setup is that I've added a new list with the key sw, which compiles the sw.ts file, targeting ECMAScript 2017 , and outputs it to the desired location along with other arguments.


Our dev environment still doesn't support real-time changes yet. So far, we've just defined the development setup. Since we want to avoid running the build process in production, there's no need to go into mix.exs and add a lot of options. However, we will add a few aliases for the development environment to make things easier, which we'll cover next. Now, open the dev.exs file in the project's config folder.

config :mishka, MishkaWeb.Endpoint,
  # Binding to loopback ipv4 address prevents access from other machines.
  # Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
  http: [ip: {127, 0, 0, 1}, port: 4000],
  check_origin: false,
  code_reloader: true,
  debug_errors: true,
  secret_key_base: "Your Token",
  watchers: [
    esbuild: {Esbuild, :install_and_run, [:mishka, ~w(--sourcemap=inline --watch)]},
    tailwind: {Tailwind, :install_and_run, [:mishka, ~w(--watch)]},
    esbuild_sw: {Esbuild, :install_and_run, [:sw, ~w(--sourcemap=inline --watch)]},
    esbuild_sw:
      {Esbuild, :install_and_run,
      [:sw, ~w(--watch --minify --define:process.env.NODE_ENV="production")]}
  ]

If you want to see changes in real time, add another key to this list named esbuild_sw . Just adding this key is enough. Essentially, with the --watch mode, any change you make in sw.ts will automatically be transformed into sw.js , along with a source map. We don't need a source map in production, so before each Git commit, run the mix command that we are about to create. I know it’s a bit manual, but you can automate all of this if you prefer. Since we didn' t want to modify the Dockerfile , we adopted this approach.


Open the mix.exs file and follow these instructions. Note that we're only showing the newly added elements, so be careful not to accidentally remove your existing aliases.

defp aliases do
  [
    "assets.sw.build": ["esbuild sw"],
    "assets.sw.deploy": [
      "esbuild sw --minify"
    ]
  ]
end

It’s that simple. Before committing to Git, you only need to run one or both of these commands

mix assets.sw.build

mix assets.sw.deploy


Now, why do we need to run mix assets.sw.deploy if we're already building on each change in developer mode? This command helps you produce a production-ready version, removing the source map at the end of the file, which isn't needed in production.


And the final step is simply to add sw.js to the lib/yourapp_web/components/layouts/root.html.heex file so that it gets called when the project loads.


Why not add it to app.js? You could do that, but due to the CSP (Content Security Policy) settings applied to our website, it made things a bit challenging, so we decided to place the code here.

<script>
  if ("serviceWorker" in navigator) {
    window.addEventListener("load", () => {
      navigator.serviceWorker
        .register("/sw.js", { scope: '/' })
        .then((registration) => {
          console.log(
            "Service Worker registered with scope:",
            registration.scope,
          );
        })
        .catch((error) => {
          console.error("Service Worker registration failed:", error);
        });
    });
  }
</script>

That's it! Now open your project, go to the Network tab, and you'll see that all images are downloaded the first time. But after refreshing the page, you'll notice that next to each image, it says service worker , meaning these images are no longer being downloaded.


Enabling CSP

You might also want to enable CSP (Content Security Policy) on your website. Here are all the headers that we implemented based on our needs. You can modify them as necessary. Please note that if you apply these settings, you'll need to adjust the script tag accordingly, for example:

<script nonce={assigns[:script_csp_nonce]}>

Make sure to change the module names and domains in this file to suit your own setup. You should also call this file in your router—simply plug Mishka.Plugs.CSPPolicy . This is how we've implemented CSP in the Phoenix framework.


It's worth mentioning that these settings have been thoroughly implemented and tested for all Phoenix and LiveView components created by Mishka, and you can easily use them in both Phoenix and LiveView.

defmodule Mishka.Plugs.CSPPolicy do
  @moduledoc false
  import Plug.Conn

  def init(options), do: options

  def call(conn, opts) do
    conn
    |> put_csp(opts)
  end

  def put_csp(conn, _opts) do
    [style_nonce, script_nonce] =
      for _i <- 1..2, do: 16 |> :crypto.strong_rand_bytes() |> Base.url_encode64(padding: false)

    conn
    |> put_session(:style_csp_nonce, style_nonce)
    |> put_session(:script_csp_nonce, script_nonce)
    |> assign(:style_csp_nonce, style_nonce)
    |> assign(:script_csp_nonce, script_nonce)
    |> put_header(script_nonce, style_nonce)
  end

  if Application.compile_env(:mishka, :dev_routes) do
    defp put_header(assign, script_nonce, style_nonce) do
      assign
      |> put_resp_header(
        "content-security-policy",
        "default-src 'self'; script-src 'nonce-#{script_nonce}'; " <>
          "style-src 'self' 'nonce-#{style_nonce}'; style-src-elem 'self' 'nonce-#{style_nonce}'; " <>
          "img-src 'self' data: blob:; object-src 'none'; font-src data:; connect-src 'self'; frame-src 'self'; " <>
          "worker-src 'self'"
      )
    end
  else
    defp put_header(assign, script_nonce, style_nonce) do
      assign
      |> put_resp_header(
        "content-security-policy",
        "default-src 'self'; script-src 'nonce-#{script_nonce}'; " <>
          "style-src 'self' 'nonce-#{style_nonce}'; style-src-elem 'self' 'nonce-#{style_nonce}'; " <>
          "img-src 'self' data: blob:; object-src 'none'; font-src data:; connect-src 'self'; frame-src 'none'; " <>
          "worker-src 'self'; base-uri https://mishka.tools;"
      )
    end
  end
end



If you enjoyed this article, please feel free to share it on social media.

Support Our Work

98% of Mishka's products are open source, and we need your support to keep growing stronger.

Every contribution makes a difference and is deeply appreciated! ❤️