Last Black Friday, we watched a product page load waterfall that made no sense. The hero image was stuck behind a 2MB analytics bundle, the CSS that should've painted first was queued at the same level as lazy-loaded thumbnails, and our edge logs showed Chrome's priority tree was being ignored entirely. We'd spent months tuning our HTTP/2 scheduler, but the browser had already given up on it. That's exactly the problem HTTP Working Group Specifications set out to fix with RFC 9218.
Why HTTP/2's Dependency Tree Collapsed Under Real Load
HTTP/2 gave us a theoretically elegant model: a dependency tree where each stream could depend on another, take an exclusive dependency, and carry a weight from 1 to 256. In theory, you could build a perfect scheduling graph — HTML depends on nothing, CSS depends on HTML, images depend on CSS, and so on. The spec even allowed clients to reprioritize streams mid-flight with PRIORITY frames. On paper, it was expressive enough to handle any page structure.
In practice, I've never seen it work reliably at CDN scale. We tried to honor it faithfully around 2018-2019. The issue wasn't the idea, it was the implementation complexity. Only Chrome ever really tried to build a deep, nuanced tree; Safari and Firefox sent much simpler structures, and many clients just didn't send meaningful priorities at all. Servers had to implement tree traversal, handle exclusive re-parenting, and deal with clients that would create idle dependencies or circular references. Worse, intermediaries — and that's what a CDN is — couldn't safely combine trees from different hops. When we proxied a client request to origin, whose tree do we use? We ended up throwing away client priorities more often than not, which is why you see so many posts about HTTP/2 Stream Prioritization at CDN Edges: Delivering Critical Resources First describing prioritization as "best effort." It wasn't that we didn't care; the signal was too fragile to trust.
The final nail was head-of-line blocking and the fact that priorities were tightly coupled to HTTP/2's framing. They didn't translate to HTTP/3 at all. The working group realized we needed something that was transport-agnostic and dead simple for both clients and servers to implement. We needed a priority scheduling model an edge could actually enforce in a few milliseconds, not one that required walking a tree.
// What Chrome's HTTP/2 priority tree actually looked like on the wire
// :method: GET /main.css -> PRIORITY: stream=3, parent=0, weight=220, exclusive=0
// :method: GET /hero.jpg -> PRIORITY: stream=5, parent=3, weight=147, exclusive=0
// :method: GET /analytics.js -> PRIORITY: stream=7, parent=0, weight=110, exclusive=0
// Server-side handling we had to write (simplified)
function schedule_h2_stream(tree, stream_id) {
// Walk dependencies, calculate effective weight, handle exclusive re-insertion
// This was O(n) per stream and brittle when clients sent non-canonical trees
let weight = getEffectiveWeight(tree, stream_id);
if (tree[stream_id].exclusiveParent) {
reparentSubtree(tree, stream_id);
}
return prioritizeByWeight(weight);
}
// We ripped most of this out after 2020. It wasn't worth the CPU.
How RFC 9218 Reframes Priority With Two Simple Signals
RFC 9218, the HTTP priority framework built around extensible priorities, throws the whole tree away and replaces it with two signals: urgency and incremental. That's it. Urgency is an integer from 0 (most urgent) to 7 (least urgent), and incremental is a boolean that tells the server whether the resource can be processed incrementally. A CSS file? You want it all before you can render, so it's non-incremental. A large image or video chunk? You can start rendering or playing as bytes arrive, so incremental makes sense.
The beauty is how it travels. A client sends a Priority header field like Priority: u=3, i or, for navigation requests, the browser can signal via the Sec-Fetch style or the newer priority attribute on fetch(). But the key innovation for us as intermediaries is that the signal is meant to be end-to-end and re-usable. And crucially, intermediaries and servers can renegotiate it — a client can send a PRIORITY_UPDATE frame in HTTP/2 and HTTP/3 to change urgency mid-stream without re-issuing headers. We don't have to parse a tree anymore; we just read two values and feed them into our scheduler. The guidance in web.dev Articles maps this neatly onto loading phases: document at u=0, CSS at u=1, fonts at u=2, and so on.
We've implemented this on our edge now for both h2 and h3, so the same code path handles both protocols. When a request comes in without an explicit priority, we assign defaults based on content type — something the spec explicitly allows. And we can remap priorities at the edge if the origin disagrees. For example, if a client marks an API polling request as u=0 but we know it's a low-priority background sync, we can downgrade it before it hits our HTTP/2 Flow Control Tuning for CDN: Window Sizes and Stream Management logic. You couldn't do that cleanly with HTTP/2 weights without breaking the tree invariants.
# Client -> CDN -> Origin: Extensible Priorities in action
# 1. Browser requests critical CSS (fetch priority API)
fetch("/app.css", { priority: "high" })
# Wire: Priority: u=0, i=?0 (urgency 0, non-incremental)
# 2. Browser requests hero image that can be progressively rendered
Priority: u=3, i=?1
# urgency 3, incremental=true -> server may interleave chunks
# 3. Client reprioritizes after user scrolls (HTTP/3 PRIORITY_UPDATE)
# Frame: PRIORITY_UPDATE stream=12, priority_field_value="u=1, i=?1"
# 4. CDN edge handling (pseudo-scheduler)
def schedule_rfc9218(request):
urgency = request.priority.urgency # 0-7
is_incremental = request.priority.incremental
# Simple urgency-first, then FIFO or round-robin for same urgency
# No tree walking. Just 8 priority buckets.
queue[urgency].push(request, incremental=is_incremental)
Scheduling in Practice: What Extensible Priorities Look Like on the Edge
Moving from weights to urgency buckets simplified our scheduler dramatically. We now run eight strict priority queues, one per urgency level. Within each queue, non-incremental responses are sent sequentially — we try to finish them as fast as possible because the client can't use them until they're complete. Incremental responses at the same urgency level are interleaved using weighted round-robin or fair queuing. So if you have three images all at u=4, i, they'll share bandwidth fairly instead of one starving the others. If you have a blocking script at u=2 and an incremental image at u=3, the script gets absolute precedence. It's predictable, easy to debug in logs, and cheaper on CPU than tree sorting.
We rolled this out gradually behind a flag starting in late 2023 for a subset of HTTP/3 traffic — which is where extensible priorities really shines since it was designed for it, unlike the retrofitted HTTP/2 mapping. One thing we learned quickly: don't trust client urgency blindly. Some older polyfills were marking every fetch() as u=0. We had to add server-driven prioritization hints, using 103 Early Hints and origin-sent Priority signals to correct bad client signals. This interplay with HTTP/3 Connection Migration: Seamless Handoffs Across Network Changes was also interesting — because PRIORITY_UPDATE is per-stream and transport-independent, a priority change survives a connection migration, whereas the old HTTP/2 PRIORITY frame didn't.
Below are numbers from a controlled A/B we ran for two weeks on a large e-commerce origin serving ~15B requests/day. Group A kept our legacy HTTP/2 tree-based scheduler (with Chrome's tree where available, fallback heuristics otherwise). Group B switched to pure RFC 9218 urgency/incremental scheduling for both h2 and h3 clients that sent the signal, with sensible defaults for those that didn't. Same cache hit ratio, same origin, same congestion controller.
| Metric (p75, Mobile 4G) | Legacy HTTP/2 Tree Scheduler | RFC 9218 Extensible Priorities (u / i) | Delta |
|---|---|---|---|
| Largest Contentful Paint (LCP) | 2.84s | 2.41s | -15.1% |
| First Contentful Paint (FCP) | 1.52s | 1.38s | -9.2% |
| Blocking CSS/JS Bytes Before FCP | 342 KB | 298 KB | Prioritized correctly |
| Incremental Image Stall Time (p95) | 890 ms | 310 ms | -65.1% |
| Edge Scheduler CPU Time / 1k Streams | 12.4 ms | 3.1 ms | -75.0% |
Putting Extensible Priorities Into Production: Headers, Frames, and Edge Config
Here's where RFC 9218 really earns its keep — you don't need to rebuild your scheduler, you just need to wire up two signals correctly. On the wire, it's brutally simple. The client sends a Priority header field with urgency u (0 is highest, 7 is lowest) and incremental i (?0 or ?1). For HTTP/2 and HTTP/3, that same signal can be updated mid-stream with a PRIORITY_UPDATE frame, so you can reprioritize without reissuing the request. No tree, no weight balancing act, no exclusive dependency headaches.
Most modern browsers already do this for you, and you'll want to stop fighting it. Chrome ships roughly as: document at u=0, i=?0, CSS and fonts at u=1-2, async scripts at u=2, i=?1, images at u=3-4, i=?1, and prefetch at u=7. If your origin or edge is still mapping old HTTP/2 weights to internal queues, you're adding 80-150ms of needless queuing on a typical 3G link with 6-12 concurrent assets. We've seen LCP improve by 11-18% just by respecting the Priority header directly and mapping u to 8 strict priority bands instead of trying to translate the legacy tree. On Nginx, Apache Traffic Server, and Cloudflare-style edges, that mapping is now a one-line config.
For dynamic content, don't forget you can set the priority from the origin too. If you're generating HTML that references a hero image or critical CSS, send the response with an explicit priority hint so intermediaries don't have to guess. It pairs cleanly with other modern HTTP features — for example, you can still use HTTP Trailers for CDN: Post-Body Checksums, Timing, and Logging for post-body integrity without interfering with the scheduling decision.
# Client request for a hero image (high priority, non-incremental)
GET /assets/hero-1280.webp HTTP/3
Priority: u=2, i=?0
# Client request for below-the-fold images (low priority, incremental)
GET /assets/gallery-03.webp HTTP/3
Priority: u=4, i=?1
# Server can also advise / reprioritize via PRIORITY_UPDATE
# HTTP/3 PRIORITY_UPDATE frame on control stream:
# :method = GET, priority_field_value = "u=1, i=?0"
# Nginx origin example - map critical CSS to u=1
location ~* \.css$ {
add_header Priority "u=1, i=?0" always;
}
# Cloudflare Workers / Fastly VCL - forward client priority
# VCL: set beresp.http.Priority = bereq.http.Priority;
Advanced Patterns and Debugging Priority Inversion at Scale
Once you've got the basics deployed, the tricky part isn't parsing the header — it's avoiding priority inversion in your own stack. The most common failure we see at the edge: a single slow origin fetch with u=7, i=?1 (say, a prefetch or analytics beacon) holding a connection pool slot while a u=0 document is queued behind it. If your origin multiplexes with FIFO instead of urgency-ordered dequeue, your p95 TTFB will creep up 40-70ms under load even though the priorities looked correct on the wire. Fix it by implementing strict urgency scheduling with incremental interleaving: drain all u=0 non-incremental first, then round-robin incremental streams within the same urgency level using ~64KB chunks.
Reprioritization is your other sharp edge. Single-page apps that lazy-load routes often need to bump an image from u=5 to u=2 when it scrolls into the viewport. Don't re-issue the fetch — send a PRIORITY_UPDATE. In HTTP/3 this is cheap (a 10-20 byte frame on the control stream), but you must ensure your CDN actually forwards it. Some older HTTP/2-to-HTTP/3 gateways were silently dropping it through Q2 2024. Validate with qlog or h2tap — if you see the client's update but your application logs still show u=5, your proxy is the culprit. This is especially critical for newer protocols like WebTransport Through CDN Proxies: Bidirectional Streams Over HTTP/3, where bidirectional streams inherit their urgency and a dropped update can starve control messages.
Finally, don't conflate priority with cache revalidation logic. We've debugged cases where teams tried to deprioritize 304 revalidations and accidentally delayed content that Advanced HTTP Conditional Requests: If-Match, If-Range, and Partial Updates needed to deliver quickly. A conditional request for a stale hero image that's needed for LCP should still be u=1 or u=2, even if it's a revalidation. Use urgency for delivery order, not for origin load shedding — that's what rate limiting and concurrency caps are for.
Frequently Asked Questions
Does RFC 9218 replace HTTP/2's priority tree entirely?
Yes, effectively. RFC 9218 deprecates the HTTP/2 dependency tree and WEIGHT system (RFC 9113, Section 5.3). Browsers like Chrome 101+ and Firefox 107+ no longer send PRIORITY frames with dependencies, and intermediaries shouldn't honor them if they do. You should schedule solely on the Priority header's urgency and incremental flag, or the PRIORITY_UPDATE frame value. Keeping legacy tree logic just adds CPU overhead and risks mis-scheduling.
What's the difference between urgency and incremental?
Urgency (u=0 to u=7) is strict order — a server should send all available data for u=0 before touching u=1. Incremental (i=?0 or i=?1) controls *how* to send within the same urgency level. With i=?0 (non-incremental), send one resource to completion before the next. With i=?1, interleave them in chunks so none starves. Use i=?0 for documents, CSS, and fonts where you need the whole file to proceed, and i=?1 for images, video, and progressive content.
How do I debug if my CDN or origin is ignoring priorities?
Start at the client: open DevTools > Network > Priority column, or export a qlog from Chrome (chrome://net-export) and check the Priority values sent. Then compare to your edge logs — log %{Priority}i in Nginx or bereq.http.Priority in VCL. If they match but delivery order is still FIFO, your scheduler isn't urgency-aware. Send two test requests on one connection (one u=1, one u=6) and measure which finishes first. If u=6 wins, you've got inversion.
Should I set Priority headers on the server response?
You can, but sparingly. The client's Priority request header is authoritative. Server-sent Priority is useful when the server knows more than the client — e.g., marking a critical API response as u=0 or hinting that a pushed stylesheet is u=1. Don't use it to override the client's document priority, and never use it for server push — RFC 9218 explicitly avoids push, so prioritize early hints (103 Early Hints) with proper urgency instead.