Early-session telemetry goes missing when the browser deprioritizes a dynamically injected analytics script behind assets that were discovered earlier in the parse — a deterministic latency spike, not a network problem.
Triage Workflow: Confirm the Analytics Script Is Being Deprioritized
Work through these steps before touching any code. Confirming the exact failure mode prevents applying the wrong fix.
If the analytics request starts promptly but the payload is large, the problem is transfer size, not priority. This guide addresses only the scheduling gap.
Root Cause: Dynamic Injection Hides Scripts from the Preload Scanner
Most CMPs resolve consent asynchronously, then call document.createElement('script') and append the analytics element to <head>. The browser’s speculative preload scanner operates only on statically parsed markup — it never sees a script created at runtime. The dynamically created element therefore enters the resource queue with no fetch-priority hint, inheriting the default auto classification.
At the moment of insertion, the browser’s scheduler has already dispatched requests for statically discovered assets: web fonts, stylesheets, preloaded images. The analytics script joins the queue behind them, and on congested connections or HTTP/2 streams with competing high-priority resources, it waits until those transfers complete.
This is the same scheduler behaviour described in Using Priority Hints to Control Script Execution, where fetchpriority is introduced as the explicit signal that overrides heuristic classification. The CMP’s dynamic injection pattern severs the connection between DOM position (which normally guides priority) and actual network dispatch order, making the hint the only reliable lever available after static parse time.
The window of data loss — typically 300–800 ms — maps directly to the gap between when the CMP consent callback fires and when the analytics script payload is fully downloaded and parsed.
Resolution Path: Set fetchPriority Before appendChild
The fix is a single-line property assignment inside the existing CMP consent callback. No architecture change is needed; no consent boundary is crossed.
Minimal fix for TCF 2.x consent callbacks
// Triggered by the TCF API after the user completes consent action
window.__tcfapi('addEventListener', 2, function (tcData, success) {
if (
success &&
tcData.eventStatus === 'useractioncomplete' &&
tcData.purpose.consents[1] // Purpose 1: store/access device
) {
// Stop listening — we only need to run once
window.__tcfapi('removeEventListener', 2, function () {}, tcData.listenerId);
var script = document.createElement('script');
script.src = 'https://cdn.analytics-provider.com/v2/core.js';
script.async = true;
// MUST be set before appendChild — the browser reads this at dispatch time
if ('fetchPriority' in script) {
script.fetchPriority = 'high';
}
script.addEventListener('load', function () {
// SDK is now available; initialize with your measurement ID
if (typeof window.AnalyticsSDK !== 'undefined') {
window.AnalyticsSDK.init({ measurementId: 'G-XXXXXXXX' });
}
});
document.head.appendChild(script); // fetch dispatched here, at high priority
}
});
Alternative: static <script> tag with execution guard
If your CMP architecture permits the analytics payload to be fetched unconditionally (confirm with legal that the script binary itself contains no tracking side effects), declare the <script> tag statically in <head> so the preload scanner discovers it immediately. Block SDK initialization behind the consent callback.
<!-- In <head>: browser fetches payload immediately at high priority.
The data-consent-pending attribute is a guard, not a spec feature — your
consent callback checks it before calling AnalyticsSDK.init(). -->
<script
src="https://cdn.analytics-provider.com/v2/core.js"
fetchpriority="high"
async
id="analytics-core"
data-consent-pending="true">
</script>
<script>
// Guard: run SDK init only after explicit user consent
function onConsentGranted() {
var el = document.getElementById('analytics-core');
if (!el || !el.dataset.consentPending) return;
delete el.dataset.consentPending;
// Script may still be loading; wait for it if so
if (typeof window.AnalyticsSDK !== 'undefined') {
window.AnalyticsSDK.init({ measurementId: 'G-XXXXXXXX' });
} else {
el.addEventListener('load', function () {
window.AnalyticsSDK.init({ measurementId: 'G-XXXXXXXX' });
});
}
}
// Wire to your CMP's consent-complete event
window.addEventListener('cmp_consent_granted', onConsentGranted, { once: true });
</script>
The static approach gives the preload scanner a head start on the network fetch, which is the most aggressive form of priority elevation available. The dynamic approach with fetchPriority = 'high' is the correct default when legal requires zero pre-consent network contact with the analytics domain — the request only fires after consent is confirmed.
For a broader view of how fetchpriority interacts with preload and prefetch strategies, see the comparison of network-hint layering options on that page.
Verification: Confirm the Fetch Start Time Moved Earlier
After deploying, measure the impact using PerformanceResourceTiming:
// Run in the browser console after page load and consent grant
var entry = performance.getEntriesByType('resource').find(function (e) {
return e.name.includes('analytics-provider');
});
if (entry) {
var nav = performance.getEntriesByType('navigation')[0];
console.log('Fetch start (ms after nav start):', (entry.fetchStart - nav.startTime).toFixed(0));
console.log('Response end (ms after nav start):', (entry.responseEnd - nav.startTime).toFixed(0));
console.log('Transfer size (bytes):', entry.transferSize);
}
A successful implementation moves the fetchStart value at least 250–500 ms earlier compared to the baseline measurement from the triage step. Confirm in the Network waterfall that the analytics row no longer has a grey “queued” bar; the request should start immediately after the consent callback fires.
Also verify entry.nextHopProtocol is h2 or h3. HTTP/2 and HTTP/3 carry stream priority signals derived from fetchpriority; if the value is http/1.1, the hint still affects local queue ordering but not server-side stream scheduling. Misconfigured CDN proxies that downgrade to HTTP/1.1 can reduce the measurable gain.
Common Pitfalls
-
Setting
fetchPriorityafterappendChild. The fetch is dispatched at the moment the element is inserted into the DOM. Assigning the property on an element already in the document has no effect on the in-flight or already-queued request. Always set it before the append call. -
Marking multiple scripts
fetchpriority="high". The browser’s scheduler treats all high-priority resources as peers and may throttle them collectively. Limit high priority to one analytics endpoint per page. Demote non-critical trackers explicitly withfetchpriority="low"to widen the scheduling gap rather than elevating everything. -
Disabling the CMP auto-loader without removing the manual injection. Many CMPs ship an auto-loader module that detects post-consent callbacks and injects the analytics script automatically. If you add a manual
fetchPriority = 'high'injection without disabling the auto-loader, the script is fetched twice: once at high priority (your code) and once at default priority (the auto-loader). Duplicate fetches produce duplicate initialization calls and can corrupt session data. Consult your CMP’s documentation to disable auto-injection before deploying the manual pattern.
Related
- Using Priority Hints to Control Script Execution — full attribute semantics, browser support, and interaction with HTTP/2 stream weights
- Implementing Preload and Prefetch for Third-Party Scripts — fetching analytics payloads ahead of consent resolution using
<link rel="preload"> - Debugging Script Waterfall Delays in Chrome DevTools — step-by-step waterfall inspection to isolate scheduling gaps
- Architecting GDPR-Compliant Consent Gating — the consent execution gate that governs when analytics script injection is permitted