HTTP/2 Priority Trees vs the HTTP/3 Priority Header
Symptom: the same page, the same CDN, the same bytes — and the LCP image completes at 560 ms over h2 but at 1 000 ms over h3, while the DevTools Priority column reads High in both captures.
Root cause: HTTP/3 kept the intent and threw away the mechanism
Under HTTP/2 the browser expresses priority as topology. RFC 7540 §5.3 gave every stream a 1-bit exclusive flag, a 31-bit parent stream identifier and an 8-bit weight (the wire value is weight − 1, so the logical range is 1–256), carried either in the priority fields of the HEADERS frame or in a standalone PRIORITY frame of type 0x02. The server keeps that tree, and when its send window opens it splits the available bytes among the ready siblings in proportion to their weights. Chromium never really used the proportional part: it chains each new stream as a non-exclusive dependency of the most recent stream at the same or higher priority, producing a linked list, so ordering comes from the chain rather than from the arithmetic. Firefox does the opposite and builds a real tree, opening idle anchor streams at IDs 3, 5, 7, 9, 11 and 13 — leader, other, background, speculative, follower and urgent-start — and parenting each request under the anchor that matches its class. WebKit stays flat and leans on weights alone. Three engines, three incompatible shapes, all of them legal.
HTTP/3 has none of this. RFC 9114 removed the priority fields from the HEADERS frame and defines no PRIORITY frame at all, because QUIC gives every stream its own ordered delivery and there is no single reliable channel on which a tree could be mutated in a defined order. What replaces it is RFC 9218, the extensible prioritisation scheme: a priority request header field carrying a Structured Fields Dictionary with two keys — u, an Integer from 0 (most urgent) to 7, defaulting to 3, and i, a Boolean defaulting to false — plus a PRIORITY_UPDATE frame (0xF0700 for request streams in HTTP/3, 0x10 on stream 0 in HTTP/2) for changing a stream’s urgency after the request has already been sent. RFC 9218 §2 is explicit that the scheme is advisory: a server may use the signal. Nothing in the specification requires it to.
The regression is therefore not a QUIC problem and not a bandwidth problem. It is an interpretation problem: the browser stopped speaking the language your server understood, started speaking one it may not implement, and both sides consider that behaviour conformant.
Eight buckets have to carry what 256 weights used to
Chromium derives its HTTP/2 weight from a request’s internal priority tier p with ⌊255 ÷ 7 × (7 − p)⌋ + 1, which yields 256, 219, 183, 146, 110, 73, 37 and 1. Those numbers, plus the position of the stream in the dependency chain, are the whole signal. On HTTP/3 the same tier has to be squeezed into one of eight urgency levels, and the chain position — which is what actually did the work on HTTP/2 — has no representation at all.
| Resource class | Chromium tier | HTTP/2 weight | HTTP/2 chain position | HTTP/3 signal |
|---|---|---|---|---|
| Render-blocking CSS | Highest | 256 | head of the list | priority: u=0 |
LCP image, fetchpriority="high" |
High | 219 | after the CSS stream | priority: u=1 |
| Parser-blocking script | Medium | 183 | after the image stream | priority: u=2 |
| In-viewport images, XHR | Low | 146 | after the script stream | header omitted (u=3) |
| Async script, non-blocking CSS | Low | 146 | after the script stream | priority: u=4 |
| Deferred and offscreen images | Lowest | 110 | tail of the list | priority: u=5 |
| Prefetch, speculative load | Lowest | 37 | tail of the list | priority: u=6 |
Two rows in that table share a weight and a chain position but land on different urgency levels, and one row sends no header at all because its urgency equals the default — a detail that matters when you go looking for the field in a capture and conclude it is missing. The mapping is not a lossless re-encoding; it is a re-derivation from the browser’s internal tier, which is why an h2 and an h3 capture of the same page can disagree about relative order in ways the browser resource priority queues never expose in the DevTools column.
Round-robin is the default, not the fallback
When a QUIC server has no urgency scheduler it does not fail closed to request order — it interleaves. Every request stream with data available gets an equal share of the congestion window each time the connection is ready to send. That is a perfectly reasonable default for a general-purpose transport and a disaster for a page load, because it converts a set of staggered completions into one simultaneous completion at the end of the window.
The arithmetic is worth doing once. Take six responses totalling 270 KB — an 18 KB stylesheet, a 110 KB hero image, a 52 KB script and three 30 KB thumbnails — over a connection that can move 270 KB in one second. Served in urgency order, the stylesheet is done at 180 ms and the hero at 560 ms. Round-robined six ways, the stylesheet gets a sixth of the bandwidth and finishes at 400 ms, the thumbnails at 622 ms, the script at 785 ms, and the hero — the largest response, and the one the LCP metric is waiting on — at 1 000 ms.
The i flag is the second half of the signal
Urgency alone does not decide the shape of that chart. RFC 9218 §4.1 says responses with the same urgency should be served in the order their requests arrived, and §4.2 adds that a response marked incremental may be interleaved with other incremental responses at the same urgency. So i is the switch between the top panel and the middle one within a single urgency level. Twelve gallery thumbnails at u=3 with i set will all finish at roughly the same late moment; the same twelve with i omitted finish one after another, and the first one is usable at a twelfth of the time. Set i only where a partial body is worth something — a streamed HTML document, an event stream, a progressive image whose first scan you actually intend to paint. For a single LCP candidate, leaving it off is the point: a 60 %-decoded hero is not a largest contentful paint.
Minimal reproduction
Four requests and one deliberately inverted urgency. Serve this over HTTP/3 from the origin you want to test, with the browser cache disabled.
<!-- The stylesheet is render-blocking, so Chromium signals u=0 and the hero
u=1. The tracker is deliberately requested FIRST: under a round-robin
scheduler request order decides everything, so if the tracker's bytes
arrive before the stylesheet's, urgency is being ignored. -->
<script src="https://cdn.example.com/tracker.js" async fetchpriority="low"></script>
<link rel="stylesheet" href="https://cdn.example.com/app.css">
<img src="https://cdn.example.com/hero.avif" fetchpriority="high"
width="1200" height="675" alt="">
<script src="https://cdn.example.com/main.js" defer></script>
Read the signal off the wire rather than out of the Priority column, which shows Chromium’s internal tier and not what was transmitted:
# --http3-only forces QUIC so the h2 fallback cannot mask the result.
# The two requests differ ONLY in urgency; if the scheduler honours RFC 9218 the
# u=0 body completes first even though it was requested second.
curl --http3-only -o /dev/null -w '%{url} %{time_starttransfer} %{time_total}\n' \
-H 'priority: u=7' https://cdn.example.com/hero.avif \
-H 'priority: u=0' https://cdn.example.com/app.css
# Chromium's own view of what it sent, including the control-stream frames that
# curl cannot show you. Filter the log for PRIORITY_UPDATE and for the header
# field: a missing header does NOT prove a bug — u=3 is the default and is
# omitted on the wire, so check for the default before chasing a stripped header.
google-chrome --enable-quic --origin-to-force-quic-on=cdn.example.com:443 \
--log-net-log=/tmp/h3.json --net-log-capture-mode=IncludeSensitive
On a stack without an urgency scheduler both curl transfers finish within a few milliseconds of each other and the larger one finishes last — the signature of equal shares rather than ordering. On a stack that implements RFC 9218 the u=0 response completes first by roughly its own transfer time.
The control-stream traffic in that NetLog is the part with no HTTP/2 equivalent worth comparing. A stream’s urgency is fixed at request time by the header field, and every later change travels as a separate PRIORITY_UPDATE frame on the client’s control stream — unidirectional stream 2 — referencing the request stream by ID. That is how a browser promotes an image after layout confirms it is the LCP element, and it is a frame your intermediary must forward rather than terminate.
Deterministic fix protocol
Steps 1 to 3 are diagnosis and must run before anything is configured — the most expensive version of this bug is a week of markup changes aimed at a scheduler that was never going to read them.
- [ ] 1. Split every metric by protocol first. Bucket LCP, and the completion time of the LCP resource specifically, by
nextHopProtocol. Anh3population is not a random sample of anh2one, so compare the same pages on the same day. The collector shape is covered in collecting nextHopProtocol with Resource Timing. - [ ] 2. Read the signal off the wire, not out of DevTools. The Priority column is Chromium’s internal tier. Use the NetLog capture above and confirm two things separately: a
priorityheader field on the requests whose urgency is not 3, andPRIORITY_UPDATEframes on control stream 2 after layout settles. - [ ] 3. Prove the server reorders on urgency. Run the inverted-urgency
curlpair. If both responses finish together, no amount offetchprioritywill help: the scheduler is round-robin and step 4 onwards is the only path. - [ ] 4. Stop the edge from normalising urgency. An intermediary that reconstructs the request to origin frequently drops the
priorityfield, and one that re-emits it often writes the default. Compare the field at the client with the field arriving at origin; au=3at origin behind au=0at the client is a rewrite, not a strip. - [ ] 5. Re-express the tree as levels, and leave room between them. Assign urgency by class, not by wishful thinking:
u=0for render-blocking CSS only,u=1for the single LCP candidate,u=2for parser-blocking script,u=4for async script,u=6for prefetch. Everything atu=0is arithmetically identical to everything atu=3. - [ ] 6. Set
ideliberately, per response, not per site. Omit it for images, stylesheets and scripts so equal-urgency responses complete sequentially. Set it for streamed HTML and event streams, where a partial body does work. - [ ] 7. Override from the origin when the client cannot know. A
priorityresponse header field tells an intermediary to reschedule the bytes it has not yet forwarded — the only lever for an asset whose importance is invisible to the browser’s heuristics. - [ ] 8. Re-measure per protocol and require convergence. The rollout is healthy when the LCP-resource completion time for
h3matchesh2on the same pages, not when the average LCP looks acceptable.
Step 7 is the one most teams skip, and it is a two-line change on the origin. The response field is read by the intermediary that is forwarding the bytes, so it works even when the browser’s own heuristics are wrong about the asset:
# The client sends its own urgency; this OVERRIDES what the intermediary uses for
# the bytes it has not yet forwarded. Only useful for assets the browser cannot
# classify — a hashed CSS chunk it sees as a plain subresource, for example.
location ~* ^/critical/.*\.css$ {
add_header priority "u=0" always; # nothing paints until this lands
}
# The reverse case matters just as much: an asset the browser over-values.
# Demoting it frees the window for u=0 and u=1 instead of splitting it.
location ~* ^/pixel/ {
add_header priority "u=6, i" always; # beacons are useful partial and never urgent
}
And on the client, when you know an asset is the LCP candidate before layout does, say so in markup rather than waiting for the PRIORITY_UPDATE at +240 ms — the same reasoning that drives preloading critical above-the-fold assets:
<!-- fetchpriority=high raises Chromium's tier from Low to High, which is what
writes u=1 into the priority header field on the very first request. Without
it the image starts at u=3 and only reaches u=1 after layout, which on this
page costs the 240 ms visible in the sequence diagram above. -->
<link rel="preload" as="image" href="/hero.avif" fetchpriority="high"
imagesrcset="/hero-800.avif 800w, /hero-1200.avif 1200w" imagesizes="100vw">
Before and after
Same page, same edge, same day; h2 shown as the control because it never regressed. The change set is steps 4 to 7 only — no asset was resized and no CDN region moved.
| Metric (p75, 4G throttled) | h2 control |
h3 before |
h3 after |
Delta on h3 |
|---|---|---|---|---|
app.css complete |
180 ms | 400 ms | 184 ms | −216 ms |
hero.avif complete (LCP resource) |
560 ms | 1 000 ms | 566 ms | −434 ms |
main.js complete |
720 ms | 785 ms | 731 ms | −54 ms |
| First thumbnail usable | 796 ms | 622 ms | 812 ms | +190 ms |
| LCP | 690 ms | 1 130 ms | 704 ms | −426 ms |
| FCP | 262 ms | 468 ms | 268 ms | −200 ms |
Requests carrying a priority field at origin |
n/a | 0 % | 94 % | +94 pts |
| Total transfer time for the six responses | 1 000 ms | 1 000 ms | 1 000 ms | unchanged |
The last two rows are the ones to argue from. Nothing got faster in the transport sense — the connection moved the same 270 KB in the same second in every column — and the 6 % of requests still arriving without a field are the ones whose urgency is genuinely 3, so the field is correctly absent. The single regression is deliberate: the first thumbnail is 190 ms later because the thumbnails no longer steal window from the hero. That is the trade the urgency scheme exists to make, and it is the reason a dashboard that averages all resource completion times will show this fix as neutral while LCP moves 426 ms.
FAQ
Does fetchpriority="high" fix this on HTTP/3?
It fixes the client half, which is usually the half that was already working. The attribute raises Chromium’s internal tier, and that tier is what gets written into the priority request header field — the exact signal a round-robin QUIC scheduler is ignoring. Run the inverted-urgency curl pair before touching markup. If the two responses complete together, the field is being transmitted and discarded, and adding more of them changes nothing. Once the server does schedule on urgency, fetchpriority="high" becomes the cheapest way to get u=1 onto the first request instead of waiting for a PRIORITY_UPDATE after layout.
Why did my HTTP/2 traffic regress when I enabled extensible priorities?
Because the switch is not additive. Advertising SETTINGS_NO_RFC7540_PRIORITIES with a value of 1 tells Chromium to stop sending its dependency chain and send the priority header field instead — a reasonable trade only if the receiving scheduler reads that field. A server build that advertises the setting while its scheduler still understands nothing but the RFC 7540 tree ends up with neither signal, and every HTTP/2 stream falls back to the same fair-share interleave that caused the HTTP/3 regression. Verify the scheduler first with the inverted-urgency test on h2, then advertise the setting; the tree stays available as a fallback until you do.
Do I need PRIORITY_UPDATE at all if every request already carries an urgency?
For a static page, rarely. It exists for the cases where importance changes after the request is on the wire: an image that layout resolves as the LCP element, a lazily-observed asset that scrolls into view, a fetch the application demotes when the user navigates away. Chromium sends it routinely for the first of those. The operational risk is not the frame itself but the hop chain: an intermediary that terminates HTTP/3 and re-originates the request has no obligation to translate a control-stream frame into anything at all, so every promotion your browser sends can be dropped without a trace in the response. If step 2’s NetLog shows the frames leaving and step 4’s origin capture shows no urgency change, that is where they died.
Related
- Up: HTTP/2 Stream Prioritization & Weighting — the parent topic: the dependency-tree model, weight arithmetic and the RFC 9218 header syntax in full
- Fixing HTTP/2 Priority Inversion Issues — the sibling guide: the same starvation symptom when the tree itself is misshapen rather than absent
- Does HTTP/3 Eliminate Head-of-Line Blocking? — why per-stream independence removes the transport stall but not the scheduling one