Skip to content

HTTP/2 priority and dependency trees as a client fingerprint

· 21 min read
Copyright: MIT
The words PRIORITY 0x02 as a monospace wordmark with a single orange line struck through it, over a dark background

For about seven years, the way a browser asked an HTTP/2 server to send its bytes in a useful order was also one of the cleaner ways to tell which browser it was. Not the User-Agent string, which anyone can set. The priority signal sat a layer below that, in the binary frames the client emitted before it had read a single response byte. Firefox built an elaborate tree of placeholder streams and hung its real requests off them. Chrome built a flat chain and leaned on one exclusive bit. Safari sent almost nothing and let the server guess. Each habit was distinct enough that a server reading 10 million connections could sort clients into buckets without ever looking at the headers.

Then the scheme that produced those habits got deprecated. RFC 9113 retired the HTTP/2 priority signaling that RFC 7540 had defined, RFC 9218 replaced it with something much smaller, and Chrome stopped sending the frames the fingerprint was built to read. So the obvious question for anyone who still cares about identifying clients on the wire: what part of the priority signal survives, and what does its absence now tell you? This post is the priority-layer answer.

The sections run in order. First the original dependency-tree model from RFC 7540, the parts that mattered for fingerprinting. Then the three browser dialects that grew on top of it, with the actual stream IDs and weights. Then the Akamai format that turned those dialects into a string a server could hash. Then the deprecation in RFC 9113 and the reasons behind it. Then RFC 9218, the urgency-and-incremental scheme that took over, and how Chrome’s adoption of it rearranged the whole fingerprint. A closing section on what the priority layer can and cannot tell a defender in 2026.

The original model: a tree of streams

RFC 7540 shipped HTTP/2 in May 2015 with a prioritization model that was, on paper, expressive. A connection multiplexes many streams over one TCP socket. With several responses competing for the same bandwidth, the client needed a way to say which ones it wanted first. The answer was a dependency tree.

Every stream could depend on another stream. The dependency was a parent pointer: stream 5 depends on stream 3 means “send stream 3’s data before mine, all else equal.” Streams with the same parent are siblings, and each carries a weight from 1 to 256 that decides how the parent’s spare bandwidth splits among them. A stream with no explicit parent depends on stream 0, the root of the connection. The result is a tree rooted at stream 0, with the client’s real requests hanging off it at various depths, each edge weighted.

Two pieces of wire carried this. The HEADERS frame that opened a request could include priority information inline, setting the new stream’s parent, weight, and one more bit. And a standalone PRIORITY frame, type 0x02, could set or change a stream’s priority at any time, including before the stream carried any request at all. That last property is what made the tree interesting and what made it a fingerprint. A client could send PRIORITY frames for streams it had not opened yet, creating placeholder nodes, idle streams that existed only to be depended on. Those placeholders never carried a request. They were scaffolding.

The PRIORITY frame payload is five bytes and three fields. One bit of Exclusive flag. A 31-bit Stream Dependency holding the parent stream’s ID. An 8-bit Weight. The weight is sent as a value from 0 to 255 and means a priority of one more than that, so the wire byte 200 is a weight of 201. The exclusive bit is the subtle one. When a stream declares an exclusive dependency on a parent, it inserts itself between that parent and all of the parent’s existing children, which become children of the new exclusive stream instead. One bit reparents an entire sibling set.

An HTTP/2 dependency tree: parent pointers plus weights 0 w=201 w=101 w=1 3 5 7 11 9 real requests placeholders *The shape mirrors a real Firefox tree: odd-numbered placeholder streams hung off the root at fixed weights, with actual requests parented onto them later.*

The model gave clients a lot of rope. A client could express “load the stylesheet and the blocking script first, share what’s left between the fonts and the above-the-fold images, and don’t touch the lazy images until everything else is done” as a weighted tree. The trouble, which surfaces later in this post, was that almost no server acted on the tree faithfully. But the client still had to build it, and how each client chose to build it became the tell.

The reordering rules had a few corners that matter for reading a capture. When a stream closes, the spec says its children are reparented to the closed stream’s parent, with the closed stream’s weight divided among them in proportion to their own weights. So the tree mutates over the life of a connection even without new PRIORITY frames, which is why a fingerprint reads the frames as sent rather than trying to reconstruct the live tree at any instant. The send order of the PRIORITY frames is therefore part of the signal, not just their contents. A client that emits its placeholder frames in a fixed sequence at connection start, before any real request, is producing a more stable string than one that interleaves priority changes with requests, and browsers that used placeholder trees front-loaded them precisely so the scaffolding existed before anything needed to hang off it.

Three browser dialects

Browsers diverged immediately, because the spec told them what they could express, not what they should. Each engine team made its own bet about how to map a page load onto a dependency tree, and those bets were stable across versions and platforms. That stability is exactly what a fingerprint needs.

Firefox built the most elaborate structure. Before sending any real request, it emitted PRIORITY frames that created a small set of idle placeholder streams, then parented its actual requests onto them by category. The placeholders acted as named buckets. In a typical capture the frames set stream 3 to depend on stream 0 with weight 201, stream 5 on stream 0 with weight 101, stream 7 on stream 0 with weight 1, stream 9 on stream 7 with weight 1, stream 11 on stream 3 with weight 1, and stream 13 on stream 0 with weight 241. Those six frames, with those exact IDs and weights, are a Firefox signature. The buckets correspond to load phases. Patrick Meenan’s writeup of browser prioritization describes the groups by role: a leader group that gets roughly twice the bandwidth of an others group, a follower group nested under the leader that only activates once the leader’s direct children finish, plus groupings for unblocked, background, and speculative requests. Leader-weight resources like images and fonts download at weight 200 once the CSS in front of them completes. The shape is a genuine tree with real internal structure, and it does not use the exclusive bit at all.

Chrome went the other way. Chromium-based browsers leaned on the exclusive flag for nearly every stream, which collapses the tree into a chain. Each new request declares an exclusive dependency, inserting itself ahead of whatever came before, so the structure is a single linked list ordered by priority rather than a branching tree. Weights barely matter in this model, because there are no real siblings to split bandwidth between; the chain is strictly sequential. That is good for render-blocking resources that genuinely want to load one after another, and worse for images, which end up queued behind everything instead of sharing bandwidth. The signature is the pattern itself: exclusive bit set, a chain rather than a tree, weight as a near-constant rather than a meaningful spread.

Safari did the least. WebKit mapped its five internal resource-priority levels statically onto HTTP/2 weights and declared no dependencies at all, an approach that reads like leftover SPDY behavior. Every resource depends on the root, weights are assigned from the five-level table, and the browser lets the server share bandwidth proportionally. There is no tree to speak of, which is itself a distinctive fingerprint: a flat fan-out from stream 0 with a small fixed set of weight values and no exclusivity.

Older Edge and Internet Explorer rounded out the spread by doing nothing useful. They expressed no meaningful prioritization, so every request split bandwidth evenly, the worst case for page performance and, again, a recognizable lack of signal. The point across all four is that the priority behavior was a property of the engine, not the page. The same browser produced the same tree shape on every site, which is the definition of a usable fingerprint.

The divergence was not an accident of sloppy implementation. It came from the engines disagreeing about what a page load is. Chrome’s view is that a load is mostly a sequence: the parser hits a blocking script, stalls, and the script has to arrive before anything after it can run, so a strict ordering by an exclusive chain matches the parser’s reality. Firefox’s view is that a load is a set of phases that overlap, with CSS and blocking scripts in front and images and fonts behind, so a tree with weighted phase buckets matches better. Safari’s view, inherited from the SPDY work that predated HTTP/2, is that the server already knows roughly what to do if you just hand it five priority levels, so why build a tree at all. Three reasonable readings of the same spec produced three structures distinct enough that a server could tell them apart on the first connection, and none of the three had any incentive to converge, because each was tuned to its own engine’s loading model. The fingerprint was a side effect of honest engineering disagreement.

If you want the adjacent signals that travel with this one, the HTTP/2 SETTINGS-frame fingerprint and the pseudo-header ordering tell sit in the same frames and are read together with priority in practice.

The Akamai format: turning the tree into a string

Habits are not a fingerprint until someone writes them down in a canonical form. That is what Akamai’s Elad Shuster did in 2017, in a whitepaper and talk on passive HTTP/2 client fingerprinting presented at AppSec USA and Black Hat Europe that year. The research drew on more than 10 million HTTP/2 connections collected across Akamai edge servers and produced fingerprints for over 40,000 unique user agents. The contribution was a string format: a deterministic way to serialize the first few frames of a connection so that two captures of the same client produce the same text.

The format concatenates four sections with pipes. SETTINGS, then the WINDOW_UPDATE increment, then the PRIORITY information, then the pseudo-header order:

S[;]|WU|P[,]#|PS[,]

The SETTINGS section lists each parameter as id:value, semicolon-separated, in the order the frame sent them. WINDOW_UPDATE is the connection-level window increment as a single number, or 0 if none was sent. The pseudo-header section abbreviates the four request pseudo-headers to single letters, m for method, a for authority, s for scheme, p for path, comma-separated in the order they appeared.

The priority section is the one that concerns us. Each PRIORITY frame is serialized as a colon-separated tuple:

StreamID : Exclusivity_Bit : Dependent_StreamID : Weight

Multiple PRIORITY frames join with commas, in send order. If the client sent no PRIORITY frames at all, the section is a single 0. So Firefox’s six-frame tree from the previous section serializes to something like 3:0:0:201,5:0:0:101,7:0:0:1,9:0:7:1,11:0:3:1,13:0:0:241. Read it back and the whole tree is there: stream, exclusivity, parent, weight, repeated. That string is stable across Firefox sessions and distinct from anything Chrome or Safari produces, which is the whole point.

The Akamai HTTP/2 fingerprint, four sections SETTINGS id:value;... WINDOW _UPDATE PRIORITY sid:e:dep:wt,... PSEUDO m,a,s,p | | | Firefox priority section: 3:0:0:201,5:0:0:101,7:0:0:1,9:0:7:1,11:0:3:1,13:0:0:241 Chrome priority section (post-2024): 0 no PRIORITY frames sent *The third section is the priority tuple list. A populated Firefox string versus a bare Chrome 0 is the gap the deprecation opened.*

Reference servers that read this format are not exotic. There are open implementations that compute the Akamai string for NGINX, and public test endpoints will echo your own fingerprint back to you. The format also feeds commercial bot-detection products, which combine it with the TLS fingerprint a layer below. The TLS half is its own deep subject, covered in how the edge captures the handshake; the HTTP/2 half is what we have here.

The deprecation: RFC 9113 retires the tree

The expressive tree turned out to be expensive to honor and barely honored. Servers had to maintain the full dependency graph per connection, reparenting nodes as exclusive frames arrived, recomputing bandwidth splits as streams opened and closed. The payoff for all that bookkeeping was small, because deep buffers elsewhere in the stack undercut it. TCP send buffers, TLS record buffers, and the server’s own output queues all hold bytes that have already been scheduled, so a late-arriving high-priority response cannot preempt the lower-priority data already sitting in those buffers. By one assessment from the era only a couple of CDNs implemented prioritization correctly, despite broad nominal support. The client built a careful tree and the server, more often than not, ignored it.

So the model got retired. RFC 9113, published in June 2022 and edited by Martin Thomson of Mozilla and Cory Benfield of Apple, is the revised HTTP/2 specification that obsoletes RFC 7540. Its section 5.3 deprecates the priority signaling scheme. The PRIORITY frame, type 0x02, is deprecated, and so are the priority fields carried inline in HEADERS frames. The RFC’s own words are that the design and implementation of RFC 7540 stream priority suffered from limited deployment and interoperability.

Deprecated does not mean removed. RFC 9113 deliberately keeps the frame field descriptions and some mandatory handling so that new implementations stay interoperable with old ones that still send the signals. An endpoint must still be able to receive a PRIORITY frame without erroring. It is just no longer expected to act on it, and senders are told not to rely on it. The RFC points readers at an alternative, the scheme then in draft and now published as RFC 9218.

This is the moment the fingerprint surface shifted. As long as the PRIORITY frame was the standard way to express load order, every browser had to send something in that frame, and the something was distinctive. Once the frame was deprecated, browsers could stop sending it, and the most information-dense part of the HTTP/2 fingerprint started to drain away. Not all at once, and not for every client. But the direction was set.

The replacement: RFC 9218 urgency and incremental

RFC 9218, also published in June 2022, by Kazuho Oku of Fastly and Lucas Pardue of Cloudflare, defines the extensible prioritization scheme that took over. It threw away the tree. In its place are two parameters per response and nothing else.

The first is urgency, parameter u, an integer from 0 to 7 in descending order of priority. Zero is the most urgent, 7 the least, and the default when unspecified is 3. Level 7 is reserved for background work like software updates. The second is incremental, parameter i, a boolean that defaults to false. Incremental says whether a response is useful as it arrives in chunks, like a progressively-rendered image, versus only useful complete, like a script that has to be fully present before it executes. That is the entire model. Eight urgency levels and one flag. A server can sort responses into urgency buckets and, within a bucket, decide whether to interleave incremental ones or serve them in order.

Two parameters reach the wire differently. The Priority request header carries the initial priority as an RFC 8941 structured-fields dictionary, version-independent, so the same priority: u=1, i works over HTTP/2 and HTTP/3 alike. And a frame, PRIORITY_UPDATE, lets either side reprioritize after the fact. In HTTP/2 that frame is type 0x10, sent on stream 0, carrying the prioritized stream’s ID and the new priority field value. HTTP/3 uses two frame types, 0xF0700 for request streams and 0xF0701 for push streams, on the control stream. The header sets the opening priority; the frame changes it later.

RFC 7540: weighted tree RFC 9218: two parameters parents, weights, exclusive bit u = 0 .. 7 i = 0 | 1 urgency (default 3) + incremental *The new scheme discards parent pointers, weights, and the exclusive bit. Eight urgency levels and a boolean carry the whole signal.*

The fingerprinting consequence is direct. The new signal has far less entropy than the old tree. A dependency tree with stream IDs, parents, weights, and exclusivity bits could encode dozens of bits of client-specific structure. An urgency value from a set of eight and one boolean cannot. Worse for the defender, the new parameters are deliberately content-dependent: urgency reflects what kind of resource is being fetched, so it varies request to request within one client rather than labeling the client. The shape of the old tree was a property of the engine. The value of the new parameter is a property of the request. Only the first is a stable fingerprint.

There is a second-order signal in the new scheme, though it is weaker and needs more requests to read. Browsers map resource types onto urgency values according to their own internal tables, and those tables are not identical. The urgency a browser assigns to a render-blocking script, a late-discovered font, a preloaded image, or an XHR triggered by script differs from engine to engine, and a defender that watches a full page load can compare the distribution of urgency values across many requests against what a known browser would assign. That comparison is far noisier than reading a tree from one frame, because it requires many samples and the values overlap heavily between engines. It also degrades quickly: a developer can set fetch priority from JavaScript through the standard fetchpriority attribute and the Fetch API priority hint, which moves a given request’s urgency away from the engine default and muddies the per-engine table. The signal is real but thin, and it asks the defender to collect a session rather than judge a single request.

What Chrome’s switch did to the fingerprint

Chrome was the browser whose move mattered most, because Chrome’s exclusive-chain tree was a large, recognizable signal and Chrome is most of the traffic. Patrick Meenan of Google, the same person behind WebPagetest and the prioritization analysis cited above, proposed shipping the RFC 9218 Priority header in Chromium. It went through developer trial in Chrome 119, origin trial across 120 to 122, and shipped to stable in Chrome 124 around February 2024.

With that change, modern Chrome stopped sending separate HTTP/2 PRIORITY frames. The priority section of its Akamai fingerprint went from a populated chain to a bare 0. Its initial priority now rides in the request as an RFC 9218 header, something like priority: u=0, i for a high-urgency incremental resource, with HEADERS-frame priority bits set to a near-constant rather than describing a tree. The most distinctive part of Chrome’s HTTP/2 fingerprint, the part that used to separate it cleanly from Firefox and Safari at the priority layer, was deliberately flattened. A current Chrome HTTP/2 fingerprint reads roughly as 1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p: the priority slot is just 0.

That flattening is not a loss of signal so much as a relocation of it, and this is the part worth sitting with. The absence of a PRIORITY frame is itself a fact about the client. A request that claims to be Chrome 144 in its User-Agent but sends a full Firefox-style placeholder tree has contradicted itself, because real Chrome 144 sends no such tree. A request that claims to be Chrome but sends the old Chrome exclusive-chain has contradicted itself too, because that chain belongs to a Chrome old enough to predate the switch, and the version in the User-Agent can be checked against the date of the change. The priority layer stopped being a positive identifier of which browser and became a consistency check: does the priority behavior match the version the rest of the connection claims to be?

Firefox and Safari complicate the picture rather than simplifying it, because they did not all move at the same time or in the same way, and an HTTP client library that hard-codes one browser’s old priority behavior will mismatch whichever real browser has since changed. The defender no longer reads the priority section to learn the client’s identity. The defender reads it to catch the client’s identity contradicting itself across layers, which is the same cross-layer logic that catches an ALPN token that disagrees with the HTTP that follows and a pseudo-header order that does not match the claimed engine.

What survives as a signal

The priority layer used to be one of the richest passive HTTP/2 tells, and it isn’t anymore. The dependency tree was deprecated, Chrome flattened its contribution, and the replacement carries a fraction of the entropy. If you were ranking the HTTP/2 fingerprint sections by how much they separate clients in 2026, priority has fallen well below SETTINGS values, the connection window, and pseudo-header order. The tree is mostly a historical artifact now, alive in old client libraries and in the captures Akamai took back when every browser still built one.

What remains is narrower and, in a way, harder to fake. Presence or absence of PRIORITY frames is a binary that has to agree with the claimed browser version. Firefox’s placeholder tree, where it still appears, is the same six-frame signature it always was, and an HTTP client emitting those exact stream IDs and weights is announcing Firefox whether or not its User-Agent does. The RFC 9218 urgency values, weak as a per-client label, still have to be plausible for the resource type, so a client that sends the same urgency for everything looks unlike a browser that varies it by resource. None of these is the rich tree of 2017. Each is a small consistency constraint, and a client built to imitate a browser has to satisfy all of them at once while also matching the TLS handshake, the header casing, and everything else.

The arc is worth naming plainly. A protocol feature designed to make pages load faster turned out to be a better identifier than a performance optimization, was deprecated because it was a mediocre optimization, and in being deprecated lost most of its value as an identifier too. The fingerprint did not get defeated by anyone evading it. It got dissolved by the standards process deciding the underlying feature was not worth keeping. What is left is a small number of bits that mostly serve to check whether a client’s story about itself holds together across the stack, and a populated PRIORITY section is now, more than anything, a sign you are looking at something old.


Sources & further reading

Further reading