Skip to main content

By Tom Davies

Integrating Partytown with Craft CMS, Google Tag Manager, & Cloudflare Workers

I was recently tasked with investigating improvements to the front-end performance of one of our clients’ Craft CMS applications, both in terms of PageSpeed Insights metrics, as well as subjective performance.

Outside of production, the app’s Lighthouse performance scores were already very good, and it quickly became obvious that what was slowing the site down was 3rd-party marketing and analytics JavaScript that had been added to the site, in this case via Google Tag Manager (GTM).

Historically, the impact of 3rd-party JavaScript on performance has been a near impossible problem to to solve, outside of educating with clients to flag the impact and working to reduce the amount of it on a page. In this case though, reducing the number of scripts wasn’t going to be an option, so instead we looked to a library that we’ve been monitoring the progression of for a little while, but hadn’t yet used: Partytown.

What Is Partytown and how does it work?

From the Partytown website:

Partytown is a lazy-loaded library to help relocate resource intensive scripts into a web worker, and off of the main thread. Its goal is to help speed up sites by dedicating the main thread to your code, and offloading third-party scripts to a web worker.

Sounds like just what we need! So, join me on on a (semi) epic quest as we integrate Partytown, Craft and GTM, with a little help from Cloudflare Workers and some appropriate music.

Caveat Developer…

Ok, just one thing before we go Loco in Acapulco: It’s important to know that using Partytown definitely comes with some trade-offs. It’s not a one-size fits all solution and it’s definitely not “plug-and-play”.

Some popular marketing tags like Hotjar straight up won’t work with Partytown. Even if all the tags you want to use do work, it’s probably only suitable for projects where you have a close enough relationship with whoever will be configuring GTM that you both educate them about the changes required and can work together to update the library’s config when new tags are added. Talk to your web developer about whether Partytown is right for you.

Implementation

1. Require the Partytown Library

This part is easy 🙂

npm install @builder.io/partytown

2. Vite Integration

Partytown has two parts: a snippet that we need to inline in the document <head> and a bundle of JS files which that snippet will load in turn. Here at Zaengle, we use Vite for our front-end build because it’s super fast, flexible, and requires fairly minimal config. Handily, recent versions of Partytown ship with a built-in Vite integration that can help us with both parts:

// vite.config.js
import fs from 'node:fs'
import { partytownVite } from '@builder.io/partytown/utils'
import { partytownSnippet } from '@builder.io/partytown/integration'
// Generate the Partytown snippet each time we start Vite - we will inline this in our Craft layout later
fs.writeFileSync(path.join(__dirname, 'assets/js/partytown-snippet.js'), partytownSnippet())

export default defineConfig(({ command }) => ({
  // ...
  plugins: [
    // Tell the Partytown Vite plugin where to output the bundle
    partytownVite({
      dest: path.join(__dirname, 'web/dist/partytown'),
    }),
  ],
}))

We also want to ignore the paths we’re going to be writing from Vite in git, because as build artefacts they don’t want to get checked in:

# .gitignore
assets/js/partytown-snippet.js
web/dist/partytown/

3. Getting Partytown to Work with Common Scripts

So far, so simple. Things are about to get a little more complicated, however. GTM works by asynchronously loading scripts (“Tags” in GTM parlance) into the page.

From the Partytown docs again (emphasis added):

Often third-party scripts are added to the page by appending a script tag.

When the <script> element is appended to the <head> using this traditional approach, the script’s HTTP response does not require Cross-Origin Resource Sharing (CORS) headers.

However, because Partytown requests the scripts within a web worker using fetch(), then the script’s response requires the correct CORS headers.

Many third-party scripts already provide the correct CORS headers, but not all do.

Turns out that some of the most popular GTM tags (looking at you Facebook Pixel) do not provide the correct CORS headers. So what we need to do is use a proxy server to add the headers to the response for us.

While we absolutely could write a proxy server in Craft, doing so would likely be counter-productive: we’re trying to improve performance here, and having our application servers proxy a large number of requests to 3rd party JS libraries isn’t a great idea when we want them to be free to build and serve our site’s HTML as far as possible. Instead, this looks like a case for… serverless* functions!

* Yes, just as “there is no Cloud, only other people’s computers” there are obviously still servers involved in running "Serverless" functions, but the main thing we care about here is that those servers are not our application servers, and the code is being run somewhere where we don’t have to worry about provisioning a server and keeping it running.

3.1 Deploying a CORS Proxy with Cloudflare workers

There are a ton of options out there for running functions in the cloud. We’re going to use Cloudflare workers, as the site in question was using Cloudflare already, and Cloudflare Workers are both highly performant and have a very generous 100,000 requests each day for free.

Cloudflare’s wrangler CLI makes it super easy to both scaffold and deploy a worker written in JavaScript. While you can create and edit workers via the Cloudflare dashboard, using wrangler to develop locally makes it easy to both check in our worker’s code into our main git repository (I used a workers/ subdirectory in the project root) and modify the worker locally and get immediate feedback:

npm install -g wrangler
# login 
wrangler login 
cd workers/
wrangler generate partytown-cors-proxy

Among the generated files we should now have a wrangler.toml:

name = "partytown-cors-proxy"
main = "src/index.js"
compatibility_date = "2023-07-24"
usage_model = "bundled"
env = { }

The only really important thing here is main = "src/index.js", which is the path to our worker itself:

/**
 * A CORS Proxy for Partytown
 *
 * - Run 'npm run start' in your terminal to start a development server
 * - Open a browser tab at http://localhost:8787/ to see your worker in action
 * - Run 'npm run deploy' to publish your worker
 *
 * Learn more at https://developers.cloudflare.com/workers/
 */


/**
 * The permitted URLs for the remote third party APIs you want to allow proxying of
 * @type {string[]}
 */
const PERMITTED_ENDPOINTS = [
  'www.google-analytics.com',
  'connect.facebook.net',
  'snap.licdn.com',
  'www.youtube.com',
]

/**
 * The CORS headers to apply to the response to a preflight request
 */
const CORS_HEADERS = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Methods': 'GET,HEAD,POST,OPTIONS',
  'Access-Control-Max-Age': '86400',
}

/**
 * isPermittedEndpoint()
 * 
 * Check if the request is going to a permitted API URL
 * @param {string} apiUrl
 * @returns {boolean}
 */
function isPermittedEndpoint(apiUrl) {
  const apiHostname = new URL(apiUrl)?.hostname
  return PERMITTED_ENDPOINTS.some(origin => apiHostname.match(origin))
}

/**
 * Respond to a proxy request, appending CORS headers
 * @param request
 * @param apiUrl
 * @returns {Promise<Response>}
 */
async function handleProxyRequest(request, apiUrl) {
  // Rewrite request to point to API URL. This also makes the request mutable
  // so that we can add the correct Origin header to make the API server think
  // that this request is not cross-site.
  const proxyRequest = new Request(apiUrl, request)
  proxyRequest.headers.set('Origin', new URL(apiUrl).origin)
  const proxyResponse = await fetch(proxyRequest)
  // Recreate the response so we can modify the headers

  const response = new Response(proxyResponse.body, proxyResponse)
  // Set CORS headers (could allow a specific domain instead of *)
  response.headers.set('Access-Control-Allow-Origin', '*')
  // Append to/Add Vary header so browser will cache response correctly
  response.headers.append('Vary', 'Origin')

  return response
}

/**
 * Handle OPTIONS requests
 * @param request
 * @returns {Promise<Response>}
 */
async function handleOptions(request) {
  const isCorsPreflightRequest = [
    'Origin',
    'Access-Control-Request-Method',
    'Access-Control-Request-Headers'
  ].every(field => request.headers.has(field))

  if (isCorsPreflightRequest) {
    return new Response(null, {
      headers: {
        ...CORS_HEADERS,
        'Access-Control-Allow-Headers': request.headers.get('Access-Control-Request-Headers'),
      },
    })
  }

  // Handle standard OPTIONS request.
  return new Response(null, {
    headers: {
      Allow: CORS_HEADERS['Access-Control-Allow-Methods'],
    },
  })
}

/**
 * Handle inbound requests
 */
export default {
  async fetch(request) {
    const apiUrl = new URL(request.url)?.searchParams.get('url')

    if (!apiUrl) {
      return new Response(null, {
        status: 400,
        statusText: 'The url parameter is required',
      })
    }

    if (!isPermittedEndpoint(apiUrl)) {
      return new Response(null, {
        status: 400,
        statusText: `${apiUrl} is not a permitted endpoint`,
      })
    }
    switch (request.method) {
      case 'OPTIONS':
        return handleOptions(request)
      case 'GET':
      case 'HEAD':
      case 'POST':
        return handleProxyRequest(request, apiUrl)
      default:
        return new Response(null, {
          status: 405,
          statusText: `${request.method} method not allowed`,
        })
    }
  },
}

This isn’t a tutorial on Cloudflare workers, so I’m going to mostly skip over the above and leave you to read the comments in the code instead. Suffice to say that to adapt it, you’ll probably want to start by customizing the PERMITTED_ENDPOINTS array, which accepts strings or regex patterns to match against the APIs that you want your proxy to permit access to.

We run our worker locally with npm run start and when we’re happy we deploy it to Cloudflare with npm run deploy.

4. Wiring up Craft to Partytown: Part 1

Now that we’ve got Vite outputting some scripts, and a proxy server running to add CORS headers when needed, we need to wire up Craft and Partytown and provide some configuration to make everything work.

4.1 Craft CMS Config

It’d be useful to be able to turn Partytown on or off in production quickly without running a deploy, just in case we hit any unexpected problems. To do this we can use a couple of environment variables piped through Craft’s custom config file:

Settings defined in a config/custom.php file don’t map to or affect any built-in Craft features, but can be useful to centralize data, flags, or secrets that otherwise don’t have a place to live.

# .env
PARTYTOWN_ENABLED=true
# The URL to our deployed Cloudflare worker (or our local development instance)
PARTYTOWN_PROXY_URL=https://my-proxy.workers.dev/
<?php
// config/custom.php
use craft\helpers\App;

return [
    'partytown' => [
        'isEnabled' => App::parseBooleanEnv('$PARTYTOWN_ENABLED') ?? false,
        'proxyUrl' => App::env('PARTYTOWN_PROXY_URL'), 
    ],
];

Now we can access craft.app.config.custom.partyTown.isEnabled in our templates. Huzzah!

4.2 Templating Part I: Configure Partytown and Include the Snippet

Time to use our new settings to add Partytown to our layout. We’re using nystudio107's Craft Vite plugin, so we’ll make use of its craft.vite.inline() method here, but any other method of inlining the snippet file would do:

{# _layout.twig #}
<!doctype html>
<html lang="en-US">
<head>
...
  {% if craft.app.config.custom.partytown.isEnabled %}
    <script>
      window.partytown = {
        debug: {{ devMode | json_encode }},
        // see "Making GTM tags work below"
        forward: [
          'dataLayer.push',
          'fbq',
          '_hsq.push',
        ],
        // this is the path we set in the vite plugin, absolute from web root
        lib: '/dist/partytown/',
        resolveUrl: function(url) {
          if ([
              'www.google-analytics.com',
              'connect.facebook.net',
              'snap.licdn.com',
              'www.youtube.com',
          ].includes(url.hostname)) {
            // use our proxy server for hostnames that need it
            const proxyUrl = new URL('{{ craft.app.config.custom.partytown.proxyUrl }}');
            proxyUrl.searchParams.append('url', url.href);
            return proxyUrl;
          }
          return url;
        },
      }
      // inline the snippet that we generated in Vite
      {{ craft.vite.inline('@root/assets/js/partytown-snippet.js') }}
    </script>
  {% endif %}
...
</head>

In addition to inlining our snippet, we’re also setting a window.partytown global where we provide Partytown with some runtime options:

  1. debug does what you would expect - here we’re shadowing Craft’s devMode setting
  2. forward allows us to provide an array of string object paths that map to properties on window that would be forwarded to Partytown’s web worker when called.
  3. lib provides the path in web root where we’re outputting Partytown’s JS bundle from Vite
  4. resolveUrl allows us to provide a function that will modify request URLs before Partytown fetches them. We check for one of our hostnames that needs CORS headers adding, and routing those requests via our Cloudflare Worker proxy, passing the original url as a parameter.

4.2 Templating Part II: Set type="text/partytown" on script tags and replace the SEOmatic Google Tag Manager embed

By default, Partytown doesn’t make any changes to how scripts are loaded and run. Instead we need to opt-in by setting type="text/partytown" on the script tags we want to run via Partytown. This is very much a “feature not a bug”, as it means we can incrementally adopt Partytown across an application, and if we have issues using it for a particular script, we can just load it conventionally. Generally then, it’s just a case of incrementally adding the above type attribute to script tags and testing to confirm that everything works as expected, adding additional config to window.partytown.forward[] and your window.partytown.resolveUrl() as required.

If your app currently inserts Google Tag Manager using the omni-popular SEOMatic Plugin, you’ll find you need to do an extra step, however. That’s because while SEOmatic allows us to edit the contents of the GTM embed script, it inserts the <script> tag itself in a manner whereby we can’t append the type attribute to it. To work around this, you can either disable Google Tag Manager under “Tracking Scripts” in the plugin settings, or leave it enabled but use a Twig comment to disable the output of the Script Template field.

Then you can manually add the following as the last thing before your closing </head> tag:

{% if
  seomatic.config.environment == 'live'
  and not seomatic.helper.isPreview
  and getenv('GTM_CONTAINER_ID')
%}
  <script type="text/partytown">
    window.dataLayer = window.dataLayer || [];
    window.dataLayer.push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' });

    var s = document.createElement('script');
    s.src = 'https://www.googletagmanager.com/gtm.js?id={{ getenv('GTM_CONTAINER_ID') }}';
    s.async = 1;
    s.type='text/partytown';
    var m = document.getElementsByTagName('script')[0];
    m.parentNode.insertBefore(s,m);

  </script>
{% endif %}

Note that this snippet assumes that:

  1. Your GTM container ID can be found in a $GTM_CONTAINER_ID environment variable
  2. You haven’t customised the name of GTM’s window.dataLayer variable

Results and Conclusion

🥳 If you’ve been playing along and made it this far, you should now have a working Partytown installation. Slip off your shoes and pour yourself a (virtual) Piña Colada. Welcome to Partytown!

But… was all that work worth it…? In our case, yes absolutely. Diving into the site’s Lighthouse Mobile Performance scores, we went from a workman-like 76 to a very respectable 92.

Diving deeper into the Lighthouse results we can see exactly why:

  1. Reduction in Total blocking time from 100ms to 0ms
  2. Speed Index improved from 6.7 s to 3.3 s
  3. An 88% reduction in Javascript downloaded and executed on the main thread. From 1.77MiB to 223KiB. It’s worth noting that all that extra JavaScript is still being downloaded and run in the background, which is not ideal, but it’s at least being done in a way that minimises the impact on user experiences.
  4. The removal of 4 long main thread JS tasks (a major source of input delay) totalling around 300ms of execution time

Want to read more tips and insights on working with a website development team that wants to help your organization grow for good? Sign up for our bimonthly newsletter.

By Tom Davies

Senior CMS Developer x 10

When Tom's not coding or playing legos with his kids, he's on a long-term quest to bake the perfect sourdough.