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.
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.
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.
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.