~0x64616E69656C
nxTLS · EXP 05 / HTTP/2 Priority & TLS Records · 2026-10-18

Priority is a signal.
Delivery is a queue.

nginx 1.31.7 understands RFC 9218. But when does a high-urgency response actually reach the client? A controlled HTTP/2 experiment separates frame priority from SSL buffering, TLS record boundaries and already committed TCP output.

Bounded experiment complete.
240/240 primary cases passed. Explicit priority moved the first complete critical DATA frame forward by paired medians of 27.8 ms and 29.1 ms. Static 4 KiB TLS fragments offered an additional, workload-dependent delivery effect. No production policy was changed.
nginx 1.31.7 · pinned BoringSSL · TLS 1.3 / h2 30 randomized repeats per cell · separate server-side trace
Independent nxTLS experiment · 18 October 2026. The nginx 1.31.7 release and pinned BoringSSL are fixed inputs. The 240-case campaign used disposable stock and static-4096-fragment builds. The later instrumented priority-update run is separate from performance sampling. Aggregate primary statistics were externally inspected; individual raw primary samples were not independently replayed.

At a glance

Priority changed which response data arrived first. TLS fragmentation then changed how early parts of an already selected response could be decrypted. They are separate mechanisms, and smaller records are not a free optimization.

Primary matrix
240/240
Two workloads, four cases, 30 randomized repeats each
Priority, simultaneous
−27.8 ms
A→B paired median, complete critical DATA
Priority, late request
−29.1 ms
A→B paired median, complete critical DATA
Trace controls
2/2
Live update applied; completed-stream negative control passed
one HTTP/2 connectionRFC 9218nginx 1.31.716 KiB nginx SSL buffer4096-byte TLS experiment20 ms RTT / 20 Mbit/s

Question & mechanism

When does an HTTP/2 priority decision become client-visible? nginx 1.31.7 accepts the request Priority field and the HTTP/2 PRIORITY_UPDATE extension. Its scheduler orders eligible frames by effective urgency, then incremental state and stream ID. That scheduling decision does not extend to bytes already copied into nginx's SSL output buffer or committed to the TLS/TCP send path.

Static 4 KiB records can expose authenticated pieces earlier. They cannot undo a previous SSL_write(), reorder bytes already committed to TCP, or make nginx revisit frame scheduling four times simply because one write becomes four records. RFC 9218 provides scheduling advice, not a promise of strict response serialization.

Controlled method

Every sample uses a single TLS 1.3 connection with ALPN h2, never competing TCP connections. A deterministic hyper-h2 client verifies response bodies, SHA-256, stream/frame events, flow-control headroom, and TLS record boundaries reconstructed from the byte stream rather than inferred from TCP segments. The four cases keep nginx ssl_buffer_size 16k fixed:

CaseHTTP/2 request prioritiesBoringSSL max send fragment
AEqual urgency (u=3)Stock
BExplicit RFC 9218 urgenciesStock
CEqual urgency (u=3)Static 4096 bytes
DExplicit RFC 9218 urgenciesStatic 4096 bytes

Simultaneous: two 1 MiB background responses compete with a 4 KiB critical response submitted on the same connection. Late request: the critical stream arrives after at least 32 KiB of background DATA. The clean test path used an isolated namespace topology, MTU 1500, 20 ms RTT, 20 Mbit/s and zero configured loss. Thirty randomized repeats were completed for each cell in both workloads (240/240 passed).

For timing, the client distinguishes the first decrypted part of a DATA frame from a complete DATA frame emitted by hyper-h2. They may differ when a DATA frame spans several small TLS records. The tables below use the complete DATA-frame timestamp as the application-facing measure.

240-run results

First complete critical HTTP/2 DATA frame · per-cell median · milliseconds (lower is better)
Simultaneous
A · equal
100.93 ms
B · priority
73.11 ms
C · 4 KiB
111.76 ms
D · both
69.98 ms
Late Request
A · equal
87.73 ms
B · priority
58.71 ms
C · 4 KiB
62.74 ms
D · both
48.77 ms

A: equal urgency / stock TLS. B: RFC 9218 / stock TLS. C: equal urgency / 4 KiB fragments. D: RFC 9218 / 4 KiB fragments. Bars share a 0–120 ms scale. These are per-cell medians, not paired-difference medians.

WorkloadCaseFirst complete critical DATABackground before DATAWhole fixture completesTLS records
SimultaneousA100.933 ms128 KiB920.691 ms142
B73.114 ms64 KiB920.704 ms142
C111.764 ms160 KiB924.148 ms529
D69.979 ms64 KiB924.094 ms529
Late requestA87.726 ms224 KiB480.543 ms80
B58.707 ms144 KiB480.528 ms80
C62.742 ms160 KiB482.261 ms272
D48.768 ms128 KiB482.279 ms272

The per-cell numbers above are medians of 30 samples. A→B and B→D conclusions below instead use paired differences formed within each randomized repeat. The paired median need not exactly equal the subtraction of two cell medians.

ComparisonSimultaneous, complete DATALate request, complete DATAWhat changed?
A→B−27.803 ms−29.054 msExplicit RFC 9218 priority only
A→C+10.812 ms−25.000 msStatic 4 KiB records without priority
B→D−3.121 ms−9.921 ms4 KiB records with priority already active

For A→B and B→D, the observed paired complete-DATA differences were negative in all 30 repeats in each workload. But A→C was not uniformly harmful in the simultaneous case: its observed range crossed zero. The descriptive factorial interactions also differ in sign across workloads; this is not an additive, general optimization rule.

Record costs & trade-offs

When explicit priority is already enabled (B→D), the first decrypted critical DATA bytes moved forward by paired medians of 4.984 ms (simultaneous) and 11.736 ms (late). The corresponding complete HTTP/2 DATA frames improved less: 3.121 and 9.921 ms. Authentication of an early record is not completion of the H2 frame it carries.

Metric, B→DSimultaneousLate request
Additional TLS application records+387+192
Additional record overhead (22 B / record)+8,514 B+4,224 B
Whole-fixture completion, paired median+3.479 ms+1.764 ms
Observed median throughput B / D18.258 / 18.191 Mbit/s17.525 / 17.462 Mbit/s

CPU readings include disposable nginx startup and shutdown. Their ranges overlap; the experiment does not establish a production CPU penalty or equivalence. A smaller TLS record is also not a new HTTP/2 scheduling opportunity for bytes already passed downstream. The mixed A→C results are another reason not to set a default based solely on this fixture.

Live PRIORITY_UPDATE trace

A separate, synchronously instrumented build ran two single-repeat controls after the performance campaign. It recorded 810 events for the live dynamic case and 411 for completed-update. These traces are diagnostic and are excluded from the 240 performance samples.

Server-side state transition, not a promise of immediate delivery
before priority update
u=5, i=1

Active stream 3 has low urgency, incremental delivery requested.

PRIORITY_UPDATE received ↓
after update applied
u=0, i=0

nginx changes effective active-stream priority. The completed-stream negative control does not trigger an apply event.

state changed ✓

At the instant of live-stream apply, the instrumented test observed three distinct output counters:

Observed layerBytes pendingInterpretation
nginx SSL output buffer16,384User-space SSL output already pending
Kernel SIOCOUTQ173,760Queued and not yet acknowledged
Kernel SIOCOUTQNSD14,480Not yet sent; included in SIOCOUTQ, not additional

The live state change occurred 10.250 ms after the client sent the update. Subsequent instrumented output activity was visible approximately 4.3 ms after apply. The client observed the next complete stream-3 DATA frame 37.204 ms after apply, preceded by ten fully decoded 8 KiB background DATA frames at strictly earlier timestamps. One more background frame appears at the same timestamp as that DATA event, so no separate delay is assigned to it.

Mechanism established: nginx applies a live priority-state update while SSL and TCP output is already pending. The trace does not show that all already enqueued H2 frames were re-sorted, nor does it causally allocate the client-observed delay to a single queue. The code-level inspection found no explicit wholesale resort in the update handler.

Limits & project decision

  • Scope: 20 ms RTT, 20 Mbit/s, 0% configured loss, two synthetic workloads and a single shared h2 connection; no WAN/loss study or browser-rendering benchmark.
  • Evidence: 240/240 host-runner cases and aggregate statistics reviewed. All primary raw samples remain on the host and were not independently replayed in the external review. The two trace-control samples were independently replayed.
  • Statistics: 30 paired repeats per cell show a consistent pattern within the dataset, not distributional tail guarantees, universal equivalence or a proven interaction law.
  • Attribution: the trace proves active-state change and already pending output, not client-side speedup caused by that particular update or precise time spent in each queue.
  • Implementation: only the existing isolated static fragment patch was used. nginx and BoringSSL vendor source trees were not made into nxTLS forks.
  • Policy: this result does not select a default TLS fragment limit or change accepted nxTLS decisions, including D-015.

Priority decides what can go next. It cannot undo what has already gone downstream.

nxTLS boundary: The bounded experiment is complete. nginx 1.31.7's RFC 9218 behavior was effective in these tests, and static 4 KiB records provided a smaller, workload-dependent client-delivery benefit. Neither observation justifies a new production patch, release policy or upstream regression claim.

Reproduction & sources

RFC 9218 — Extensible Prioritization Scheme for HTTP ↗

nginx upstream PR #1520 — HTTP/2 priorities ↗

Reproduction: experiments/http2-priority-record-delivery/README.md, RESULTS.md, run.py, test_client.py, analyze.py, and trace-build.py. Full pinned revision: nginx 1.31.7, BoringSSL 594a44291086dcc7cef06cabec4c0b47afd674c0, Debian Testing x86-64 and Clang 21.1.8. No production service was altered.

← EXP 05 summary