MonkeyLoader All articles
Infrastructure

Forty Lines and a Promise: Swapping a Tag Manager for a Consent-Aware Script Loader

MonkeyLoader
Forty Lines and a Promise: Swapping a Tag Manager for a Consent-Aware Script Loader

Photo: https://www.linuxconsole.org, GPL, via Wikimedia Commons

A marketing site I inherited last year loaded 310 KB of third-party JavaScript before anyone had read a word of copy. Nine script requests, four origins, and a container file that existed mainly to load other files that existed mainly to load other files. The tag manager was doing its job. Its job was just much bigger than ours.

So we replaced it. Not with a better tag manager — with about forty lines of JavaScript and a JSON-shaped config. Here's what that looked like, including the consent gate, because in the UK that's the bit you can't skip.

First, be honest about what you're actually using it for

Before you delete anything, export the container and inventory every tag. On our site, of eleven tags, four were dead (vendors we'd stopped paying), three were duplicated pixel variants from an agency handover, and exactly four were live. That's a common ratio.

Then write down the features you genuinely depend on. Ours came down to five:

  1. Load scripts in a defined order — the heatmap needed the analytics library on the page first.
  2. Load them without blocking the HTML parser.
  3. Gate them by consent category, and re-run everything when consent changes.
  4. Isolate failures — one blocked vendor must not stop the others.
  5. Let someone other than a developer add a new vendor without a redeploy.

Point five is the real reason people keep tag managers, and it's worth being upfront about the trade. We solved it with a config block in the repo and a review step, which is slower than a marketing manager clicking publish and considerably faster than a Friday afternoon incident review.

The loader

This is deliberately unglamorous. No classes, no build step, no dependencies.

(function () {
  var SCRIPTS = [
    { id: 'analytics', src: '/js/analytics.js', category: 'analytics', timeout: 4000 },
    { id: 'heatmap', src: 'https://cdn.example.com/hm.js', category: 'analytics', after: 'analytics', timeout: 6000 },
    { id: 'ads', src: 'https://cdn.example.com/ads.js', category: 'marketing', timeout: 6000 }
  ];

  var state = {}; // undefined | 'pending' | true | 'failed'

  function permitted(cat) {
    return !!window.__consent && window.__consent[cat] === true;
  }

  function load(spec) {
    if (state[spec.id] || !permitted(spec.category)) return;
    if (spec.after && state[spec.after] !== true) return;

    state[spec.id] = 'pending';
    var el = document.createElement('script');
    el.src = spec.src;
    el.async = true;

    var timer = setTimeout(function () { fail(spec, 'timeout'); }, spec.timeout || 5000);
    el.onload = function () { clearTimeout(timer); state[spec.id] = true; flush(); };
    el.onerror = function () { clearTimeout(timer); fail(spec, 'network'); };

    document.head.appendChild(el);
  }

  function fail(spec, reason) {
    state[spec.id] = 'failed';
    if (!spec.retried) {
      spec.retried = true;
      setTimeout(function () { state[spec.id] = undefined; flush(); }, 1500);
    } else if (window.console) {
      console.warn('[loader] ' + spec.id + ' failed: ' + reason);
    }
    flush();
  }

  function flush() {
    for (var i = 0; i < SCRIPTS.length; i++) {
      var spec = SCRIPTS[i];
      if (!state[spec.id]) load(spec);
    }
  }

  window.addEventListener('consentchange', flush);
  flush();
})();

That minifies and gzips to comfortably under 2 KB — nearer 800 bytes in practice, since the config is the only variable part. It does the five things on the list and nothing else. There is no debugging UI, no tag templates, no version history. Those are real losses and I'll come back to them.

The consent gate, done properly

PECR is not subtle about this. Storing or reading information on someone's device — cookies, localStorage, fingerprinting, most pixels — requires consent unless it's strictly necessary for the service the user asked for. Analytics is not strictly necessary. Advertising pixels are definitely not. The ICO has been clear for years that implied consent from a banner that just sits there doesn't count, and that rejecting must be as easy as accepting.

What that means for the loader:

The pleasant side effect: because everything funnels through one function, you can log exactly what fired, what was blocked and why. That's a better audit trail than any tag manager gives you.

Failure handling is the part everyone forgets

Third-party CDNs go down. Ad blockers eat requests. Corporate proxies on council-issued laptops mangle things in mysterious ways. The loader's job is to make sure one dead vendor doesn't take the rest with it — hence the timeout, the single retry, and the fact that onerror never throws.

We also added a debug switch: append ?loader=debug and the script prints a table of what loaded, what was blocked by consent, what timed out and what failed. Two afternoons of work; it has paid for itself every time a marketer has asked why a campaign pixel "isn't showing up".

Set a budget, then prove the swap

A performance budget is only useful if it's small enough to be visible on a chart. Ours, for a static marketing site:

Metric Budget
Total JS transferred (gzip) ≤ 60 KB
Third-party origins on first load ≤ 2
Third-party JS as share of total < 25%
LCP on Slow 4G ≤ 2.5 s
INP (75th percentile) ≤ 200 ms

Measure before and after with the same tools: WebPageTest from a London node for the lab numbers, Lighthouse CI in the build for a regression gate, and a small web-vitals beacon for the field data. Ours, illustratively, went from nine script requests to three, 310 KB of third-party JS to 48 KB, and total blocking time on a throttled mid-range Android from around 1.2 s to about 380 ms. Your numbers will differ — measure your own, on the same device, in the same week, or you'll be comparing apples to a nice photo of some pears.

What you're giving up

No self-service publishing for marketing. No tag templates that handle a vendor's quirks for you. No built-in preview mode or version history. And when a vendor changes their snippet, someone has to paste it into a pull request instead of a text box.

In practice, on a site of this size, those trade-offs were worth it. The container was already the single most expensive thing in the page load, and nobody could name half of what it contained. A config file in version control with a reviewer attached is not a downgrade — it's just a different kind of friction, and it happens to leave a paper trail.

Rolling it out sensibly

Do the inventory first, not the code. Map each existing tag to a consent category before you write a single line. Ship the loader to staging with the old container still installed but no longer called, compare the network waterfall side by side, then watch field data for a fortnight before deleting anything for good.

All Articles

Related Articles

The Wrapper, the Pin and the Kill Switch: Extending Vendor Loaders You Cannot Fork

The Wrapper, the Pin and the Kill Switch: Extending Vendor Loaders You Cannot Fork

The Long Weekend Release Drill: Ship on Friday, Sleep on Monday

The Long Weekend Release Drill: Ship on Friday, Sleep on Monday

Roll Your Own Feature Flags: A Two-Person Team's Case for Ditching the Flag SaaS

Roll Your Own Feature Flags: A Two-Person Team's Case for Ditching the Flag SaaS