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.

Seen side by side, the two paths a script can take into the resource queue explain the whole defect. A statically declared tag is discovered by the preload scanner during parsing and carries an inferred priority derived from its type and position; a runtime-created element skips that machinery entirely and arrives with nothing.

Why an injected script starts at the back of the queue Two parallel paths ending at the browser's resource queue. The upper path starts at a static script tag in the HTML, passes through the preload scanner during parsing, and enters the queue with a priority inferred from element type and document position. The lower path starts at a consent callback that creates a script element at runtime, bypasses the preload scanner entirely because parsing has finished, and enters the queue with the default auto priority behind fonts and images already dispatched. <script src> in HTML present at parse time preload scanner infers priority from type + position createElement() inside a consent callback scanner already finished no priority inferred at all resource queue fonts + images already dispatched ahead of it The lower path is the only one you control after parsing — which is why the hint must be set on the element itself.

That asymmetry is also why the fix is so cheap. You are not overriding a decision the browser made badly; you are supplying information the browser never had. Nothing about the consent boundary changes, no markup moves, and the vendor’s own loader is untouched — the element simply carries the classification it would have received had it been present in the original document.

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.

Express the result as recovered events rather than milliseconds when you report it, because that is the number the change was made for. Pageview beacons are lost in proportion to how long the script takes to become ready: every visitor who leaves inside that window is a session your analytics never records, and short sessions — the ones most likely to bounce — are precisely the ones the window swallows.

Shrinking the window, in events rather than milliseconds Two bars on a shared axis running from zero to 900 milliseconds. Before the fix, the window between the consent grant and the analytics script becoming ready is 720 milliseconds, and roughly nine percent of sessions end inside it and are never recorded. After setting fetch priority high before appending the element, the window is 240 milliseconds and roughly three percent of sessions are lost. A caption notes that the loss rate is a property of the window length, not of the vendor. Consent grant → analytics ready before 720 ms window ≈ 9 % of sessions lost after 240 ms ≈ 3 % of sessions lost 0 300 600 900 ms Loss is a property of the window, not of the vendor: shorten the window and the same tag records more sessions. Percentages are illustrative — derive yours by comparing beacon counts against server-side pageviews.

Derive your own two numbers rather than trusting the illustration: compare the count of analytics pageviews against a server-side or edge-logged pageview count for the same period, and the ratio is your loss rate. Running that comparison before and after the change is also the only way to distinguish a genuine recovery from normal traffic variance, since a 3 % shift is well inside week-to-week noise on most sites.

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.

  • Treating the hint as a substitute for connection warming. Priority decides who goes first among requests the browser can already dispatch; it does nothing about DNS, TCP, and TLS, which a consent-gated origin has usually not yet paid. Pair the hint with a <link rel="preconnect" crossorigin> for the vendor origin emitted in the initial HTML, so the handshake completes during the banner’s lifetime and the promoted request finds a warm socket waiting. Without it you have accelerated a request that still spends its first 200 ms on a handshake.

  • 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