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.

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.

HTTP/2 stream scheduling: correct vs inverted priority Left panel shows critical CSS completing before analytics. Right panel shows analytics starving the critical CSS stream, causing it to finish later. 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) Inverted priority (broken) t = 0 t = 400 ms Analytics (starving CSS) 3rd-party font Critical CSS ↑ delayed FCP (late) LCP img

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.

  • [ ] 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

    
      # Render-blocking assets get highest urgency
      
        Header set Priority "u=0"
      
      # Non-critical scripts deferred to urgency 4
      
        Header set Priority "u=4"
      
    

    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 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 });

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 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.