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.
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.
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, neverdefer. It has no dependants in the document, sodeferwould only queue it behind your first-party bundle in the shared deferred list. - [ ] Add
fetchpriority="low"and apreconnectto 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.writeloader. Insert an element withscript.async = falseinstead, 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 |
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.