PROTOCOLS-FUN . QUIC / HTTP-3

QUIC and HTTP/3

RFC 9000 · 9001 · 9114
rev 2026.08
transport on UDP, in user space
crypto TLS 1.3, no record layer
encrypted headers included, almost

QUIC is a transport that swallowed its own security layer. RFC 9000 defines streams, loss recovery, and connection migration; RFC 9001 hands key management to TLS 1.3 but throws away the TLS record layer, carrying handshake bytes in CRYPTO frames instead. The result encrypts nearly the whole packet, including most of the header - and then leaves the first flight readable by anyone who can see it, because the keys that protect an Initial packet are derived from a connection ID that travels in the clear. Every byte below is from the published test vectors in RFC 9001 Appendix A, re-derived rather than copied. Hover a field to light its bytes, click a field marked + for its lookup table.

carrierone UDP datagram, one or more packetsspecRFC 9000 §17

Two header shapes. The long header carries a version and both connection IDs, and is used only until the handshake completes. The short header drops everything the peer already knows and is what carries virtually all real traffic. A single UDP datagram may hold several QUIC packets back to back, which is how a client sends Initial and Handshake packets together.

long header: a client Initial packet

The header exactly as RFC 9001 Appendix A.2 defines it, before header protection is applied. The Initial & Keys tab shows what changes on the wire.

the second bit is a lie detector. Bit 1 of the first byte is the Fixed Bit, and it is always 1 - a packet with it clear is not QUIC. It exists so that QUIC can be distinguished from other UDP protocols on the same port, and it is the one bit QUIC deliberately did not encrypt or randomise. The "greasing the QUIC bit" extension (RFC 9287) lets endpoints negotiate clearing it anyway, precisely so nothing starts depending on it.
short header: a 1-RTT packet

No version, no source connection ID, no length. Eleven bytes of header for a packet that may carry 1400 bytes of payload.

there is no length field, and that is deliberate. A short header packet runs to the end of the UDP datagram, so a receiver cannot find the payload boundary without first knowing the connection ID length - which is not in the packet either. Only the endpoint that chose the connection ID knows how long it is. An observer that did not watch the handshake cannot reliably parse a 1-RTT packet at all, which is the point.
version negotiation

Not a real packet type: a long header with version 0x00000000, listing what the server does support. The client picks one and starts over.

versions 0x?a?a?a?a are reserved to be refused. Same GREASE logic as RFC 8701 in TLS: a client offers a version it knows nobody implements, and a server that fails to negotiate cleanly gets caught in testing rather than blocking QUIC v2 forever. QUIC v2 (RFC 9369, version 0x6b3343cf) exists almost entirely for this reason - it is wire-compatible in shape but renumbers the long header packet types and changes the key-derivation salt and labels, so anything that hardcoded v1 constants breaks loudly.
long header packet types, v1
bitstypecarrieskeys
0x00InitialCRYPTO (the ClientHello), ACK, PADDINGderived from the destination connection ID - readable by anyone
0x010-RTTapplication data, before the handshake finishesearly traffic keys from the PSK
0x02HandshakeCRYPTO (the rest of the TLS handshake), ACKTLS handshake traffic secrets
0x03Retrya token, no packet number, no payloadnone - integrity-tagged with a fixed known key

QUIC v2 renumbers these to Initial=1, 0-RTT=2, Handshake=3, Retry=0. Nothing about the packets changed; the numbers moved so that ossified middleboxes fail visibly.

salta constant in the RFCinputthe destination connection IDspecRFC 9001 §5.2

Initial packets are encrypted. They are also readable by every observer on the path, because both facts are true at once: the key is derived from a value that travels in the clear, using a salt printed in the RFC. This is not a flaw - it is integrity protection against blind injection, not confidentiality - but it is routinely misread as "QUIC encrypts the handshake".

deriving the client Initial keys
// RFC 9001 5.2 - initial_salt is a constant, DCID is the client's first destination CID initial_salt = 0x38762cf7f55934b34d179ae6a4c80cad ccbb7f0a DCID = 0x8394c8f03e515708 initial_secret = HKDF-Extract(initial_salt, DCID) = 7db5df06e7a69e432496adedb0085192 3595221596ae2ae9fb8115c1e9ed0a44 client_secret = HKDF-Expand-Label(initial_secret, "client in", "", 32) = c00cf151ca5be075ed0ebfb5c80323c4 2d6b7db67881289af4008f1f6c357aea key = HKDF-Expand-Label(client_secret, "quic key", "", 16) = 1f369613dd76d5467730efcbe3b1a22d iv = HKDF-Expand-Label(client_secret, "quic iv", "", 12) = fa044b2f42a3fd3b46fb255c hp = HKDF-Expand-Label(client_secret, "quic hp", "", 16) = 9f50449e04a0e810283a1e9933adedd2

Same HKDF-Expand-Label as TLS 1.3, "tls13 " prefix included - see the key schedule on the TLS page. The server side is identical with the label "server in". Every value above was re-derived for this page and matches RFC 9001 Appendix A byte for byte.

this is why "encrypted" QUIC is fingerprintable. Any observer reads the DCID out of the clear header, runs the same three lines, decrypts the Initial, reassembles the CRYPTO frames, and parses the TLS ClientHello inside. That is exactly what JA4q does. Initial protection exists to stop off-path attackers injecting packets, not to hide the handshake - and once the handshake completes, real keys take over and this stops applying.
header protection

Applied after payload encryption, over the bits an observer could otherwise use to correlate packets: the low bits of the first byte and the packet number.

// RFC 9001 5.4.2 - sample starts 4 bytes past the start of the packet number // field, with the packet number ASSUMED to be 4 bytes long for sampling sample = d1b1c98dd7689fb8ec11d242b123dc9b 16 bytes of the encrypted payload mask = AES-ECB(hp, sample)[0..4] = 437b9aec36 first byte: c3 ^ (0x43 & 0x0f) = c0 long header: low 4 bits only pkt number: 00000002 ^ 7b9aec36 = 7b9aec34 unprotected c300000001088394c8f03e5157080000449e00000002 on the wire c000000001088394c8f03e5157080000449e7b9aec34
a chicken-and-egg the receiver solves by construction. You need the packet number to build the AEAD nonce, but the packet number is protected, and the mask comes from the encrypted payload - which starts at an offset that depends on the packet number length, which is in the protected first byte. QUIC breaks the loop by fixing the sample offset at packet number offset + 4 regardless of the actual packet number length, so a receiver can always locate the sample before it knows anything else.
short headers protect 5 bits, not 4. The mask covers the low 5 bits of a short header's first byte because the Key Phase bit is in there and would otherwise let an observer see exactly when a connection rotated keys. Long headers have no Key Phase bit, so only 4 bits are masked.
Retry - a packet with no packet number

Sent when a server wants to validate the client's address before committing state. It carries a token the client must echo, and a 16-byte tag computed with a key published in the RFC. Provenance note: every other byte on this page was re-derived from field values; these 16 tag bytes are RFC 9001 Appendix A.4's published value, reproduced as-is rather than recomputed.

the Retry tag authenticates the path, not the server. Its key is a constant in RFC 9001 §5.8, so anyone can compute it - it only proves the Retry was not corrupted, and stops off-path injection by an attacker who cannot see the original connection ID. Real protection comes later: the server must echo the original DCID back in a transport parameter, and the client checks it, so a forged Retry is detected at handshake completion.
the amplification limit. Before validating a client's address, a server must not send more than 3x the bytes it has received (RFC 9000 §8.1). That is also why a client Initial must be padded so the UDP datagram is at least 1200 bytes - without the padding, the server's certificate chain would not fit in its 3x budget and the handshake would stall. QUIC's flood-amplification story is a size arithmetic problem, solved with mandatory padding.
packet payloada sequence of framesintegersvariable length, 1-8 bytes

A QUIC packet has no payload structure of its own - it decrypts to a stream of frames, each self-describing, read until the packet runs out. Almost every length and identifier in QUIC is a variable-length integer, so the encoding is worth reading first.

variable-length integers

The top two bits of the first byte give the total length. All four RFC 9000 Appendix A.1 test vectors, concatenated.

2msblengthusable bitsrange
001 byte60 - 63
012 bytes140 - 16383
104 bytes300 - 1073741823
118 bytes620 - 4611686018427387903
non-minimal encodings are legal. The value 37 is 0x25 in one byte and 0x4025 in two, and both are valid - RFC 9000 §16 explicitly permits the longer form. Anything comparing encoded bytes instead of decoded values is wrong. Frame types are varints too - which is how the type space extends past 63 without a flag day - but they are the one documented exception to the rule above: RFC 9000 §12.4 requires a frame type to use the shortest possible encoding, so a parser may compare those bytes directly.
CRYPTO - the TLS handshake, with no TLS record layer

Offset and length, then raw handshake bytes. The first 16 bytes of the same ClientHello decoded on the TLS page.

CRYPTO is an ordered stream that is not a stream. It has an offset like a STREAM frame, so handshake data can arrive out of order and be reassembled - but it has no stream ID, no flow control, and cannot be reset. There is one CRYPTO stream per packet number space. This is what replaced the TLS record layer: no 5-byte record header, no content type byte, no 0x17 disguise.
STREAM - application data

Type byte 0b00001OFF: the low three bits say whether Offset and Length are present and whether this is the final frame. Here all three are set.

0x08 baseSTREAM frames occupy types 0x08 through 0x0f - the type byte is the flags.
+0x04 OFFan Offset field is present. Absent means offset 0.
+0x02 LENa Length field is present. Absent means the frame runs to the end of the packet, which is how a sender avoids paying for a length on the last frame.
+0x01 FINthis is the last data on the stream in this direction.
stream IDs encode who opened it and what it does. The low two bits: 0x00 client-initiated bidirectional, 0x01 server-initiated bidirectional, 0x02 client-initiated unidirectional, 0x03 server-initiated unidirectional. Stream 0 is therefore the first client bidirectional stream, which in HTTP/3 is the first request. No handshake is needed to open a stream - you just send data on an ID you are allowed to use, and the limit is raised with MAX_STREAMS.
ACK - ranges, not a bitmap

This frame acknowledges packets 8 through 10 and 3 through 5. Ranges are encoded descending from the largest, each Gap counting the unacked packets between - and both Gap and Length are stored one less than their true value, because a zero-length range is meaningless.

packet numbers never repeat, and that fixes TCP's oldest ambiguity. A TCP retransmission reuses the sequence number, so an ACK for it is ambiguous - did it acknowledge the original or the retransmission? The measured RTT is then wrong in a way that cannot be detected, which is the retransmission ambiguity problem Karn's algorithm works around by discarding those samples. QUIC gives every transmission a new packet number and tracks separately which data it carried, so every ACK produces a usable RTT sample. Losing that ambiguity is most of why RFC 9002 loss recovery is simpler than TCP's.
where each frame is allowed

I = Initial, H = Handshake, 0 = 0-RTT, 1 = 1-RTT. A frame in the wrong packet type is a PROTOCOL_VIOLATION.

typeframeI H 0 1note
0x00PADDINGI H 0 1A zero byte. Also what pads an Initial to 1200.
0x01PINGI H 0 1Elicits an ACK. Keepalive and path probing.
0x02-03ACKI H · 10x03 carries ECN counts. Never in 0-RTT.
0x06CRYPTOI H · 1Not in 0-RTT: early data is not handshake data.
0x07NEW_TOKEN· · · 1A token for the client to use on a future connection.
0x08-0fSTREAM· · 0 1Application data only.
0x18NEW_CONNECTION_ID· · 0 1Supplies spare CIDs for migration.
0x1a-1bPATH_CHALLENGE / RESPONSE· · 0 1RESPONSE is 1-RTT only.
0x1c-1dCONNECTION_CLOSEI H 0 1Only the 0x1c form is allowed in Initial and Handshake - 0x1d reports an application error, and there is no application yet.
0x1eHANDSHAKE_DONE· · · 1Server to client. The TLS Finished still exists, in CRYPTO frames - this is the separate signal that the server considers the handshake confirmed, which is the point a client may discard its handshake keys.
TLS1.3 only, no negotiationspaces3 packet number spacesspecRFC 9001

QUIC uses TLS 1.3 for key agreement and nothing else. There is no TLS record layer, no ChangeCipherSpec, no session ID, no EndOfEarlyData, and no version negotiation inside TLS - a QUIC connection that is not TLS 1.3 is not a QUIC connection.

the 1-RTT handshake
client server
Initial[0]: CRYPTO(ClientHello)padded to 1200 bytes, PADDING framesinitial keys
initial keysInitial[0]: CRYPTO(ServerHello), ACKanyone on the path can read both of these
handshake keysHandshake[0]: CRYPTO(EncryptedExtensions,
Certificate, CertificateVerify, Finished)
1-RTT keys1-RTT[0]: STREAM0.5-RTT data, client not yet authenticated
Handshake[0]: CRYPTO(Finished)handshake keys
1-RTT complete
1-RTT[1]: STREAM1-RTT keys
1-RTT keys1-RTT[1]: HANDSHAKE_DONE

The bracketed number is the packet number in that space. Initial, Handshake, and Application Data each number from zero independently, each with their own keys and their own ACK state - a lost Handshake packet cannot be acknowledged by a 1-RTT ACK, because they are different spaces.

what QUIC deleted from TLS
TLS 1.3 over TCPin QUICwhy
record layergoneQUIC packets already frame and protect. A second framing layer would be pure overhead.
ChangeCipherSpecgoneThe middlebox-compatibility dummy is meaningless on UDP - nothing was ossified to look for it.
legacy_session_idmust be emptySame reason. RFC 9001 §8.4 requires zero length.
EndOfEarlyDatagonePacket number spaces already mark where early data ends.
key updateone bitThe Key Phase bit in the short header replaces the KeyUpdate message. Next secret is HKDF-Expand-Label(secret, "quic ku", "", 32).
TLS 1.2 fallbackforbiddenNo downgrade path exists, so no downgrade protection is needed.
quic_transport_parameters (extension 57)

QUIC's own negotiation rides inside the TLS ClientHello as a single extension. Flow control limits, idle timeout, and connection ID confirmation all live here.

this extension is why a QUIC ClientHello fingerprints differently. It is present on every QUIC connection and absent on every TCP one, and its contents are a detailed profile of the client's transport tuning. It also carries the anti-forgery check for Retry: the server echoes original_destination_connection_id, and a client that sees the wrong value knows the Retry was injected.
0-RTT in QUIC resumes the transport, not just the crypto. A client sending early data must remember the server's transport parameters from the previous connection and stay within them, because it is sending before the server has stated its limits. If the server comes back with lower limits than were remembered, that is a connection error. So 0-RTT carries every replay caveat from the TLS page - not forward secret, replayable, idempotent requests only - plus a transport state machine that has to agree with a connection that already ended.
identitya connection ID, not a 4-tuplespecRFC 9000 §5, §9

A TCP connection is its four-tuple: change the client's IP and the connection is dead. QUIC identifies a connection by an opaque connection ID chosen by the receiver, so the addresses underneath can change without the connection noticing. That single decision is what makes handoff between wifi and cellular survivable, and it creates a linkability problem QUIC then has to spend the rest of §9 solving.

each side names itself
asymmetricEach endpoint chooses the connection ID that its peer will put in outgoing packets. The two directions use different CIDs and different lengths, and neither side knows how the other derives its own.
0 to 20 bytesA server behind a load balancer encodes routing information into the CID so any box in the fleet can steer a packet to the right server. A client that never migrates can use a zero-length CID.
the first DCIDChosen at random by the client, used only to derive Initial keys, then discarded. The server replaces it in its first flight.
sparesNEW_CONNECTION_ID supplies unused CIDs in advance, each with a stateless reset token. active_connection_id_limit caps how many the peer must track.
migration, and the linkability it creates

Path validation is a two-frame challenge-response with 8 bytes of unpredictable data.

client (new address) server
1-RTT from a new IP:portnon-probing frame, e.g. STREAM1-RTT keys
1-RTT keysPATH_CHALLENGE8 random bytes, sent to the new address
PATH_RESPONSEsame 8 bytes back1-RTT keys
path validated - congestion controller resets to a fresh path
migration hands an observer a correlation key for free, so QUIC rotates it. If the client kept the same connection ID when it moved from wifi to cellular, anyone watching both networks could link the two addresses to one user with certainty. RFC 9000 §9.5 therefore requires a client to use a fresh, previously unused connection ID when it migrates, and to not reuse one across paths. The privacy of migration depends entirely on having spare CIDs already issued - which is why NEW_CONNECTION_ID is sent early and often rather than on demand.
a migrating connection restarts congestion control. The new path has unknown capacity, so RFC 9000 §9.4 requires resetting the congestion controller and RTT estimate. Migration is cheap for connection state and expensive for throughput - the connection survives, but the first seconds on the new path are slow starts again.
stateless reset

A server that has lost its state cannot send a CONNECTION_CLOSE, because it no longer has the keys to encrypt one.

// RFC 9000 10.3 - shaped to be indistinguishable from a short header packet 01xxxxxx first byte: fixed bit set, everything else random <random> unpredictable bits, at least 38 of them <token> the last 16 bytes: the stateless reset token for that CID
the whole design is that it looks like nothing. An endpoint receiving one cannot tell it from an undecryptable 1-RTT packet - it just fails to decrypt, then checks whether the last 16 bytes match a reset token it was issued. Any observer sees a packet of random bytes. The token was delivered earlier, encrypted, in a NEW_CONNECTION_ID frame, so only the real peer can produce it and only the real peer can recognise it.
one requestone bidirectional streamspecRFC 9114

HTTP/3 is HTTP/2's semantics with the multiplexing deleted, because QUIC already does it. HTTP/2 built streams on top of one TCP connection and inherited head-of-line blocking at the transport; HTTP/3 maps each request onto its own QUIC stream, so a lost packet stalls only the streams whose bytes were in it.

streams and their jobs
streamtypecarries
requestclient bidiHEADERS, then DATA, then optional trailing HEADERS. One request and its response, then closed.
controluni 0x00SETTINGS first, then GOAWAY, MAX_PUSH_ID. Exactly one per side, never closed - closing it is a connection error.
pushuni 0x01A server push response, keyed to a PUSH_PROMISE sent earlier on a request stream.
QPACK encoderuni 0x02Dynamic table insertions, in order.
QPACK decoderuni 0x03Acknowledgements telling the encoder what the peer has processed.

A unidirectional stream announces its purpose with a varint stream type as its first bytes. Unknown types are not an error - the receiver abandons the stream, which is how new stream types ship without a version bump. Types of the form 0x1f * N + 0x21 are reserved to be sent and ignored, the same GREASE trick QUIC uses on versions.

which frame is legal on which stream

RFC 9114 §7.2. A frame on the wrong stream is a connection error, not a stream error - the whole connection dies.

typeframecontrolrequestpushnote
0x00DATAnoyesyesMust follow a HEADERS frame. DATA arriving first is H3_FRAME_UNEXPECTED.
0x01HEADERSnoyesyesRequest or response fields, then optionally again for trailers.
0x03CANCEL_PUSHyesnonoEither side, to abandon a promised push before it is delivered.
0x04SETTINGSfirst, oncenonoAny other frame first on the control stream is H3_MISSING_SETTINGS; a second SETTINGS is H3_FRAME_UNEXPECTED.
0x05PUSH_PROMISEnoserver only-Carries the promised request fields. A client sending one is an error.
0x07GOAWAYyesnonoA server sends the last stream ID it will serve; a client sends the last push ID it will accept.
0x0dMAX_PUSH_IDclient onlynonoRaises the push budget. There is no way to lower it.
0x02 0x06
0x08 0x09
reservedneverneverneverHTTP/2's PRIORITY, PING, WINDOW_UPDATE, CONTINUATION. Receipt anywhere is H3_FRAME_UNEXPECTED.
0x1f*N
+0x21
GREASEignoreignoreignoreReserved to be sent and ignored. Not an error - that distinction is the entire point.
reserved and GREASE are opposites, and conflating them breaks both. The four HTTP/2 codepoints are poisoned: receiving one kills the connection, so a proxy blindly forwarding h2 frames into h3 fails loudly instead of half-working. The 0x1f*N+0x21 series is inert: receiving one must be silently ignored, so implementations stay tolerant of frames invented later. One is a tripwire, the other is a vaccine.
the control stream is critical and unclosable. Exactly one per direction, opened before anything else, and closing it at any point is H3_CLOSED_CRITICAL_STREAM. The two QPACK streams are critical in the same way. This is a real departure from HTTP/2, where settings and pings shared the single connection - HTTP/3 has to keep several streams alive for the connection to mean anything.
a HEADERS frame: GET https://example.com/

Frame type, length, then a QPACK-encoded field section. Four header fields in fourteen bytes.

a SETTINGS frame
HTTP/2's frame numbers are poisoned on purpose. Types 0x02, 0x06, 0x08 and 0x09 - PRIORITY, PING, WINDOW_UPDATE and CONTINUATION in HTTP/2 - are reserved in HTTP/3 and receiving one is a connection error of type H3_FRAME_UNEXPECTED. They are not merely unused: they are actively rejected, so a proxy that blindly forwards h2 frames into an h3 connection fails immediately instead of half-working. Their jobs are gone anyway - flow control and pings belong to QUIC now, and CONTINUATION is unnecessary because a QUIC stream has no frame size limit.
head-of-line blocking moved, it did not vanish. QUIC removes it at the transport: a lost packet no longer stalls unrelated streams. But QPACK's dynamic table reintroduces it at the compression layer - a field section that references a table entry the decoder has not received yet must block until it arrives. HTTP/3 exposes the tradeoff as a setting, QPACK_BLOCKED_STREAMS, and an encoder that wants zero blocking simply never references the dynamic table, paying in bytes instead of latency.
problemHPACK assumes ordered deliveryspecRFC 9204

HPACK compresses HTTP/2 headers against a dynamic table that both sides mutate in lockstep - which works only because TCP delivers in order. QUIC streams have no order between them, so a table update on one stream may arrive after a reference to it on another. QPACK is HPACK rebuilt around that fact.

the five field line representations

RFC 9204 §4.5. The high bits of the first byte select the form; the remaining bits are a prefixed integer that may continue into further bytes.

patternnamemeaning
1TxxxxxxIndexedName and value both come from table entry xxxxxx. T=1 static, T=0 dynamic. One byte for a complete header field.
0001xxxxIndexed, post-baseReferences a dynamic entry added after the Base. Relative addressing that stays correct however the insertions interleave with this section.
01NTxxxxLiteral, name referenceName from the table, value inline. N=1 means never index this field - set on authorization and anything else a CRIME-style oracle could probe, and it must survive being forwarded by an intermediary.
0000NxxxLiteral, post-base name refSame, but the name is a post-base dynamic entry.
001NHxxxLiteral, literal nameBoth spelled out. H flags Huffman coding on the name; the value that follows carries its own H bit in its length prefix.
the field section prefix

Two prefixed integers in front of every field section, and the whole reason QPACK can decode out of order.

Required Insert CountAn 8-bit prefix integer. Zero means the section references no dynamic entries and can never block - which is exactly what the HEADERS frame on the HTTP/3 tab does. A non-zero value is not the raw count: RFC 9204 §4.5.1.1 encodes it modulo 2 * MaxEntries, offset by one, so it stays small and the decoder reconstructs the true value from what it has already inserted.
Delta BaseA sign bit S then a 7-bit prefix integer. S=0 gives Base = ReqInsertCount + DeltaBase; S=1 gives Base = ReqInsertCount - DeltaBase - 1. Base is the origin that relative dynamic references count back from, which is what lets one encoding stay valid no matter what else arrived first.
the static table is 99 entries and does most of the work. HPACK's was 61. QPACK's holds values as well as names - :method GET at index 17 and :scheme https at 23 are single bytes because the whole pair is in the table. A request built only from static entries compresses well and can never block, which is why encoders default to exactly that and reach for the dynamic table only under sustained load.
the two side streams

Each endpoint opens one of each, as unidirectional streams typed 0x02 and 0x03. Both are critical: closing either is a connection error.

streaminstructionpatterndoes
encoder 0x02Set Dynamic Table Capacity001xxxxxResizes the table, bounded by the peer's QPACK_MAX_TABLE_CAPACITY.
encoderInsert with Name Reference1TxxxxxxNew entry, name taken from the static (T=1) or dynamic (T=0) table.
encoderInsert with Literal Name01HxxxxxNew entry, name spelled out. H flags Huffman coding.
encoderDuplicate000xxxxxRe-inserts an existing entry at the head so it survives eviction.
decoder 0x03Section Acknowledgment1xxxxxxxA field section on that stream ID decoded successfully - the encoder now knows those entries were understood.
decoderStream Cancellation01xxxxxxThat stream died; drop its outstanding references so entries can be evicted.
decoderInsert Count Increment00xxxxxxAcknowledges insertions the decoder has processed, so the encoder learns what it may safely reference without blocking.
two extra streams exist because compression state cannot ride in-band. Putting table updates on a request stream would make every request depend on every other one, reinventing the head-of-line blocking QUIC just removed. So insertions go on their own ordered stream and acknowledgements come back on another. The cost is that QPACK needs four streams of machinery to do what HPACK did with one shared table - the honest price of unordered delivery.
eviction is the reason the decoder talks back at all. An encoder cannot evict an entry that some in-flight field section still references, and it has no other way to learn when that section was decoded. Section Acknowledgment and Stream Cancellation are what free the table. An encoder that ignores the decoder stream slowly runs out of usable table and degrades to literals - correct, just steadily worse.
inputthe ClientHello inside the InitialsiblingJA4+ breakdown

JA4q is JA4 with the protocol character q. Everything else about the derivation is identical, because the input is the same TLS ClientHello - it just arrives inside CRYPTO frames in an encrypted Initial packet instead of a TCP stream. As the Initial & Keys tab shows, that encryption stops nobody.

Example value carried over from the JA4+ breakdown so the two pages agree. The hashes are FoxIO's; the structural claims below are derived from RFC 9001.

how a QUIC hello differs from a TCP one

Typical browser values, not protocol requirements. Only the version row is mandated by RFC 9001; the rest is what current clients happen to send, which is exactly what makes it a fingerprint.

segmentTCP helloQUIC hellofixed?why
protocoltqn/aThe only difference in the algorithm itself.
version1313mandatedRFC 9001 §4.2 forbids offering anything below TLS 1.3, so this segment carries no information on QUIC.
cipher count1503conventionRFC 9001 §5.3 bars only TLS_AES_128_CCM_8_SHA256, so four suites are legal. Browsers offer three; the count drops from 15 because the TLS 1.2 suites a TCP hello carries for fallback are useless here.
ALPNh2h3conventionQUIC does not constrain ALPN. HTTP/3 registers h3, so browsers send it; other protocols run over QUIC with their own identifiers.
extensions1610conventionThe QUIC hello adds quic_transport_parameters (57) and sheds the TLS 1.2 legacy set - no session_ticket, no ec_point_formats, no renegotiation_info.
reading it off the wire
// everything an observer needs is in the clear or derived from it 1. read DCID from the long header cleartext, bytes 6-13 2. initial_secret = HKDF-Extract(salt, DCID) salt is a constant in RFC 9001 3. derive client key / iv / hp 4. remove header protection, decrypt payload 5. reassemble CRYPTO frames by offset may span several packets 6. parse the TLS ClientHello, compute JA4 identical to the TCP case
"encrypted" does not mean unfingerprintable. QUIC's own key schedule hands out the handshake to anyone on the path. The protection on an Initial packet is integrity, not confidentiality - it stops an off-path attacker injecting a forged ClientHello, and does nothing at all against a passive observer. Any claim that QUIC hides client identity from the network is wrong for the first flight and correct for everything after it.
what actually got harder. After the handshake, QUIC is a real improvement for privacy: the short header exposes only a connection ID and a few bits, packet numbers are masked, and there is no cleartext length field to parse. An observer who missed the handshake cannot even reliably find packet boundaries. So the fingerprinting window is narrow and early - which is an argument for capturing at connection start, and for eBPF sensors that see the first datagram rather than proxies that see a parsed struct. See also TLS 1.3 handshake for the hello being parsed here, and Attack & Mitigation for what gets done with the result.