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.

fetchpriority=high effect on analytics script waterfall position Two waterfall strips. Without the hint, the analytics script starts at 700ms after lower-priority assets. With the hint, it starts at 150ms, before those same assets. 0 ms 400 ms 800 ms 1 200 ms Without hint critical.css font.woff2 widget.js analytics.js queued 700 ms With fetchpriority=high critical.css analytics.js starts at ~150 ms (font, widget follow)

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.

// 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 fetchPriority after appendChild. 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 with fetchpriority="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

Up: Using Priority Hints to Control Script Execution