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.
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
LoworLowestpriority resource whose waterfall bar begins before or overlaps aHighestorHighpriority 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
jqto 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 -20Resources with
queue_msabove 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
fetchpriorityto HTML. Applyfetchpriority="high"to the LCP image and any above-the-fold resource that lacks it. Applyfetchpriority="low"toasyncanalytics and deferred font requests. Do not applyfetchpriority="high"to more than three resources — spreading the hint dilutes its effect. -
[ ] Step 4 — Set RFC 9218
Priorityresponse 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 4Header 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. ConfirmPRIORITYframes orPriorityresponse headers appear with the urgency values you set. If they are absent, a proxy or CDN layer is stripping them; addproxy_ignore_headers Priority;(Nginx) or equivalent upstream config to preserve them. -
[ ] Step 6 — Audit webpack/Vite chunk splits. Aggressive
splitChunkscreates 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
PerformanceResourceTimingin 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.
Related
- HTTP/2 Stream Prioritization & Weighting — parent: dependency trees, weight arithmetic, and PRIORITY frame structure
- Mitigating Head-of-Line Blocking — sibling: transport-layer HOL blocking under TCP and QUIC
- Render-Blocking Resource Identification — complementary: identifying which resources hold FCP when priority is correct but render is still blocked