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 same intent, two entirely different wire formatsTwo columns compare what each protocol carries. HTTP/2 lists the HEADERS priority fields with an exclusive bit, a 31-bit dependency and an 8-bit weight, the PRIORITY frame, Chromium’s eight computed weights and Firefox’s six idle anchor streams. HTTP/3 lists the priority request header field with u and i, the PRIORITY_UPDATE frame on the control stream, the absence of any priority fields in HEADERS, and the SETTINGS identifier that switches HTTP/2 over to the same scheme. The same intent, two entirely different wire formats HTTP/2 — RFC 7540 priority tree HTTP/3 — RFC 9218 urgency HEADERS frame, PRIORITY flag set E bit · dependency 31 b · weight 8 b PRIORITY frame, type 0x02 re-parents a stream mid-connection Chromium: 256 219 183 146 110 73 37 1, chained into a list Firefox: anchors 3 5 7 9 11 13 one idle parent per resource class priority request header field u = 0…7 default 3 · i = boolean PRIORITY_UPDATE 0xF0700 control stream, reorders later HEADERS has no priority fields RFC 9114 removed them entirely SETTINGS 0x9 = 1 on HTTP/2 switches h2 to the same scheme What does not survive the upgrade is topology: 256 weights and an arbitrary tree shape collapse into eight urgency levels plus one boolean — and RFC 9218 makes even those advisory.

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.

Six responses, 270 KB, one connection, three schedulersThree stacked Gantt panels over a one-second axis. With the HTTP/2 dependency chain honoured the stylesheet completes at 180 milliseconds, the hero image at 560, the script at 720 and three thumbnails at 1000. With an HTTP/3 round-robin scheduler every response starts at zero and the hero image finishes last at 1000 milliseconds. With the RFC 9218 priority header honoured the HTTP/2 ordering returns. Six responses, 270 KB, one connection — identical bytes, three schedulers app.css 18 KB hero.avif 110 KB main.js 52 KB 3 thumbs HTTP/2 — Chromium chain, server honours it app.css u=0 180 ms hero.avif u=1 LCP at 560 ms main.js u=2 720 ms 3 thumbs u=3 1 000 ms HTTP/3 — no urgency scheduler, six-way round-robin app.css 400 ms hero.avif LCP at 1 000 ms — 440 ms later main.js 785 ms 3 thumbs 622 ms HTTP/3 — priority header honoured, i left off app.css u=0 180 ms hero.avif u=1 LCP back at 560 ms main.js u=2 720 ms 3 thumbs u=3 1 000 ms 0 250 ms 500 ms 750 ms 1 000 ms Total transfer is 1 000 ms in all three panels. Only the completion order differs. Round-robin is fair to streams and hostile to a page: everything important finishes last.

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.

Reprioritising a stream that is already in flight over HTTP/3A sequence diagram with a browser lifeline and an origin lifeline. Four HEADERS frames carry the initial urgency values on request streams 0, 4, 8 and 12 to 56. The stylesheet completes at 180 milliseconds. At 240 milliseconds the browser sends a PRIORITY_UPDATE frame of type 0xF0700 on control stream 2, raising stream 8 to urgency 0, and the hero image completes at 560 milliseconds. Reprioritising a stream that is already in flight (HTTP/3) Browser Origin or edge t = 0 HEADERS, request stream 0 — GET / — no priority fields exist +2 ms HEADERS, stream 4 — GET /app.css — priority: u=0 +3 ms HEADERS, stream 8 — GET /hero.avif — priority: u=1 +6 ms HEADERS, streams 12–56 — 12 thumbnails — priority: u=3, i +180 ms DATA — app.css complete: u=0 drained ahead of everything +240 ms PRIORITY_UPDATE 0xF0700 on control stream 2: stream 8 → u=0 +560 ms DATA — hero.avif complete, 440 ms ahead of round-robin The initial urgency rides on the request; every later change is a separate control-stream frame. An intermediary that terminates the control stream silently drops all reprioritisation.

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. An h3 population is not a random sample of an h2 one, 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 priority header field on the requests whose urgency is not 3, and PRIORITY_UPDATE frames on control stream 2 after layout settles.
  • [ ] 3. Prove the server reorders on urgency. Run the inverted-urgency curl pair. If both responses finish together, no amount of fetchpriority will 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 priority field, and one that re-emits it often writes the default. Compare the field at the client with the field arriving at origin; a u=3 at origin behind a u=0 at 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=0 for render-blocking CSS only, u=1 for the single LCP candidate, u=2 for parser-blocking script, u=4 for async script, u=6 for prefetch. Everything at u=0 is arithmetically identical to everything at u=3.
  • [ ] 6. Set i deliberately, 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 priority response 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 h3 matches h2 on the same pages, not when the average LCP looks acceptable.

Choosing the urgency level and the incremental flag for one responseA decision tree. One response branches three ways: if it blocks first paint it becomes urgency 0, if it is the single LCP candidate it becomes urgency 1, and if it is merely in the viewport it becomes urgency 3. Each branch leads to the exact priority header field to emit, and a closing band explains when the incremental flag should be added. One response, one urgency level — decided before the flag, never after Classify the response by what it blocks blocks first paint is the LCP merely in view Render-blocking CSS nothing paints without it The one hero image exactly one candidate Thumbnails, XHR visible, not decisive priority: u=0 i omitted priority: u=1 i omitted — never partial header omitted u=3 is the default Add i only when a partial body is useful: streamed HTML, an event stream, a progressive scan you will actually paint. Two incremental responses at one urgency both finish at the far edge.

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