Third-party scripts injected after a Consent Management Platform (CMP) fires consent show 200–800 ms Queueing or Stalled entries in the Chrome DevTools Network panel — even when the server responds instantly. This is a client-side scheduler artifact, not a server latency problem, and it can delay analytics execution by a full second on mid-range mobile devices.

Triage Workflow: Reproduce the Stall Deterministically

The delay only appears under realistic conditions. Run this protocol exactly once before touching any code, so you have a measured baseline to compare against after the fix.

Checklist: DevTools capture setup

Record the Queueing and Stalled values. A healthy post-consent injection group shows Queueing < 50 ms. Values above 150 ms confirm the scheduler priority mismatch described below.

Waterfall timing phases — what each phase means

Chrome DevTools waterfall phases for a dynamically injected script A horizontal bar chart showing how Chrome breaks a single network request into Queueing, Stalled, DNS Lookup, Initial Connection, SSL, TTFB, and Content Download phases. Queueing and Stalled are highlighted as the phases inflated by dynamic injection. Queueing Stalled DNS Lookup Initial Connection SSL TTFB + Download 0 ms 300 ms 600 ms 900 ms ~400 ms ~200 ms ~60 ms ~80 ms ~60 ms ~300 ms Inflated by dynamic injection (target of this fix) Normal network phases

Queueing measures how long Chrome held the request in its scheduler queue before placing it on the wire. Stalled is the time spent waiting for a free socket or HTTP/2 stream. Both are inflated by dynamic injection; the network phases that follow (DNS, TCP, SSL, TTFB) are determined by the server and connection, not injection timing.

Root Cause: Dynamic Injection Priority Mismatch

Chrome’s preload scanner processes HTML as a byte stream, discovering <script src="..."> tags before the main thread parses the DOM. Those early-discovered scripts receive a High or Medium network priority and enter the request queue while the page is still loading. Scripts injected via document.head.appendChild() inside a consent callback arrive after the preload scanner has completed its pass. The browser assigns them fetchpriority="auto", which maps to Low or Medium depending on current render pipeline activity.

When several consent-gated scripts fire simultaneously — a typical scenario when a tag manager receives consent and triggers its container — they all arrive at the scheduler at the same instant. Chrome serialises their queue entries, and each waits behind the others before even beginning DNS resolution. This is the Queueing spike you see in the waterfall. If all six TCP sockets to an origin are occupied, Chrome adds the extra wait as Stalled.

The governing mechanism is covered in depth in the optimizing the network waterfall for external assets guide. The fix requires two things working together: explicit priority assignment on dynamically created script elements, and connection warming performed before consent fires so DNS and TCP overhead is already paid.

The minimal complete fix has two parts. Apply both; either alone is insufficient.

Part 1 — Pre-warm connections before the CMP loads

Place these in <head>, before the CMP script tag. They perform DNS + TCP/TLS without downloading any payload, so no user data is transmitted and no consent is required.

<!-- Add to <head> before your CMP script tag.
     crossorigin is required — omitting it forces a second TCP handshake
     when the credentialed fetch fires after consent. -->
<link rel="preconnect" href="https://cdn.your-analytics-vendor.com" crossorigin>
<link rel="preconnect" href="https://tags.your-tag-manager.com" crossorigin>

<!-- dns-prefetch as a fallback for browsers that do not support preconnect -->
<link rel="dns-prefetch" href="https://cdn.your-analytics-vendor.com">

Replace the href values with the actual origins your consent-gated scripts load from. Check the Initiator column in DevTools — the domain shown there is the correct origin to warm.

For background on how preload and prefetch for third-party scripts differs from preconnect, see that guide; preconnect is the right hint here because you do not yet know the full script URL at page-load time.

Part 2 — Assign fetchPriority at injection time

/**
 * Inject a consent-gated script with an explicit network priority.
 *
 * @param {string} src       - Absolute URL of the script to load.
 * @param {'high'|'low'|'auto'} priority - fetchpriority hint value.
 *                             Use 'high' for your primary analytics endpoint only.
 *                             Use 'low' for pixel fires, retargeting, and non-critical tags.
 */
function injectConsentScript(src, priority) {
  const script = document.createElement('script');
  script.src = src;
  script.async = true;

  // fetchPriority (camelCase) sets the fetchpriority HTML attribute.
  // It must be set before appendChild — changing it after the element is
  // inserted has no effect on the already-dispatched network request.
  script.fetchPriority = priority;

  // crossOrigin = 'anonymous' reuses the pre-warmed anonymous connection
  // created by the preconnect hint. Omitting this forces a new connection.
  script.crossOrigin = 'anonymous';

  document.head.appendChild(script);
}

// Defer non-critical tags to idle time so they do not compete with
// the high-priority analytics request for socket bandwidth.
function injectNonCriticalTag(src) {
  const inject = () => injectConsentScript(src, 'low');
  if ('requestIdleCallback' in window) {
    requestIdleCallback(inject, { timeout: 2000 });
  } else {
    setTimeout(inject, 200); // Fallback for Safari < 17
  }
}

// Wire both functions to your CMP's consent callback.
// This example uses a generic onConsentGranted hook — replace with
// your CMP's actual event (e.g. OneTrust's OptanonConsent, Cookiebot's
// CookiebotOnConsentReady, or a custom dispatchEvent call).
window.addEventListener('consentGranted', function () {
  // One high-priority script maximum — your primary analytics SDK.
  injectConsentScript('https://cdn.your-analytics-vendor.com/sdk.js', 'high');

  // All other tags at low priority, deferred to idle time.
  injectNonCriticalTag('https://tags.your-tag-manager.com/pixel.js');
  injectNonCriticalTag('https://retargeting.example.com/tag.js');
});

fetchPriority = 'high' tells the scheduler to promote this request above auto-priority requests already in the queue. fetchPriority = 'low' pushes non-critical tags below everything else, preventing them from competing for sockets with LCP-critical resources.

For a deeper treatment of using priority hints to control script execution — including how fetchpriority interacts with async and defer — see that guide.

Verification: Single Concrete Check

Re-run the DevTools capture protocol from the triage section under identical conditions (Fast 3G, cleared cache, cleared consent). The fix has worked when:

  1. The Priority column in the Network panel shows High next to your analytics SDK and Low next to pixel/retargeting scripts.
  2. The Queueing value for every consent-gated script drops below 50 ms.
  3. Stalled entries disappear from the waterfall timeline.
  4. In the Performance panel, Evaluate Script events for consent-gated tags no longer overlap with the LCP candidate window.

If Queueing is still elevated after the fix but Stalled is gone, the remaining delay is HTTP/2 stream prioritisation on the server side — verify the CDN serving the scripts supports HTTP/2 push prioritisation and is not resetting stream priority.

Common Pitfalls

  • Setting fetchpriority="high" on every consent-gated script. Chrome enforces an internal ceiling on simultaneous high-priority requests. Marking three or more scripts high causes the scheduler to silently downgrade them to medium anyway, negating the hint. Reserve high for one script per origin — your most critical analytics endpoint.

  • Omitting crossorigin on both the preconnect hint and the injected script element. A hint without crossorigin warms an anonymous connection. If the script request is then made without crossorigin, the browser treats it as a credentialed request and opens a new connection, discarding the warmed one entirely. Both the <link> hint and the script.crossOrigin property must match.

  • Testing the fix only on localhost or with cache enabled. Local cache bypasses DNS and TCP entirely, making Queueing appear near-zero regardless of the injection priority. Always test with a fresh consent state, Disable cache checked, and network throttling active to surface the real scheduler behaviour.


Related

Up: Optimizing the Network Waterfall for External Assets