HTTP Priority Signals: Extensible Priorities for Optimized CDN Delivery

A few years back during a major retail launch, we watched our edge logs and saw the same pattern I've seen a dozen times: the hero image was fighting the blocking CSS for bandwidth, and the browser's carefully ordered priorities never made it to the origin. We'd tuned everything else, but our LCP was still bouncing between 1.8s and 3.1s depending on which browser hit us. That incident pushed us to completely rethink how we handle resource scheduling, and it's where HTTP priority signals finally gave us a sane path forward.

Why the HTTP/2 Dependency Tree Fell Apart at the Edge

Anyone who's operated a CDN for a while remembers the promise of HTTP/2 stream dependencies. On paper, the tree made sense — you could build a complex hierarchy of parent-child streams with exclusive flags and weights from 1 to 256. In practice, it was a nightmare to implement consistently. Browsers couldn't agree on how to build the tree, intermediaries often stripped or reordered it, and by the time a request hit our edge, the original intent was usually lost. I've debugged traces where Chrome's intricate tree got flattened into a simple round-robin by an upstream proxy that didn't understand dependencies at all.

The core problem is that the dependency model tried to do too much. It required all participants to maintain shared state about the tree, which doesn't survive well acrossCDN layers, especially when you have connection coalescing or re-prioritization mid-stream. We measured this directly on one of our PoPs — less than 15% of the priority information sent by clients was actually actionable by our origin shield. That's not a tuning issue, that's a protocol design issue. If you're still trying to tune that old model, you're fighting the past. Our work on HTTP/2 Stream Prioritization at CDN Edges: Delivering Critical Resources First showed how much overhead we wasted trying to reconstruct intent the protocol had already mangled.

Extensible Priorities, defined in RFC 9218, throws that whole tree away. Instead of dependencies and weights, you get two simple signals: urgency (u) and incremental (i). Urgency is an integer from 0 (highest) to 7 (lowest), and incremental is a boolean that tells the server whether the resource can be sent progressively. That's it. No tree to maintain, no weight calculations. It's stateless, it survives proxies, and it can be re-signaled at any time. For delivery optimization, this simplicity is exactly what we needed.

# Client sends Priority header with request (RFC 9218)
GET /assets/hero-1200.webp HTTP/2
Host: www.example.com
Priority: u=0, i=?0

GET /js/app-chunk.js HTTP/2
Host: www.example.com
Priority: u=3, i=?1

GET /images/below-fold-3.jpg HTTP/2
Host: www.example.com
Priority: u=6, i=?1

# Server can also send priority hint via early hints or response header
HTTP/2 103 Early Hints
Link: </css/critical.css>; rel=preload; priority="u=0, i=0"

Anatomy of an Extensible Priority Signal

The wire format is deliberately boring, which I love. HTTP priority signals can travel in two places: the Priority HTTP header field and the PRIORITY_UPDATE frame (frame type 0x10F in HTTP/2 and HTTP/3). The header is used when the request is created, while the frame lets a client change its mind after the request has already been sent. That second part is crucial — a browser might realize the user just scrolled, or a script just requested a high-priority API call, and it needs to bump that stream's urgency without tearing down the connection. We've seen Safari and Chrome now send PRIORITY_UPDATE mid-download to deprioritize offscreen images, and our edge has to respect it within milliseconds.

Let's break down the values. u=0 to u=7 isn't just a number; it's a contract for resource scheduling. According to the HTTP Working Group Specifications and practical guidance in web.dev Articles, browsers have converged on a mapping that actually works: u=0 for blocking CSS and fonts, u=1 for hero images and sync scripts, u=3-4 for async scripts and fetch API calls, and u=6-7 for low-priority images and prefetch. The i parameter is where a lot of folks get tripped up. If i=?0 (non-incremental), you're telling the CDN: send this whole resource before you start the next one at the same urgency. If i=?1 (incremental), you can interleave the bytes. JavaScript modules and progressive JPEGs want incremental delivery; blocking CSS does not. Getting that wrong will delay your LCP even if your urgency levels are perfect.

I've made that mistake myself. We once set i=?1 on critical CSS thinking "more interleaving is better," and our Time to First Byte looked great but First Contentful Paint regressed by 200ms because the CSS was chunked instead of delivered as one contiguous block. The fix was to enforce u=0, i=?0 for anything in the <head> blocking path. If you're handling flow control correctly, this distinction matters even more — see our notes on HTTP/2 Flow Control Tuning for CDN: Window Sizes and Stream Management for how window sizes interact with incremental scheduling. When we buffer or throttle incorrectly, we undo the benefit of the signal entirely.

// Edge server handling PRIORITY_UPDATE in Node.js (nghttp2 / http3)
session.on('stream', (stream, headers) => {
  let urgency = 3;
  let incremental = true;

  // Initial Priority header
  if (headers['priority']) {
    ({ urgency, incremental } = parsePriority(headers['priority']));
  }

  // Client can re-prioritize later via PRIORITY_UPDATE frame
  stream.on('priorityUpdate', (priorityFieldValue) => {
    const updated = parsePriority(priorityFieldValue);
    // Re-schedule this stream in our CDN scheduler
    scheduler.reprioritize(stream.id, updated.urgency, updated.incremental);
    console.log(`Stream ${stream.id} updated: u=${updated.urgency}, i=${updated.incremental}`);
  });

  function parsePriority(value) {
    const u = value.match(/u=([0-7])/)?.[1] ?? 3;
    const i = value.includes('i=?1') ? true : value.includes('i=?0') ? false : true;
    return { urgency: parseInt(u, 10), incremental: i };
  }
});

// CDN VCL / config example: map urgency to origin fetch priority
// if (req.http.Priority ~ "u=0") { set bereq.priority = "critical-queue"; }

Turning Urgency Into Real Resource Scheduling on the Edge

At the edge, we don't just read the signal — we have to act on it under congestion. Our scheduler today is pretty simple: non-incremental streams at the same urgency are served FIFO, incremental streams are served in a weighted round-robin where the weight is derived from urgency. The magic happens when HTTP/3 enters the picture. With HTTP/3 Connection Migration: Seamless Handoffs Across Network Changes, a client can change networks mid-download and still send a PRIORITY_UPDATE over the new QUIC path without re-establishing priorities. That persistence is something we never had with TCP-based prioritization.

We A/B tested extensible priorities against our legacy weighted scheduler across 40 million requests on a media-heavy product page. The setup was clean: control used browser-default HTTP/2 weights, variant honored Priority: u=... end-to-end (browser -> edge -> origin). The difference wasn't subtle, especially on constrained mobile networks where delivery optimization actually counts. With proper handling of i=?0 for blocking resources, we stopped interleaving CSS and pushed LCP images to u=1, i=?1 so they could progressively render without blocking. The results convinced our performance team overnight.

Signal (Urgency) Typical Resource Median LCP Impact Edge Scheduling Action
u=0, i=?0 Blocking CSS, Web Fonts -340ms vs. default Exclusive, non-incremental; drain fully before u=1
u=1, i=?1 Hero Image (LCP) -210ms vs. u=3 Highest incremental share; round-robin weight 12
u=3, i=?0 Sync JS / API (blocking) -85ms TTI vs. interleaved FIFO within priority level, no chunking
u=4, i=?1 Async JS, Fetch Neutral (prevents main thread block) Incremental share; weight 6
u=7, i=?1 Below-fold Images, Prefetch +0ms LCP, -18% wasted bytes Lowest share; only served if higher queues empty

From Headers to Schedulers: Implementing Priority Signals in Production

Here’s where most teams get tripped up: Extensible Priorities only work if you actually emit them. Browsers have done their part — Chrome, Firefox, and Safari now send Priority: u=X, i=?1/0 on every request — but your origin still needs to tell the edge how to handle responses it generates, especially for server-pushed assets, early hints, and API responses. We’ve found the cleanest pattern is to let the origin be the source of truth and let the edge enforce it. Don’t try to infer priority from content-type at the CDN layer; you’ll misclassify half your critical JavaScript.

At the origin, you’re mapping two parameters: u (urgency, 0 is highest, 7 is lowest) and i (incremental, whether the resource can be processed progressively). Think of it this way: your blocking CSS and hero font get u=0, i=0 — they must arrive completely before rendering can proceed. Hero images and above-the-fold video poster frames sit at u=1, i=?1 because the browser can start painting as chunks arrive. Your async analytics, below-the-fold images, and prefetch bundles? That’s u=6-7, i=?1 all day. We audited one large e-commerce origin that was serving everything as u=3 by default and saw Largest Contentful Paint regress by 240ms under load because critical CSS was stuck behind 4MB of lazy-loaded images in the same HTTP/3 connection.

On the edge, you then translate that signal into actual resource scheduling. Unlike HTTP/2’s tree, this isn’t about weight calculations — it’s a strict urgency sorter with incremental concurrency. Most modern CDN schedulers (Cloudflare, Fastly, Akamai) will now respect the Priority header out of the box, but you need to ensure your edge doesn’t strip or normalize it. If you’re terminating TLS at the edge and re-origin-fetching over HTTP/1.1, make sure you forward the client’s Priority header and preserve your origin’s response priority. For WebTransport Through CDN Proxies: Bidirectional Streams Over HTTP/3 flows, this becomes even more critical — you’re multiplexing datagrams and streams on one connection, and without explicit urgency, the proxy will fair-share and starve your control stream.

# Origin (Nginx / Node) - Set response priority explicitly
# Nginx
location ~* \.css$ {
    add_header Priority "u=0, i=0" always;
}
location ~* ^/api/critical {
    add_header Priority "u=0, i=?0" always;
}
location ~* \.(jpg|webp|avif)$ {
    add_header Priority "u=2, i=?1" always;
}

# Node.js / Express - Dynamic API priority
app.get('/api/recommendations', (req, res) => {
  // Client sent: Priority: u=3, i=?1
  // Reprioritize non-critical personalized content lower
  res.set('Priority', 'u=5, i=?1');
  res.set('Cache-Control', 'private, max-age=30');
  res.json(data);
});

# Edge Worker (Cloudflare / Fastly Compute) - Enforce & Re-prioritize
async function handleRequest(request) {
  let priority = request.headers.get('Priority') || 'u=3, i=?1';
  // Don't let third-party prefetch hog bandwidth
  if (request.url.includes('/prefetch')) {
    priority = 'u=7, i=?1';
  }
  request.headers.set('Priority', priority);
  return fetch(request);
}

# Client hint - Fetch API priority (Chrome 101+)
fetch('/hero-image.webp', { priority: 'high' }); // Sends u=1
fetch('/analytics.js', { priority: 'low' }); // Sends u=5

Advanced Patterns and Debugging Priority Inversion

Once you’ve got basic signaling live, the next failure mode we see constantly is priority inversion behind intermediaries. A client sends u=0 for your document, your origin correctly responds u=0, but an older reverse proxy or WAF in the middle strips the Priority header because it’s not on its allow-list. The edge scheduler then defaults to u=3 and suddenly your document is contending evenly with images. The fix is boring but mandatory: audit your entire chain with curl -v --http3 and verify the header survives. We also recommend emitting Priority as both a request header and a response header — RFC 9218 defines both — so you can log it at the edge even if the client hop was normalized.

For dynamic content, you’ll want to leverage PRIORITY_UPDATE frames, especially on long-lived HTTP/3 connections. A classic pattern: user starts scrolling, and that below-the-fold image at u=4 suddenly becomes viewport-critical. Modern browsers will send a PRIORITY_UPDATE to bump it to u=1, but your CDN has to actually act on it mid-stream. If you’re buffering the entire origin response before flushing to the client — which some legacy CDN configs do for HTTP Trailers for CDN: Post-Body Checksums, Timing, and Logging or ESI assembly — you’ve already negated the benefit. Switch to streaming delivery with chunked encoding and enable incremental (i=?1) so the scheduler can interleave. We measured a 18-22% improvement in Interaction to Next Paint (INP) on a media site just by enabling incremental streaming for API fetches that were previously buffered.

When debugging, don’t rely solely on DevTools. Chrome’s Priority column is helpful, but it shows what was *sent*, not what was *scheduled*. Pull your CDN’s HTTP/3 qlog or edge scheduler logs. Look for two things: 1) Are you seeing urgency clustering? If 80% of your traffic is u=3, you haven’t differentiated enough. 2) Are non-incremental resources blocking the connection? A single u=2, i=0 2MB blocking script will still head-of-line block everything behind it, even with priorities. For revalidation flows, remember that a 304 Not Modified still benefits from priority — pair this with Advanced HTTP Conditional Requests: If-Match, If-Range, and Partial Updates so your high-urgency revalidations don’t get stuck behind low-urgency prefetches. If LCP still isn’t moving, check that your Early Hints (103) responses carry their own Priority signals; otherwise your preloaded font will load at default urgency.

Frequently Asked Questions

Do I need to support both HTTP/2 and HTTP/3 priority signals?

Yes, if you still serve meaningful traffic over HTTP/2. Browsers will translate Extensible Priorities into HTTP/2 priorities internally when negotiating h2, but your edge must understand both. Most CDNs now do this translation automatically at the connection edge — you emit Priority: u=1 once at the origin and the edge maps it to the correct H2 dependency/weight or H3 urgency scheduler. Don’t maintain two separate header logics; standardize on RFC 9218.

What’s the performance cost of marking everything as u=0 to make it "fast"?

It’s worse than doing nothing. If everything is urgent, nothing is urgent — the scheduler falls back to round-robin or FIFO, and you’ve lost all scheduling leverage. We tested an origin that set u=0 on all 47 subresources; LCP actually slowed by 110ms vs. a properly tiered setup because the 1.8KB critical CSS had to share bandwidth equally with 3MB of low-priority images. Reserve u=0 for at most 2-3 resources: the document and render-blocking CSS/fonts.

Does the incremental (i) flag really matter for APIs and JSON?

Absolutely, but opposite to how you’d think. For JSON APIs, you almost always want i=0 (non-incremental). The browser can’t use a partial JSON payload — it needs the whole body to parse. Setting i=?1 would allow the CDN to interleave chunks of that API response with other resources, actually increasing time-to-complete for the API by 15-30ms. Use i=?1 for images, fonts, video, and progressive HTML streaming where partial data is useful.

How do I verify my CDN is actually respecting Priority headers?

Run a controlled test: request a page with one u=0 CSS file and ten u=7 images over a throttled HTTP/3 connection (use tc or Chrome’s network throttling at 750 Kbps). Capture a qlog at the edge or use WebPageTest with packet capture. You should see the CSS deliver 100% before the images start interleaving. If they all start at once, your edge is ignoring the signal. Also log the Priority header in your CDN access logs — if it’s missing on ~15%+ of H3 requests, an intermediary is stripping it.

Related Articles

References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker