Service worker rendering, in the cloud and in the browser

tl;dr

This site is now rendered entirely on-demand via service workers!

The first time you visit, you'll get HTML rendered in the cloud using Cloudflare Workers. For each subsequent page you visit, a local in-browser service worker generates equivalent HTML, taking advantage of local caching to render as much HTML as possible without blocking on the network.

Both service worker environments share the majority of the same Workbox code for routing and streaming response generation.

You can see everything that changed from my previous setup in this diff.

Previously, on blog infra

This site previously used a custom 11ty setup, with static HTML generated at build time, using Nunjucks templates and Markdown pages. (It was migrated from a long-ago Jekyll config.)

After your first visit, a Workbox-powered service worker took over, and cached all of the templates and page data. HTML for future navigations are generated by the SW, independent of the network, using the same templating logic that 11ty uses at build time.

The templates were cached independently from the page content, so I could, e.g., update the site header template, and that's the only thing that needs to be invalidated—already cached content could stay as-is.

It was served from Firebase static hosting.

This all worked well!

But, the disconnect between how 11ty and the service worker generated the HTML bothered me. They shared templates, but static HTML "routing" happens via file system layout, while the service worker needed its own independent routing configuration. Also, running Nunjucks inside of a service worker works, but it's not exactly elegant, or lightweight. And generating the site's full HTML at build time is fine for a site with a modest amount of content, but I could see it being an issue for larger sites, or for sites that rely on database or API lookups to populate their HTML.

Service workers everywhere

I've been fascinated by the new crop of cloud runtime environments that expose a service worker API (or some modified version of it). Cloudflare Workers seems like the most mature of them, and it looks like the recently formed Deno Company is working on something similar. Running the service worker API in the cloud opens the door for a closer sharing routing and HTML generation logic with the browser's service worker—isomorphic rendering, if you will. (It's cool if you won't.)

A recent tweet from Luke Edwards about his new, lightweight Handlebars-compatible templating library, Tempura, reminded me that Luke was now working on the Cloudflare DevRel team, and that I have been intending to explore this space further.

Well, no time like this weekend!

Build setup

Some of the work involved swapping out the 11ty build infrastructure in favor of a bespoke build process. At some point I might re-add 11ty, because I ultimately think that I just ended up recreating most of what it already does. But while prototyping this, I needed the level of control offered by generating exactly the build artifacts I wanted from my source Markdown files.

The nice thing is that, for the most part, my existing posts didn't need to change—they remain Markdown documents with some frontmatter metadata, just like 11ty (and before that, Jekyll) expected.

Shared service worker code

Most of the magic takes place in the shared service worker code, which handles routing using a wrapper on top of the upcoming URLPattern API, along with some logic to handle requests for static assets like images, CSS, JS, or JSON files.

Streaming, sequential templates

Each matching route triggers a sequence of Tempura templates, with each rendered template streaming its partial HTML immediately in environments that support constructing ReadableStreams, courtesy of the workbox-streams library.

Here's an adapted snippet of code:

registerRoute(
  new URLPatternMatcher({pathname: '/(.*).html'}).matcher,
  streamingStrategy(
    [
      () => Templates.Start({site}),

      async ({event, params}) => {
        const post = params.pathname.groups[0];
        const response = await loadStatic(event, `/static/${post}.json`);
        if (response?.ok) {
          const json = await response.json();
          return Templates.Page({site, ...json});
        }
        return Templates.Error({site});
      },

      () => Templates.End({site}),
    ],
    {'content-type': 'text/html'},
  ),
);

This initial HTML response always comes from the first template immediately, without blocking on any data retrieval, so you should see consistently fast renders of each page's header. What's displayed right away is roughly equivalent to what you'd see if you were using an App Shell.

Unlike with an App Shell approach, it's easy to set up a completely different sequence of templates for different routes. You're not forced to always respond with a single, hardcoded placeholder HTML document.

Different approaches to static assets

The some significant difference between the CloudFlare and browser service workers is how they load static assets.

The CloudFlare Workers runtime supports loading static assets via a kv-asset-handler helper library.

Here's an abbreviated snippet of that code:

const loadStatic = async (event, urlOverride) => {
  const options = urlOverride
    ? {
        mapRequestToAsset: (request: Request) => {
          const absoluteURLString = new URL(urlOverride, request.url).href;
          return mapRequestToAsset(new Request(absoluteURLString, request));
        },
      }
    : {};

  return await getAssetFromKV(event, options);
};

The browser runtime relies on Workbox's caching and routing to load those assets. A stale-while-revalidate strategy ensures that the service worker can render all the content quickly if you've previously visited the same page.

Workbox's BroadcastUpdatePlugin will notify any open window clients when an update is found for cached content during the revalidate step. I took the blunt-force approach of reloading the entire page when this happens, but a more nuanced approach would involve showing a message on the screen, prompting the user to reload if they would like to see new content.

You end up with code like:

const swrStrategy = new StaleWhileRevalidate({
  cacheName: 'static',
  plugins: [new BroadcastUpdatePlugin()],
});

const loadStatic = async (event, urlOverride) => {
  return await swrStrategy.handle({
    event,
    request: urlOverride || event.request.url,
  });
};

An alternative would be to use Workbox's build tools to generate a precache manifest of all the JSON files needed to render every page on the site. (This is what I've done with previous iterations of this blog.) I decided to go with runtime caching approach instead, in the interest of minimizing the amount of data transferred during service worker installation. This means that you can't navigate to every page on this blog while offline—only to the pages you've previously visited. If making the entirety of your site work offline after the first visit is important to your use case, precaching everything will let you do that!

What's next?

Unlike some of my previous efforts in this space, I'm pretty confident that this setup isn't too out there. It seems like web developers are comfortable with embracing service workers in the cloud, and Workbox has been going strong for over six years now.

Moreover, while I implemented this architecture on blog site with Markdown files, this same approach could apply equally well to a site that relied on database or API calls to populate each page's content. The only thing that needs to change is the logic that retrieves data to populate each page's template. And because that logic only needs to be written in one place, inside the shared service worker code, you don't have to worry about it getting out of sync between the cloud and browser.

I'm excited to see if more folks use my current setup as inspiration, and am happy to chat with anyone interested in turning this into a reusable started kit!