Fixing HTTP/2 priority inversion issues

Low-priority streams are consuming connection bandwidth ahead of your render-blocking CSS or LCP image, inflating FCP and LCP despite correct HTML ordering.

Root cause: how the browser dependency tree goes wrong

HTTP/2 multiplexes all resources over a single TCP connection and relies on a stream dependency tree — specified in RFC 7540 §5.3 — to apportion bandwidth between concurrent streams. Each stream carries a weight (1–256) and a parent stream ID. When the server’s send window fills, it schedules frames proportionally by weight within each dependency level. Crucially, the browser constructs this tree independently of the server; the two sides can diverge if an intermediate proxy strips PRIORITY frames or if the server ignores them.

Two HTTP/2 dependency trees: the weighted tree the browser signalled versus the flat equal-weight tree the server scheduled after PRIORITY frames were stripped On the left, four streams hang off the connection root with weights 220, 200, 32 and 16, giving critical CSS 47 percent of the send window. On the right the same four streams all carry the default weight 16, so each receives 25 percent and analytics competes evenly with the stylesheet. What the browser signalled What the server scheduled instead Stream 0 (root) critical.css · w=220 · 47% hero.webp · w=200 · 43% analytics.js · w=32 · 7% font.woff2 · w=16 · 3% Stream 0 (root) critical.css · w=16 · 25% hero.webp · w=16 · 25% analytics.js · w=16 · 25% font.woff2 · w=16 · 25% Share of the send window = stream weight ÷ Σ weights of its siblings. Strip the PRIORITY frames and every stream falls back to w=16 — an even split.

Priority inversion occurs when that tree is misaligned in one of three ways. First, a low-weight stream can be placed higher in the dependency tree than a high-weight stream, giving it first-class scheduling regardless of weight. Second, aggressively-split JavaScript bundles issued via import() default to a medium urgency tier, competing directly with synchronous CSS during the first paint. Third, CDN edge nodes sometimes translate RFC 7540 PRIORITY frames into RFC 9218 Priority headers incorrectly, reversing the intended order at the point where the origin server actually sends bytes. For background on how HTTP/2 stream prioritization and weighting works at the protocol level, that article covers the full dependency-tree model and weight arithmetic.

The downstream effect is measurable: the browser’s preload scanner identifies a critical stylesheet early and emits a high-weight stream for it, but a third-party analytics script — requested moments later — arrives with a low weight yet happens to occupy a more favourable position in the dependency tree. The server drains the analytics stream first. The stylesheet waits. FCP stalls.

Stream scheduling sequence

The diagram below shows a correct priority ordering (left) versus an inverted one (right), where the analytics stream drains bandwidth before the critical CSS stream completes.

Side-by-side HTTP/2 stream scheduling: correct order on the left, inverted order on the right Left panel shows critical CSS completing at 200 ms and firing FCP before analytics finishes. Right panel shows analytics and a third-party font draining the connection first, pushing critical CSS to 380 ms and FCP with it. Correct priority order t = 0 t = 400 ms Critical CSS (u=0, w=220) FCP LCP image (u=0, w=200) Analytics (u=4, w=32) 3rd-party font (u=5, w=16) CSS drains its send window first; FCP at 200 ms. Inverted priority (broken) t = 0 t = 400 ms Analytics (starving CSS) 3rd-party font Critical CSS delayed 180 ms → FCP (late) LCP img Low-urgency streams hold the send window; FCP slips to 380 ms.

Minimal reproduction

This three-resource snippet isolates the inversion: a <link rel="stylesheet"> and a <script async> are requested at the same moment. Without explicit priority signalling the browser gives both medium scheduling weight; the one with the earlier stream ID wins, which may be the script.

<!-- Without priority hints: inversion risk when stream IDs collide -->
<link rel="stylesheet" href="/styles/critical.css">
<script async src="https://cdn.example.com/analytics.js"></script>

<!-- With explicit fetchpriority: browser emits correct PRIORITY frames -->
<link rel="stylesheet" href="/styles/critical.css" fetchpriority="high">
<script async src="/analytics.js" fetchpriority="low"></script>

<!-- LCP hero image must also be elevated, not left at default "auto" -->
<img src="/hero.webp" fetchpriority="high" decoding="async" alt="Hero">

The fetchpriority attribute maps directly to RFC 9218 urgency levels: "high"u=1, "auto"u=3 (Chromium default for CSS), "low"u=5. When the browser constructs its fetch priority tier for a resource, fetchpriority is the primary override signal.

Deterministic fix protocol

Work through these steps in order. Each is independently verifiable before moving to the next.

  • [ ] Step 1 — Confirm inversion exists. Open Chrome DevTools → Network panel → right-click column headers → enable Priority. Sort by start time. Identify any Low or Lowest priority resource whose waterfall bar begins before or overlaps a Highest or High priority resource. Export a HAR file (Ctrl/Cmd + S → Save as HAR with content) for deeper analysis.

  • [ ] Step 2 — Map stream IDs in the HAR. Parse the HAR with jq to correlate queue delay against priority metadata:

    # Extract URL, reported priority, and queue wait time from the HAR
    jq '.log.entries[] | {
      url: .request.url,
      priority: (.request.headers[] | select(.name | ascii_downcase == "priority") | .value) // "none",
      queue_ms: .timings.wait
    }' export.har | grep -v '"none"' | sort -t: -k3 -rn | head -20

    Resources with queue_ms above 100 ms and a high-urgency priority value are confirmed inversion candidates — the browser intended them to be fast but the server queued them behind lower-urgency work. Note that .timings.wait is server think-time plus scheduling delay; if you need to separate connection-level stalling from genuine backend latency, work through diagnosing request queueing and stalled time before blaming the priority tree.

  • [ ] Step 3 — Add fetchpriority to HTML. Apply fetchpriority="high" to the LCP image and any above-the-fold resource that lacks it. Apply fetchpriority="low" to async analytics and deferred font requests. Do not apply fetchpriority="high" to more than three resources — spreading the hint dilutes its effect.

  • [ ] Step 4 — Set RFC 9218 Priority response headers on the server. Browser hints alone do not survive edge caches that reconstruct responses. Add server-side headers to enforce urgency at the transport layer:

    Nginx

    # /etc/nginx/conf.d/priority.conf
    # u=0 = highest urgency per RFC 9218; applied to render-blocking assets
    location ~* \.(css|woff2|webp|png|jpg)$ {
      add_header Priority "u=0" always;
    }
    # Defer script delivery — u=4 allows bandwidth for critical streams first
    location ~* \.(js|mjs)$ {
      add_header Priority "u=4" always;
    }

    Apache

    <IfModule mod_headers.c>
      # Render-blocking assets get highest urgency
      <FilesMatch "\.(css|woff2|webp|png|jpg)$">
        Header set Priority "u=0"
      </FilesMatch>
      # Non-critical scripts deferred to urgency 4
      <FilesMatch "\.(js|mjs)$">
        Header set Priority "u=4"
      </FilesMatch>
    </IfModule>

    Cloudflare Workers edge override

    export default {
      async fetch(request) {
        const response = await fetch(request);
        // Clone headers — Response objects are immutable in the Workers runtime
        const headers = new Headers(response.headers);
        const { pathname } = new URL(request.url);
    
        // Force render-blocking assets to highest urgency at the edge
        if (/\.(css|woff2|webp|png|jpg)$/i.test(pathname)) {
          headers.set('Priority', 'u=0');
        } else if (/\.(js|mjs)$/i.test(pathname)) {
          // Defer scripts so CSS and images drain their send windows first
          headers.set('Priority', 'u=4');
        }
        return new Response(response.body, { status: response.status, headers });
      }
    };
  • [ ] Step 5 — Verify proxy pass-through. Capture a chrome://net-export/ log (Events: HTTP2_SESSION + HTTP2_SESSION_RECV_PRIORITY). Open the capture in the NetLog viewer — do not share the file externally, as it contains full request headers. Confirm PRIORITY frames or Priority response headers appear with the urgency values you set. If they are absent, a proxy or CDN layer is stripping them; add proxy_ignore_headers Priority; (Nginx) or equivalent upstream config to preserve them.

  • [ ] Step 6 — Audit webpack/Vite chunk splits. Aggressive splitChunks creates many async bundles requested at equal urgency. Isolate render-blocking vendor code into a dedicated cache group:

    // webpack.config.js — separate render-critical vendor from deferred async chunks
    module.exports = {
      optimization: {
        splitChunks: {
          cacheGroups: {
            // enforce: true prevents this chunk from being merged into async groups
            criticalVendor: {
              test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
              name: 'critical-vendor',
              chunks: 'initial', // only synchronous imports — keeps it high priority
              priority: 20,
              enforce: true
            }
          }
        }
      }
    };

    Then preload the critical chunk so the browser fetches it before the preload scanner reaches it inline:

    <!-- modulepreload fires at highest fetch priority for ES modules -->
    <link rel="modulepreload" href="/app/critical-vendor.js" fetchpriority="high">
  • [ ] Step 7 — Deploy and validate with RUM. Instrument PerformanceResourceTiming in production to catch regressions before they affect Lighthouse scores:

    // Flag resources with high queue delay as inversion candidates
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        // requestStart - fetchStart = time the browser waited before sending
        const queueDelay = entry.requestStart - entry.fetchStart;
        if (queueDelay > 100 && entry.transferSize > 10_000) {
          // Send to your RUM endpoint for tracking
          navigator.sendBeacon('/rum/priority-inversion', JSON.stringify({
            url: entry.name,
            queueDelayMs: Math.round(queueDelay),
            transferBytes: entry.transferSize
          }));
        }
      }
    });
    observer.observe({ type: 'resource', buffered: true });

Steps 3, 4 and 6 fix different faults, and the evidence gathered in Steps 1, 2 and 5 tells you which one you have. Route the diagnosis before you start editing config:

Decision tree routing HAR and NetLog evidence to the matching fix step for HTTP/2 priority inversion Three questions in sequence. If queue time stays under 100 milliseconds there is no inversion. If the urgency header never reaches the origin, the edge is stripping it and Steps 4 and 5 apply. If many async chunks share urgency 3, Step 6 applies; otherwise the fix is the fetchpriority hints in Step 3. Triage: which fix step does the evidence point at? HAR queue_ms above 100 ms on a u=0 resource? no Not inversion — the queue is already short Look for a render-blocking stylesheet instead yes Does NetLog still show that urgency at the origin hop? no A proxy or CDN hop is dropping it Steps 4 and 5: origin header + pass-through yes More than six async chunks requested at u=3? yes The chunk split is the inverter Step 6: enforce a critical-vendor cache group no The hints are the gap Step 3: retune fetchpriority queue_ms is .timings.wait in the HAR export; u=0 is the header Step 4 sets at the origin.

Before/after metrics

These ranges are representative of production fixes on typical marketing sites with unoptimised third-party scripts. Your numbers will vary by payload and CDN.

Metric Before fix After fix Method
LCP (p75) 3.2 – 3.8 s 1.4 – 1.9 s WebPageTest 4G throttled
Critical CSS queue time 280 – 450 ms 22 – 48 ms DevTools Timing panel
Stream starvation ratio 18 – 26 % 2 – 5 % HAR analysis
PRIORITY frame alignment 58 – 70 % 95 – 99 % NetLog PRIORITY events
Lighthouse Performance (mobile) 42 – 55 72 – 88 Lighthouse CI

A stream starvation ratio above 10% is a strong signal that inversion is active. Calculate it as: (low-priority bytes transferred before first critical resource completes) / (total bytes transferred in that window).

FAQ

Does fetchpriority="high" on a stylesheet always prevent inversion?

Not by itself. The hint influences the browser’s outgoing PRIORITY frame, but the effect is lost if the server (or CDN) does not honour it — either because it ignores PRIORITY frames, or because it strips the RFC 9218 Priority response header during edge caching. Always pair fetchpriority hints with server-side Priority response headers and confirm both survive the full hop chain via NetLog.

Can priority inversion happen on HTTP/3 connections?

Yes. HTTP/3 replaces RFC 7540 PRIORITY frames with the RFC 9218 urgency scheme (values 0–7, plus an i incremental flag), but the scheduling logic at the server is still discretionary. A QUIC implementation that ignores urgency values or applies equal weights to all streams will invert priorities just as an HTTP/2 server does. The two schemes are not interchangeable, though — a weight of 220 does not translate to a single urgency value, and the mapping is covered in HTTP/2 priority trees vs the HTTP/3 Priority header. The waterfall diagnostic workflow — checking queue delay in DevTools and PerformanceResourceTiming — is identical. See mitigating head-of-line blocking for how QUIC’s per-stream independence changes the transport story without eliminating application-layer inversion.

What if the fix improves LCP but FCP stays slow?

FCP is gated on the first render-blocking stylesheet and the first render-eligible text paint. If your LCP image loads faster but FCP does not move, a different stylesheet or a @font-face declaration is blocking the render tree. Run the render-blocking resource identification workflow to identify which resource is holding FCP and whether it is a separate priority inversion or an unrelated blocking pattern.