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.
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.
↓ HTTP/2 eligible frame queue
↓ nginx frame selection
↓ nginx SSL output buffer (16 KiB)
↓ BoringSSL TLS record layer (stock / 4096 B)
↓ TCP queued / in flight / acknowledged
↓ client TLS decryption → complete HTTP/2 DATA frame
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:
| Case | HTTP/2 request priorities | BoringSSL max send fragment |
|---|---|---|
| A | Equal urgency (u=3) | Stock |
| B | Explicit RFC 9218 urgencies | Stock |
| C | Equal urgency (u=3) | Static 4096 bytes |
| D | Explicit RFC 9218 urgencies | Static 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
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.
| Workload | Case | First complete critical DATA | Background before DATA | Whole fixture completes | TLS records |
|---|---|---|---|---|---|
| Simultaneous | A | 100.933 ms | 128 KiB | 920.691 ms | 142 |
| B | 73.114 ms | 64 KiB | 920.704 ms | 142 | |
| C | 111.764 ms | 160 KiB | 924.148 ms | 529 | |
| D | 69.979 ms | 64 KiB | 924.094 ms | 529 | |
| Late request | A | 87.726 ms | 224 KiB | 480.543 ms | 80 |
| B | 58.707 ms | 144 KiB | 480.528 ms | 80 | |
| C | 62.742 ms | 160 KiB | 482.261 ms | 272 | |
| D | 48.768 ms | 128 KiB | 482.279 ms | 272 |
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.
| Comparison | Simultaneous, complete DATA | Late request, complete DATA | What changed? |
|---|---|---|---|
| A→B | −27.803 ms | −29.054 ms | Explicit RFC 9218 priority only |
| A→C | +10.812 ms | −25.000 ms | Static 4 KiB records without priority |
| B→D | −3.121 ms | −9.921 ms | 4 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→D | Simultaneous | Late 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 / D | 18.258 / 18.191 Mbit/s | 17.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.
u=5, i=1Active stream 3 has low urgency, incremental delivery requested.
u=0, i=0nginx changes effective active-stream priority. The completed-stream negative control does not trigger an apply event.
At the instant of live-stream apply, the instrumented test observed three distinct output counters:
| Observed layer | Bytes pending | Interpretation |
|---|---|---|
| nginx SSL output buffer | 16,384 | User-space SSL output already pending |
Kernel SIOCOUTQ | 173,760 | Queued and not yet acknowledged |
Kernel SIOCOUTQNSD | 14,480 | Not 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.
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.
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.