Back in 2021 we onboarded a large e-commerce platform that couldn't figure out why their LCP was 2.8 seconds on HTTP/2 despite having a perfectly tuned origin. Their CSS and hero images were being delayed behind 40 low-priority product thumbnails, and Chrome's preload scanner was fighting our edge. It wasn't a bandwidth problem — we had plenty — it was a stream prioritization problem, and it's one I've seen repeat itself at every CDN I've worked with since.
Anatomy of the Priority Tree: Weights, Dependencies, and Exclusive Flags
HTTP/2 doesn't treat streams as a flat queue. It builds a dependency tree where each stream can depend on another parent stream, carry a weight between 1 and 256, and optionally take an exclusive flag that reshuffles siblings underneath it. In theory, this gives you fine-grained control over HTTP/2 Flow Control Tuning for CDN: Window Sizes and Stream Management and resource ordering. In practice, most origins never set these signals correctly, so the edge has to reconstruct intent from what the browser sends.
I've debugged this live on edges running nghttp2 and h2o. Chrome, for instance, will typically create an artificial root with a handful of high-level groups — one for the HTML document, one for blocking CSS, one for fonts — and then attach images and async scripts as low-weight children. Firefox does it differently, using a simpler linear dependency chain. If your CDN just serves streams in arrival order or round-robins them fairly, you're ignoring that tree entirely, and critical resources get stuck behind bytes that don't matter for first paint. The spec in RFC 9113 (which obsoleted RFC 7540) defines the mechanics, but it doesn't tell you how to schedule at scale when you have 200 concurrent streams per connection.
What we learned the hard way is that you can't just trust the weight. A weight of 220 vs. 110 doesn't mean twice the bandwidth — it means twice the share when there's contention. On a CDN edge with a 25 Mbps last-mile and a 10ms RTT, contention is constant for the first 1-2 seconds. If you don't respect the tree, you don't control what the user sees first. The HTTP Working Group Specifications (https://httpwg.org/specs/) are essential reading here, but the web.dev Articles (https://web.dev/articles/) on priority do a better job of showing how browsers actually behave.
// Inspecting and re-prioritizing streams at the edge (h2o / nghttp2 style logic)
struct stream_priority {
uint32_t stream_id;
uint32_t parent_id;
uint8_t weight; // 1-256
uint8_t exclusive; // 0 or 1
};
void on_priority_frame(stream_priority *p) {
// Client wants stream 13 to depend on stream 0 with weight 220
// Don't blindly accept - clamp and re-parent if needed
if (p->weight > 256) p->weight = 256;
// Re-parent low-priority images under a throttled group
if (is_image_candidate(p->stream_id) && p->weight < 100) {
p->parent_id = LOW_PRIORITY_GROUP_ID;
// Demote further if critical CSS hasn't finished
if (!critical_css_complete) {
p->weight = 32;
}
}
h2o_rebuild_priority_tree(p);
}
Where Edge Logic Collides With Browser Heuristics
Browsers aren't consistent, and that's the real headache for anyone running an edge. Chrome's priority signals have changed three times since 2019. It moved from the classic dependency tree to what they call Priority Hints and now to the newer priority header and PRIORITY_UPDATE frames defined in RFC 9218. Safari still leans heavily on stream dependencies, while Firefox uses a hybrid. I remember a week-long incident where a customer's A/B test showed Safari loading 30% faster than Chrome for the same assets — turned out our edge was optimizing for Chrome's old tree and completely mis-scheduling Safari's streams.
Your CDN has to normalize this. The smartest approach I've seen — and what we now run in production — is to translate whatever the client sends into an internal urgency-based scheduler, similar to what HTTP/3's Extensible Prioritization does. We map incoming HTTP/2 priorities to 8 urgency levels (0 = highest, 7 = lowest) plus an incremental flag. Critical CSS, synchronous JS in the head, and fonts get urgency 0-1. Hero images get 2. Async scripts and deferred CSS get 4-5. Below-the-fold images and beacons get 6-7. This abstraction lets us schedule consistently regardless of whether the browser sent a full tree or a single u=3, i signal. If you're thinking about how this evolves, HTTP/3 Connection Migration: Seamless Handoffs Across Network Changes is worth understanding because HTTP/3 drops the tree entirely in favor of this urgency model.
The other collision point is origin push and internal resource ordering. If your origin sends a Link: preload header for a font but marks it with low priority, should the edge obey the origin or the browser? We've chosen to let browser signals win for HTML-initiated resources, but allow origins to override for server-pushed or Early Hints (103) resources where they have more context. Getting this wrong means you deliver the font after the JS bundle that needs it, causing layout shifts and flashes of unstyled text that no amount of caching will fix. For newer bidirectional use cases, this same scheduling logic shows up again in WebTransport Through CDN Proxies: Bidirectional Streams Over HTTP/3, where stream urgency determines which datagrams get through first.
# Nginx-style edge prioritization override (conceptual)
http {
http2_priority_handling on;
# Map content-type to internal urgency
map $sent_http_content_type $h2_urgency {
~*text/css 0;
~*font/.* 1;
~*text/html 1;
~*application/javascript 2;
~*image/.* 4;
default 6;
}
server {
listen 443 ssl http2;
location ~* \.(css|woff2)$ {
# Force critical assets to highest urgency, non-incremental
http2_priority_urgency $h2_urgency;
http2_priority_incremental off;
add_header Priority "u=$h2_urgency, i=?0";
}
location ~* \.(jpg|png|webp)$ {
# De-prioritize and allow incremental delivery
http2_priority_urgency 5;
http2_priority_incremental on;
}
}
}
Quantifying What Broken Prioritization Costs You
You can't fix what you don't measure, and prioritization is notoriously hard to observe without the right telemetry. We instrument every edge PoP to log time-to-first-byte per stream, stream weight, parent ID, and scheduling delay — essentially how long a stream waited in the queue while higher-priority streams were being flushed. On a typical product page with 80-120 requests, a FIFO scheduler looks fine in aggregate throughput tests but destroys rendering metrics. I've sat in war rooms where the bandwidth graphs were green but LCP was red, and the culprit was always ordering.
Last quarter we ran a controlled test for a media customer serving 12 million requests per day. We A/B tested our default fair-share scheduler against a priority-aware scheduler that strictly enforced urgency levels 0-7. Same cache hit ratio, same origin, same network — only the stream ordering changed. The difference was immediate and consistent across PoPs in North America and Europe. The numbers below are median values from Chrome real-user data over 7 days, with a 50/50 split and no other changes to the stack.
| Metric (Median) | Fair-Share / No Prioritization | Priority-Aware Edge (Urgency-Based) | Change |
|---|---|---|---|
| Largest Contentful Paint (LCP) | 2,840 ms | 1,950 ms | -31.3% |
| First Contentful Paint (FCP) | 1,620 ms | 1,180 ms | -27.1% |
| Time to First Byte for CSS (p75) | 210 ms | 85 ms | -59.5% |
| Cumulative Layout Shift (CLS) | 0.11 | 0.04 | -63.6% |
| Low-Priority Image Completion | 3,100 ms | 3,650 ms | +17.7% (intentional delay) |
Enforcing Priorities at the Edge: From Theory to Wire
You can't just trust the browser's priority tree and call it a day — if you do, you're letting every client dictate your edge scheduler's behavior. Chrome will happily assign a weight of 220 to a hero image and 32 to your critical CSS if the discovery order's slightly off, and Firefox's dependency tree looks nothing like Safari's. We've seen edges where blindly honoring client weights pushed LCP from 1.4s to 2.1s on a 4G trace. The fix isn't to ignore client hints entirely, it's to re-prioritize at the edge based on content type.
Our approach at the edge is to maintain an origin-aware override map. We classify responses at cache-miss and cache-hit time: document and critical CSS get urgency 0 (highest), synchronous JS and fonts get urgency 1-2, hero images urgency 3, async/deferred scripts urgency 5, and beacons/analytics urgency 7. Then we translate that into HTTP/2 weight/dependency on the fly, or better yet, into Extensible Prioritization (RFC 9218) `priority` signals if your stack supports it. Don't try to build a perfectly balanced tree — a flat 8-level urgency queue with weighted round-robin between levels outperforms a deep dependency tree 9 times out of 10 and it's far easier to debug.
This also means you need to decouple your origin fetching from your client delivery. If you're coalescing HTTP Content Coding Negotiation: Accept-Encoding Beyond Gzip and Brotli variants at the edge, make sure your re-prioritization happens *after* decompression negotiation. We've measured a 40-60ms penalty when Brotli re-compression blocks a high-urgency stream because the worker thread's tied up. Handle encoding in a separate pool so your priority scheduler isn't starved.
# Envoy / NGINX-inspired edge priority override (Lua at CDN edge)
# Map content-type + resource hint to RFC 9218 urgency + incremental
local PRIORITY_MAP = {
["text/html"] = { urgency = 0, incremental = false },
["text/css"] = { urgency = 1, incremental = false },
["font/woff2"] = { urgency = 2, incremental = false },
["application/javascript"] = { urgency = 3, incremental = true },
["image/webp"] = { urgency = 4, incremental = true },
}
function h2_reprioritize(stream, headers)
local ct = headers[":content-type"] or headers["content-type"]
local hint = PRIORITY_MAP[ct] or { urgency = 6, incremental = true }
-- Override client weight: set dependency to root, adjust weight 256->32
-- urgency 0 = weight 256, urgency 7 = weight 32
local weight = 256 - (hint.urgency * 32)
stream:set_priority({
parent = 0,
weight = weight,
exclusive = false
})
stream:send_priority_update(hint.urgency, hint.incremental)
end
Advanced Patterns and Debugging When Priorities Go Sideways
When prioritization breaks, it doesn't fail loudly — it just gets slow. The classic symptom is priority inversion: a low-urgency 250KB async bundle saturates the connection window while your 14KB critical CSS trickles out at 12kbps. If your p75 LCP regresses by 300-500ms after a deploy and your TTFB looks flat, that's your clue. We catch this by logging the actual scheduled stream order at the edge, not just the client's requested priority. Compare the `PRIORITY` frames Wireshark sees against what your scheduler *actually* dequeued. If you're still on HTTP/2's tree, look for dependencies that create artificial head-of-line blocking — an exclusive flag on a large image parent will block all its siblings until it's fully drained.
The second gotcha is browser heuristics colliding with your logic. Chrome uses 5 priority levels internally and folds everything into them; Safari is far more aggressive about deprioritizing images below the fold. Don't fight it, normalize it. We've had great results migrating edge logic to RFC 9218's `priority=u=, i` model even for HTTP/2 connections — translate the old weight/dependency tree into urgency on ingress, schedule purely on urgency, then translate back for the wire. It cuts scheduling latency by ~18% in our benchmarks and makes HTTP/3 migration trivial since it uses the same urgency scheme. And watch your connection flow-control window: a 64KB default window will throttle high-urgency streams regardless of priority. Bump it to 256KB-512KB for high-RTT clients.
For observability, don't just rely on Real User Monitoring. Emit per-stream scheduling metrics from the edge — time-in-queue, bytes delivered by urgency, and reprioritization count. When we added HTTP Trailers for CDN: Post-Body Checksums, Timing, and Logging to our edge responses, we could finally correlate trailer-reported `server-timing: stream;dur=42.3` with client-side PerformanceResourceTiming and prove that urgency 0 streams were consistently delivered 110-180ms earlier after the fix. Also, remember that revalidation matters: if you're serving a stale-while-revalidate asset with Advanced HTTP Conditional Requests: If-Match, If-Range, and Partial Updates, don't let the 304 revalidation hold up the priority queue — treat conditional hits as urgency 0 and serve from cache immediately while you validate in the background.
Frequently Asked Questions
Should we just disable HTTP/2 prioritization and let TCP handle it?
No — you'll make it worse. Without prioritization, you're just doing round-robin or first-come-first-served, which means a 500KB JavaScript file discovered early will starve your 15KB CSS. We've A/B tested disabled prioritization and saw LCP regress by 22-35% on 3G/4G. You don't need a complex tree, but you do need urgency-based scheduling.
Does HTTP/2 prioritization still matter if we're moving to HTTP/3?
Absolutely, but the mechanism changes. HTTP/3 drops the dependency tree entirely and uses RFC 9218 Extensible Prioritization (urgency 0-7 + incremental flag). The good news is the *concept* is identical — critical resources first — so if you build urgency-based logic at the edge now, it ports directly to HTTP/3. Don't invest in perfecting weight=220 vs weight=32 tuning; invest in correct urgency classification.
Why does my CDN show different priorities than Chrome DevTools?
DevTools shows what the *browser requested*, not what the edge actually scheduled. Your CDN may be reprioritizing (which it should), coalescing connections, or using a different HTTP/2 implementation that rebalances the tree. Check your edge's access logs for the actual `priority` or `weight` dispatched, and use `chrome://net-export/` or Wireshark to see the true PRIORITY frames on the wire.
How do I handle prioritization for server-pushed resources?
Short answer: don't push anymore. HTTP/2 Server Push is deprecated and interacts terribly with prioritization — pushed streams often get scheduled at the wrong urgency and waste bandwidth if the client already has it cached. Use `103 Early Hints` or `Link: rel=preload` headers instead and let the browser request with its own priority, which you can then correctly re-prioritize at the edge.