Deferring Third-Party Scripts Without Breaking Analytics

You added defer to the analytics snippet, Total Blocking Time fell by 450 ms — and within a day the vendor dashboard was reporting 12% fewer pageviews, no click events at all on fast bounces, and a run of gtag is not defined errors from the inline code that used to work.

Root cause: the snippet is two programs, and only one of them may be deferred

Almost every tag snippet you are handed is a concatenation of two unrelated programs. The first is a command queue stub: three or four hundred bytes that define a global function, push its arguments into an array, and stamp a start time. The second is a transport: a document.createElement("script") insertion that fetches 60–120 KB of vendor library which, on execution, drains the array and starts sending. The stub costs about 0.3 ms of main-thread time and issues no request. The transport is the entire cost you are trying to move.

Putting defer on the combined snippet defers both. The stub is no longer available at its parse position, so any first-party call made while the parser is still walking the body hits an undefined global. That is not a silently dropped hit — it is a ReferenceError, and an uncaught error terminates the remaining statements in the inline script that raised it, which is why one missing tracker frequently takes an unrelated feature down with it. Deferral also stacks two queues on top of each other: the deferred list is a single-file queue that only drains after parsing finishes and after every style sheet blocking scripts has arrived, and the injected library then inherits force-async semantics, so its execution point is arrival order rather than anything you controlled. The ordering consequences of that second queue are dissected in fixing async script race conditions.

The third mechanism is the one that costs the most data. Hits buffered inside the page die with the document. A user who taps through at 900 ms takes the whole queue with them, and no retry exists because the code that would retry never ran. Legacy loaders make this worse: a snippet built on document.write behaves acceptably as a parser-blocking script and catastrophically once deferred, because after parsing ends document.write implies document.open() and wipes everything already rendered — and Chromium additionally ships an intervention that refuses to execute a parser-blocking, cross-origin script injected by document.write on slow connections, so on exactly the devices you were optimising for, the tag simply never loads.

A deferred tag snippet on Slow 4G: the first hit reaches the collector at 1,065 ms Three horizontal lanes over a 0 to 1200 millisecond axis. The main thread lane parses HTML until 260 ms, runs the deferred snippet at 640 ms and initialises the vendor library at 980 ms. The network lane shows the document, a 6 KB loader fetched early, then a 92 KB vendor library that only starts at 640 ms because its URL is discovered when the snippet executes. The analytics hits lane shows three events at 180, 520 and 900 ms marked as dropped and one pageview at 1,065 ms delivered. One deferred tag snippet on Slow 4G: the first hit lands at 1,065 ms Three first-party events fire before the command queue exists — all three are lost. Main thread Network Analytics hits parse HTML parser free 260 ms · deferred snippet runs 640 ms · library initialises 980 ms HTML loader.js 6 KB GET vendor lib 92 KB the library URL is only discovered when the snippet executes — one extra round trip hero_view 180 ms · cta_click 520 ms · exit 900 ms dropped; pageview 1,065 ms sent 0 200 400 600 800 1000 1200 ms

Read the gap between 0 ms and 1,065 ms as the page’s blind window. It is not one delay but four stacked: 260 ms of parsing, 380 ms waiting for the deferred list to drain behind the first-party bundle and a pending style sheet, 340 ms of round trip and download for a library whose URL nothing could discover earlier, and 85 ms of compile and execute. Only the last two are network or CPU problems. The first two are queueing, and queueing is what the fix removes.

Minimal reproduction

Three tags and one inline call are enough to lose every event on a bounce. The markup below reproduces the failure exactly as it appears in production, including the first-party call that a template renders above the fold.

<!-- BROKEN. Scheduling rationale: `defer` moves the ENTIRE snippet into the
     list that runs after parsing, so the global it defines does not exist
     while the parser is still walking the body. Deferring the 0.3 ms stub
     buys nothing; only the 92 KB transport was ever worth deferring. -->
<script src="https://tag.vendor.example/loader.js" defer></script>

<h1>Pricing</h1>
<script>
  // Runs at its parse position, ~180 ms in — 460 ms before the deferred
  // snippet defines window.tag. ReferenceError, and because it is uncaught
  // the two statements after it never run either.
  window.tag('event', 'hero_view');
  document.body.dataset.heroSeen = '1';
</script>

To measure the loss rather than infer it, count the calls the page attempts against the requests the collector actually observes. This harness runs in the console of the unmodified page and needs no vendor cooperation:

// Scheduling rationale: hits are lost in the window between the first call
// site and the library's execution, so the useful number is not "how many
// hits were sent" but "how many were ATTEMPTED before a sender existed".
// Patching the global before the snippet loads makes that window visible.
let attempted = 0, deliveredAt = null;
const pending = [];
window.tag = function () { attempted++; pending.push(performance.now()); };

// sendBeacon and fetch are the two transports every vendor library uses.
const realBeacon = navigator.sendBeacon.bind(navigator);
navigator.sendBeacon = function (url, body) {
  if (deliveredAt === null) deliveredAt = performance.now();
  return realBeacon(url, body);
};

addEventListener('visibilitychange', () => {
  if (document.visibilityState !== 'hidden') return;
  // Anything still in `pending` at hide time is data that will never arrive:
  // the document is about to be discarded and nothing has flushed it.
  console.table({
    attempted,
    firstDelivery: deliveredAt === null ? 'never' : Math.round(deliveredAt),
    lostOnUnload: deliveredAt === null ? attempted
      : pending.filter((t) => t < deliveredAt).length,
  });
});

On the reproduction page, throttled to Slow 4G with a 120 ms round trip and dismissed at 900 ms, that prints attempted: 3, firstDelivery: never, lostOnUnload: 3.

The fix protocol

The snippet has a natural seam, and the whole protocol is a consequence of cutting along it. Everything that must be present stays inline and synchronous; everything that must be fetched becomes independent, low-priority and late. Between them sits a first-party array that belongs to you, not to the vendor, and that has two independent drains: the library when it arrives, and sendBeacon when the page is hidden.

Split the snippet at its seam: a 0.4 KB inline queue and a 92 KB library that may arrive late A left-to-right flow. An inline stub of 0.4 KB running at its parse position feeds a first-party hit queue held in memory. The queue has two drains: the vendor library, loaded async at Low priority and ready at 620 ms, and a sendBeacon flush fired on visibilitychange. Both deliver to the collector origin, where a preconnect saves one 120 ms round trip. Split the snippet at its seam: 0.4 KB that must be present, 92 KB that may be late Each hit is timestamped where it happens, so late delivery never means late data. 1. Inline stub 0.4 KB, 0.3 ms runs at its parse position, no fetch 2. Hit queue first-party array 3 hits by 900 ms each timestamped 3a. Vendor library async, Low priority drains at 620 ms 3b. Beacon flush on visibilitychange sendBeacon, 64 KB 4. Collector cross-origin POST preconnect saves one 120 ms RTT The stub is the only part that must be synchronous: 0.3 ms of main-thread work and no network request at all. Everything downstream of the queue may be late, retried, or blocked outright without losing a single event's timing.

Work the checklist in order. Steps 1 to 3 remove the ReferenceError class of failure, steps 4 and 5 move the bytes off the critical path, and steps 6 to 8 recover the hits that a bounce would otherwise take with it.

  • [ ] Find the seam. Read the vendor snippet and mark the boundary between the lines that define a global function plus array and the lines that build a <script> element. Everything before the boundary is the queue; everything after it is the transport.
  • [ ] Keep the stub inline, synchronous, and above the first stylesheet. A parser-inserted classic script cannot execute while a style sheet is blocking scripts, so an inline stub placed after <link rel="stylesheet"> inherits that sheet’s full download time.
  • [ ] Timestamp on push, not on send. Store performance.now() in the queue entry. Every downstream metric that involves event timing depends on this and on nothing else.
  • [ ] Load the library with async, never defer. It has no dependants in the document, so defer would only queue it behind your first-party bundle in the shared deferred list.
  • [ ] Add fetchpriority="low" and a preconnect to the collector origin. The demotion keeps the library off the wire while the LCP image is fetching; the hint overlaps the TLS handshake with parsing.
  • [ ] Replace any document.write loader. Insert an element with script.async = false instead, so the chain keeps insertion order without the intervention risk.
  • [ ] Flush on visibilitychange. Register the listener from the stub, not from the library — the whole point is that it works before the library exists.
  • [ ] Re-time the automatic pageview. Suppress the vendor’s own auto-hit and replay it from the queue with the recorded timestamp, or every session will start at the library’s arrival time.
  • [ ] Verify delivery rate, not blocking time. A change that halves Total Blocking Time and loses 3% of hits is a regression.

The stub is the load-bearing piece, and it is short enough to read in full:

<!-- Scheduling rationale: parser-inserted, classic, no src — it runs at its
     parse position for ~0.3 ms and defines the API every later call needs.
     Placed BEFORE the stylesheet link, because a classic script cannot run
     while a sheet is blocking scripts and would otherwise wait for it. -->
<script>
  (function (w) {
    w.npq = w.npq || [];
    w.tag = function () {
      // Timestamp here, not at send time: the library may execute 800 ms
      // later, and a hit stamped on arrival makes every funnel gap wrong.
      w.npq.push({ args: [].slice.call(arguments), t: performance.now() });
    };
    // Registered from the STUB so a bounce before the library arrives is
    // still delivered. sendBeacon survives document unload; fetch does not.
    addEventListener('visibilitychange', function () {
      if (document.visibilityState !== 'hidden' || !w.npq.length) return;
      var body = JSON.stringify({ nav: performance.timeOrigin, hits: w.npq });
      if (navigator.sendBeacon('/collect', body)) w.npq.length = 0;
    });
  })(window);
</script>
<!-- The collector, not the library host: the beacon above can fire long before
     the library exists, and this hint is what stops it paying a cold DNS
     lookup plus TLS handshake at the worst possible moment, page unload. -->
<link rel="preconnect" href="https://collect.vendor.example" crossorigin>
<link rel="stylesheet" href="/css/app.css">

Note the ordering in that block. The stub sits above both <link> elements, so it cannot be delayed by a pending style sheet, and the preconnect sits above the stylesheet so the handshake starts in the same round trip window rather than after it. Reversing either line costs measurable time and nothing gains from it.

The transport then becomes a single ordinary tag with no ordering requirements at all:

<!-- async, not defer: this file has no dependants in the document, so the
     deferred list would only make it wait behind the first-party bundle.
     fetchpriority=low keeps it behind the LCP image in the scheduler queue;
     it does not change WHEN it executes, only when its bytes arrive. -->
<script src="https://tag.vendor.example/lib.js" async fetchpriority="low"></script>

Where a vendor loader insists on chaining several files, replace its document.write with an in-order insertion so the chain keeps its sequence without a parser dependency:

// Scheduling rationale: createElement sets the force-async flag, so these
// would race and a plugin could execute before the core it patches. Assigning
// to the .async IDL attribute clears the flag and moves both elements into the
// in-order list: parallel fetches, sequential execution, insertion order kept.
function loadChain(urls) {
  const frag = document.createDocumentFragment();
  for (const url of urls) {
    const s = document.createElement('script');
    s.src = url;
    s.async = false;            // NOT the same as omitting the assignment
    frag.appendChild(s);
  }
  document.head.appendChild(frag);   // one insertion keeps the chain contiguous
}

Finally, drain the queue once the library announces itself, replaying the recorded timestamps rather than the current clock. Vendors that expose a ready callback make this a two-line adapter; those that do not will accept a queue array on their own global, which is the interface the stub was imitating in the first place. If the tag in question is a widget rather than a collector, the cheaper answer is usually not to load it at all until interaction — see replacing third-party embeds with facades.

Before and after

Measured on the same pricing page, Slow 4G with a 120 ms round trip, cold cache, 10,000 sampled sessions per configuration. “Delivered” counts hits the collector acknowledged divided by hits the page attempted.

Metric Blocking inline snippet Whole snippet deferred Stub + async library + flush
Total Blocking Time 640 ms 180 ms 190 ms
First Contentful Paint 1,340 ms 890 ms 880 ms
Command global available 40 ms 640 ms 40 ms
First hit at the collector 410 ms 1,065 ms 470 ms
Hits delivered 99.1% 86.5% 99.6%
Hits lost on a 900 ms bounce 0% 100% 0%
load event 1,720 ms 1,290 ms 1,300 ms
Hit delivery versus Total Blocking Time across four loading strategies Four horizontal bars showing the share of hits delivered. A blocking inline snippet delivers 99.1 percent at 640 ms of Total Blocking Time. Deferring the whole snippet drops delivery to 86.5 percent for 180 ms. An inline stub with an async library reaches 97.2 percent for 190 ms, and adding the beacon flush reaches 99.6 percent for the same 190 ms. Only the split configuration buys the blocking time without paying in lost hits Slow 4G, 120 ms RTT, 10,000 sampled sessions per strategy. share of hits delivered TBT 1. Blocking inline 99.1% delivered 640 ms 2. Snippet deferred 86.5% delivered 180 ms 3. Stub + async lib 97.2% delivered 190 ms 4. Stub + flush 99.6% delivered 190 ms 0 25 50 75 100%

One caveat before reading the chart: delivery rate is only meaningful against a fixed denominator. If the change also alters how many hits the page attempts — because a suppressed auto-pageview no longer fires, or because a listener that used to throw now runs to completion and emits two more events — the percentage moves for reasons that have nothing to do with scheduling. Freeze the call sites first, ship the loading change second, and compare cold-cache runs only, since a warm bytecode cache pulls the library’s execution 60 to 80 ms earlier and flatters row three.

Two readings matter. Row three shows that the stub alone recovers most of the loss but not all of it: the remaining 2.8% is bounces that leave before the library has drained the queue, which is exactly the gap the beacon flush in row four closes at zero additional blocking time. And row one is the honest baseline — a blocking snippet was never bad at delivering data, only at scheduling. Once you separate those two jobs you stop trading one against the other. Tracking that trade across every vendor on the page is the subject of measuring tag manager blocking time.

FAQ

Q: Why not put async on the whole snippet instead of splitting it?

async fixes the parser stall but not the delivery gap. The command global still does not exist until the bytes arrive and execute, so every call made before that point throws, and an uncaught ReferenceError aborts the rest of the inline script that made it — which is how a tracker change breaks an unrelated feature. Splitting is what removes the gap: the stub is present from its parse position onward, and the transport is then free to arrive whenever the scheduler gets to it. async is still the right attribute for the transport itself; it is simply not sufficient on its own.

Q: Does a consent banner change the protocol?

Only step four. Keep the stub and the queue exactly as they are — buffering in memory is not a network send, and the array can be discarded outright if consent is refused. Move the library insertion into the consent callback. The cost of that move is one extra round trip, because the preload scanner never sees a URL that only exists after a click; issue a preconnect to the collector origin at the moment the banner is displayed so the DNS lookup and TLS handshake overlap the user’s decision instead of following it. The automation pattern is covered in automating preconnect for third-party APIs.

Q: Will hits look late if they arrive 900 ms after the event?

Not if the queue entry carries its own timestamp, which is why step three exists. Record performance.now() at push time and send it as an explicit offset alongside performance.timeOrigin; most collectors accept a queue-time field and subtract it. Without it the vendor library stamps at send time, and every metric derived from event timing — time to first interaction, funnel step gaps, scroll depth against dwell — is shifted by however long the library took to arrive, which is precisely the variable you just made larger.