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.

Queueing and Stalled look almost identical in the waterfall — both render as grey bars before any coloured phase — but they have different causes and different fixes, and confusing them sends you down the wrong path. The distinction is which limit the request has run into.

Queueing versus Stalled — two grey bars, two different causes Two rows. The Queueing row shows a request waiting because the scheduler has ranked higher-priority requests ahead of it; the limit is priority order and the fix is to raise the request's fetch priority. The Stalled row shows a request waiting because all six per-origin connections are already in use; the limit is the connection pool and the fix is to warm a connection ahead of time or to reduce the number of concurrent requests to that origin. A footnote records that both phases render as an identical grey bar in the DevTools waterfall. Both render grey. Only one of these fixes will work. Queueing real work limit: scheduler priority order fix: raise this request's priority Stalled real work limit: 6 connections per origin fix: preconnect, or fewer parallel requests identical grey bar Raising priority on a Stalled request changes nothing: it moves the request up a queue it was never waiting in. Hover the bar in DevTools — the tooltip names the phase explicitly.

Note that the two can coexist and frequently do: a consent grant releases six scripts at once, so the first few are merely queued while the rest are also stalled behind an exhausted connection pool. In that situation the ordering of the fixes matters — warm the connections first, because raising priorities while the pool is saturated simply reshuffles which request waits.

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.

Those four checks are not independent; they fail in a fixed order, so the first one that fails tells you where to stop reading. Working through them top to bottom saves you from investigating a scheduling problem that is really a connection problem.

Verification ladder — stop at the first rung that fails Four checks in sequence. First, the Priority column reads High for the analytics SDK; if it does not, the attribute never reached the element. Second, Queueing is under 50 milliseconds; if not, competing high-priority requests still outrank it. Third, no Stalled entries remain; if any do, the connection pool for that origin is still exhausted. Fourth, script evaluation no longer overlaps the Largest Contentful Paint window; if it does, the remaining cost is main-thread execution rather than network scheduling, and no loading attribute will address it. 1 · Priority column reads High fails → the attribute never reached the element 2 · Queueing < 50 ms fails → something higher-priority still outranks it 3 · no Stalled entries fails → the origin's connection pool is exhausted 4 · no overlap with LCP window fails → the cost is main-thread execution, not the network; no loading attribute will move it

The fourth rung is the one that changes what you do next. Passing the first three and failing the fourth means the script now arrives promptly and then blocks the main thread while the browser is trying to paint — a different problem entirely, addressed by offloading heavy scripts to Web Workers or by deferring the tag past the paint rather than accelerating it toward one.

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