yum upgrades for production use, this is the repository for you.
Active subscription is required.
We rebuilt nginx-mod with Cloudflare’s 2015 nginx dynamic TLS records patch (ngx_http_tls_dyn_size) and benchmarked nginx dynamic TLS records inside a controlled rig: 200 cold HTTP/2 connections per condition, randomized interleave, CPU-pinned client and server, tc netem delay 50ms loss 1%. Under proper isolation the patch is mostly noise, defaults can hurt, and Cloudflare themselves stopped using it. Here’s the data, and what to do instead.
The Problem: TLS Tail Latency on Lossy Links
NGINX’s default ssl_buffer_size is 16 KB. Each TLS record straddles roughly 12 TCP segments. If a single segment drops on a lossy link, the receiver cannot deliver any byte of the record until retransmission completes. On a connection mid-slow-start, that adds at least one full RTT to TTFB.
Cloudflare’s 2015 dynamic_tls_records patch attacked this by ramping record size from one segment up to the full 16K over the connection’s lifetime. Their own production has since reverted to a static 4K record (README), and our bench below shows why.
What Cloudflare Does Today
From the upstream patch’s README:
“What we do now: We use a static record size of 4K. This gives a good balance of latency and throughput.”
That’s Cloudflare’s own statement, in the README of the repo where the patch originated. They wrote the patch, but their own production rolled back to ssl_buffer_size 4k – a one-line static configuration change with no patched binary required.
The simpler alternative – lower ssl_buffer_size to 4 KB – is one line of nginx config and doesn’t need a patched binary. The bench below tests both against default 16K to see which one the data actually supports.
The Solution, Ranked
Six fixes for HTTPS tail latency, ranked by leverage. Pick the highest-leverage one your stack supports today.
1. Run HTTP/3
QUIC frames data at the packet layer. There is no “TLS record straddling TCP segments” because there is no TCP. The whole problem class disappears. If your workload includes mobile and last-mile cellular eyeballs – the audience the patch was designed for – migrating to HTTP/3 is the actual fix, not patching TLS record sizing on TCP. See How to Enable NGINX HTTP/3 on Ubuntu and Debian and Enable NGINX HTTP/3 on RHEL, Rocky, CentOS, Fedora.
2. Set ssl_buffer_size 4k
One line. No patched binary. Matches what Cloudflare runs in production. Approximately ties the patch in our bench. Drop it in your http {} block:
http {
ssl_buffer_size 4k;
...
}
This is the highest leverage single-line change for HTTPS-over-TCP tail latency in 2026.
3. Tune buffers from real traffic, not from blog posts
Tail-latency tuning by guessing static numbers is a coin flip. The right way is to measure your actual response size distribution and pick buffer settings from the data. GetPageSpeed maintains the ngx_http_tuning module, a passive observer that builds histograms of header sizes, body sizes, and response times across all your traffic and emits a JSON recommendation. As of v1.3.0 the recommendation block includes ssl_buffer_size, derived from the same response body distribution it already tracks for proxy_buffers:
{
"sample_size": 847293,
"proxy_buffer_size": {
"observed": { "avg": "1.8k", "max": "23.4k", "p95_approx": "4.0k" },
"recommendation": "OK",
"suggested_value": "4k",
"reason": "95% of headers fit in 4k"
},
"ssl_buffer_size": {
"observed": { "avg": "8.4k", "max": "156.2k", "p95_approx": "1.0k" },
"recommendation": "REDUCE",
"suggested_value": "4k",
"reason": "p95 response body fits in 4k; static 4k matches Cloudflare's production setting and reduces TLS head-of-line blocking on lossy links without a patched binary"
},
"nginx_config": {
"snippet": "proxy_buffer_size 4k;\nproxy_buffers 8 4k;\nproxy_read_timeout 10s;\nclient_body_buffer_size 8k;\nssl_buffer_size 4k;",
"apply_to": "http, server, or location block"
}
}
The logic mirrors the bench results above. If your p95 response body fits inside 4 KB, the module recommends REDUCE to 4k – matching what Cloudflare runs in production. If most of your responses exceed one TLS record, it stays at OK at 16k because dynamic record sizing buys nothing in that regime. For bimodal traffic (small p95 with a non-trivial tail above 64 KB), an optional dyn_rec_advisory string surfaces in the JSON pointing nginx-mod users at the ssl_dyn_rec_* directives. Stock nginx never sees those directives in the always-emitted snippet itself – they live only in the advisory text – so nginx_config.snippet is always copy-paste deployable on the upstream binary.
Install (RHEL, Rocky, Alma, CentOS Stream, Amazon Linux, Fedora):
sudo dnf install https://extras.getpagespeed.com/release-latest.rpm
sudo dnf install nginx-module-tuning
Or on Debian / Ubuntu:
curl -sSL https://nginx-extras.getpagespeed.com/setup.deb.sh | sudo bash
sudo apt-get install nginx-module-tuning
The package drops /usr/share/nginx/modules/tuning.conf containing the load_module line. Pull it in by adding a single include at the top of /etc/nginx/nginx.conf:
include /usr/share/nginx/modules/*.conf;
Then enable the advisor and expose its status endpoint:
tuning_advisor on;
server {
listen 127.0.0.1:8080;
location = /tuning-advisor {
tuning_advisor_status;
allow 127.0.0.1;
deny all;
}
}
Reload nginx, let it observe a few hours of real traffic, then curl http://127.0.0.1:8080/tuning-advisor | jq . and paste nginx_config.snippet straight into your http {} block. The full feature reference is in NGINX Tuning Module: Data-Driven Buffer Optimization.
4. Enable tcp_notsent_lowat=16384 and BBR
Cloudflare’s 2018 follow-up post, Optimizing HTTP/2 prioritization with BBR and tcp_notsent_lowat, measured first-paint improvements much larger than the TLS record sizing patch ever produced. Setting net.ipv4.tcp_notsent_lowat=16384 and net.ipv4.tcp_congestion_control=bbr is two sysctl entries:
# /etc/sysctl.d/99-tls-tail.conf
net.ipv4.tcp_notsent_lowat = 16384
net.ipv4.tcp_congestion_control = bbr
Both knobs target the same workload (slow start plus congested last mile) but at the TCP layer, where the actual problem lives.
5. Compress aggressively
A response that fits inside ssl_buffer_size after Brotli compression never crosses a record boundary. Brotli quality 6 typically halves payload sizes vs gzip. GetPageSpeed ships the Brotli module in the same repository as nginx-mod.
6. The dynamic_tls_records patch, if you really want it
The patch ships five directives controlling how record size ramps up over a connection’s lifetime:
| Directive | Default | Purpose |
|---|---|---|
ssl_dyn_rec_enable |
off |
Master toggle |
ssl_dyn_rec_size_lo |
1369 |
Initial record size (fits 1 TCP segment) |
ssl_dyn_rec_size_hi |
4229 |
Mid-stage record size (fits 3 segments) |
ssl_dyn_rec_threshold |
40 |
Records per stage before bumping up |
ssl_dyn_rec_timeout |
1s |
Idle period before resetting to size_lo |
After 40 records the connection bumps to 4229 bytes per record; after 80 records it goes to full ssl_buffer_size. NGINX-MOD ships the patch but disables it by default. If after all of the above you still have a workload mix dominated by 50 KB to 200 KB responses on a lossy last mile, the tuned profile is the only configuration that earned its complexity in our bench:
# Only worth enabling for mid-size responses on lossy links
ssl_dyn_rec_enable on;
ssl_dyn_rec_size_lo 1369;
ssl_dyn_rec_size_hi 16384;
ssl_dyn_rec_threshold 10;
ssl_dyn_rec_timeout 5s;
This requires nginx-mod (the patch is in our build but disabled by default upstream). Don’t expect dramatic numbers from nginx dynamic TLS records. Expect a 20 to 28 percent p95 TTFB reduction on mid-size responses, no measurable change anywhere else, and nothing on the median user.
The Controlled Bench
| Component | Choice |
|---|---|
| VM | Rocky Linux 10 ARM, fresh snapshot, 2 vCPU |
| nginx | nginx-mod 1.30.0-45 with dynamic_tls_records.patch re-enabled |
| Workers | worker_processes 1; worker_cpu_affinity 0001; (pinned to CPU 0) |
| Client | taskset -c 1 curl -ks --http2 (pinned to CPU 1) |
| Network | tc qdisc add dev lo root netem delay 50ms loss 1% (100 ms RTT, 1% per-direction packet loss) |
| Conditions | 4 server blocks on 4 ports, same nginx process |
| Sample size | 200 cold connections per (size × condition) |
| Total | 200 × 4 conditions × 3 sizes = 2400 cold HTTP/2 connections |
| Order | Plan generated, then shuf to randomize. Every condition’s reqs scattered across the run. |
| Cache | echo 3 > /proc/sys/vm/drop_caches once before run |
| TLS | TLSv1.3 + TLSv1.2, ECDHE-AES128-GCM-SHA256 by default |
The four conditions:
# 8443: baseline - stock NGINX behavior
server { listen 8443 ssl; http2 on; ssl_buffer_size 16k; ... }
# 9443: dyn_rec defaults from the patch
server {
listen 9443 ssl; http2 on; ssl_buffer_size 16k;
ssl_dyn_rec_enable on;
ssl_dyn_rec_size_lo 1369;
ssl_dyn_rec_size_hi 4229;
ssl_dyn_rec_threshold 40;
ssl_dyn_rec_timeout 1s;
...
}
# 9444: dyn_rec tuned - fast ramp
server {
listen 9444 ssl; http2 on; ssl_buffer_size 16k;
ssl_dyn_rec_enable on;
ssl_dyn_rec_size_lo 1369;
ssl_dyn_rec_size_hi 16384;
ssl_dyn_rec_threshold 10;
ssl_dyn_rec_timeout 1s;
...
}
# 9445: static 4K - what Cloudflare actually runs
server { listen 9445 ssl; http2 on; ssl_buffer_size 4k; ... }
Random interleave means a baseline request, then a static-4K, then a dyn_rec, then a tuned, in shuffled order, all serviced by the same nginx process under the same netem state. Any system noise (a cron tick, an SSH heartbeat, a netem PRNG draw) hits all four conditions with equal probability.
Results
size | configuration | n | TTFB med | TTFB p95 | TTFB p99 | Total med | Total p95
10 KB | baseline (16K) | 200 | 413.1 | 646.9 | 1542.8 | 413.4 | 724.8
10 KB | dyn_rec default | 200 | 411.7 | * 613.9 | 1518.7 | 512.5 | * 656.9
10 KB | dyn_rec tuned | 200 | 413.6 | 719.2 | 1466.4 | 514.6 | 822.2
10 KB | static 4K | 200 | 412.5 | 707.0 | 1459.5 | 412.9 | 723.6
100 KB | baseline (16K) | 200 | 413.4 | 721.4 | 1481.6 | 516.1 | 1043.3
100 KB | dyn_rec default | 200 | 412.9 | 718.8 | 1481.4 | 615.8 | 922.5
100 KB | dyn_rec tuned | 200 | 412.3 | * 518.7 | 1462.1 | 614.4 | * 828.4
100 KB | static 4K | 200 | 413.0 | 604.7 | 1426.2 | 515.9 | 825.1
1 MB | baseline (16K) | 200 | 412.9 | * 608.1 | 1449.5 | 920.0 | * 1343.4
1 MB | dyn_rec default | 200 | 412.8 | 738.1 | 1523.4 | 1007.8 | 1480.3
1 MB | dyn_rec tuned | 200 | 412.5 | 714.2 | 1452.5 | 1010.1 | 1342.5
1 MB | static 4K | 200 | 412.1 | * 607.4 | 1524.1 | 921.9 | 1424.3
Asterisks mark the best value in each column for that size. All times in milliseconds.
What The Numbers Actually Say
Median TTFB is identical across all four configurations. Every single row’s median TTFB sits at 412 to 413 ms – the cost of the TLS 1.3 handshake plus one round trip. On the typical request that doesn’t see a packet drop, the patch is invisible. There is no “first paint improvement” from dynamic TLS records on the median user.
p95 TTFB swings 5 to 30 percent, much of it inside the noise band. Compare:
- 10 KB: dyn_rec default 614 vs baseline 647 – 5% improvement. Within noise.
- 100 KB: dyn_rec tuned 519 vs baseline 721 – 28% improvement. Real, repeatable.
- 1 MB: dyn_rec default 738 vs baseline 608 – 21% worse. The patch hurts large files.
Under proper isolation, dyn_rec defaults are net negative on large file delivery.
Static 4K matches baseline on most metrics. ssl_buffer_size 4k ties baseline 16K on 10 KB TTFB p95 (707 vs 647), 1 MB TTFB p95 (607 vs 608), and 1 MB Total p95 (1424 vs 1343). It modestly beats baseline on 100 KB Total p95 (825 vs 1043). The Cloudflare static configuration is approximately equivalent to default 16K on tail latency and 20% better on mid-size total transfer time.
The only consistent winner is dyn_rec with tuned settings on mid-size responses. threshold=10, size_hi=16384 (skip the middle stage, ramp to full record size after 10 small records) wins p95 TTFB on 100 KB by 28% and ties everything else on 10 KB and 1 MB. That’s a real but narrow win.
No configuration helps the p99 tail. Every condition’s p99 TTFB sits between 1426 and 1542 ms – the case where two or more segments drop on the same response. Dynamic record sizing helps when one segment drops; it does not help when retransmits compound.
Why NGINX-MOD still ships the nginx dynamic TLS records patch
After running this bench we considered dropping nginx dynamic TLS records from nginx-mod entirely. We decided to keep it for three reasons:
- Optional. With
ssl_dyn_rec_enable off(the default) the patch costs nothing – same code paths as stock nginx. - Narrow but real wins exist. The 100 KB p95 TTFB win with tuned settings is consistent and measurable. Mid-size response workloads are real (image-heavy CMS pages, JSON API list endpoints with embedded relations).
- Community fork stays current. nginx-modules/ngx_http_tls_dyn_size tracks modern nginx releases (verified through 1.29.2+). The cost of carrying it in our build is near zero.
We are not, however, recommending it as a default-on optimization. The honest position: enable it only when you know your workload sits in its narrow benefit zone, and verify with a controlled benchmark on your actual traffic.
Directive Context Cross-Check
The five ssl_dyn_rec_* directives, verified against the C source in dynamic_tls_records.patch and tested at runtime in our patched nginx-mod binary:
| Directive | Source flags | Allowed in | Verified |
|---|---|---|---|
ssl_dyn_rec_enable |
NGX_HTTP_MAIN_CONF\|NGX_HTTP_SRV_CONF |
http, server | Yes |
ssl_dyn_rec_timeout |
NGX_HTTP_MAIN_CONF\|NGX_HTTP_SRV_CONF |
http, server | Yes |
ssl_dyn_rec_size_lo |
NGX_HTTP_MAIN_CONF\|NGX_HTTP_SRV_CONF |
http, server | Yes |
ssl_dyn_rec_size_hi |
NGX_HTTP_MAIN_CONF\|NGX_HTTP_SRV_CONF |
http, server | Yes |
ssl_dyn_rec_threshold |
NGX_HTTP_MAIN_CONF\|NGX_HTTP_SRV_CONF |
http, server | Yes |
ssl_buffer_size |
NGX_HTTP_MAIN_CONF\|NGX_HTTP_SRV_CONF |
http, server | Yes |
Negative test: ssl_dyn_rec_enable on; placed inside a location {} block fails with nginx: [emerg] "ssl_dyn_rec_enable" directive is not allowed here. Confirmed.
Reproducing The Bench
Everything in this article reruns on a clean Rocky Linux 10 VM in about 35 minutes. The full script is below. Copy, paste, run.
# Install nginx-mod (Rocky Linux 10)
sudo dnf install https://extras.getpagespeed.com/release-latest.rpm
sudo dnf config-manager --enable getpagespeed-extras-nginx-mod
sudo dnf swap nginx nginx-mod
# Pin worker to CPU 0
sudo sed -i 's/^worker_processes.*/worker_processes 1;/' /etc/nginx/nginx.conf
echo 'worker_cpu_affinity 0001;' | sudo tee -a /etc/nginx/nginx.conf
# Drop the four-condition test config in (see the four server blocks above)
sudo tee /etc/nginx/conf.d/dyn-test.conf > /dev/null <<EOF
# (the four server blocks from the "Controlled Bench" section)
EOF
# Generate test files
sudo mkdir -p /usr/share/nginx/html/test
for sz in 10k 100k 1M; do
sudo dd if=/dev/urandom of=/usr/share/nginx/html/test/file-$sz \
bs=$sz count=1 2>/dev/null
done
# Apply WAN simulation
sudo tc qdisc add dev lo root netem delay 50ms loss 1%
sudo systemctl restart nginx
# Generate randomized plan
> plan.txt
for cond in base16 dyn tuned static4; do
for sz in 10k 100k 1M; do
for i in $(seq 1 200); do
echo "$cond:$sz" >> plan.txt
done
done
done
shuf plan.txt > plan.shuf
declare -A PORTS
PORTS[base16]=8443
PORTS[dyn]=9443
PORTS[tuned]=9444
PORTS[static4]=9445
echo 'cond,size,ttfb,total' > results.csv
while IFS=: read cond sz; do
port=${PORTS[$cond]}
t=$(taskset -c 1 curl -ks --http2 -o /dev/null \
-w '%{time_starttransfer}|%{time_total}' \
"https://localhost:$port/test/file-$sz")
echo "$cond,$sz,${t%|*},${t#*|}" >> results.csv
done < plan.shuf
Then aggregate with Python’s statistics.median and an index lookup at [int(n*0.95)] for p95.
How This Fits With Other NGINX-MOD Articles
We try to publish the methodology behind every claim, including the ones that come out unflattering. Other NGINX-MOD posts in this style:
- NGINX slow_start: Gradual Upstream Ramp-Up Without Plus – implements an NGINX Plus exclusive feature in open-source, validated with a 200-request ramp curve that tracks the theoretical line within 0.5%.
- NGINX HTTP/3 Reload: Why QUIC Connections Fail – shows the upstream bug, reproduces it, ships the fix.
- NGINX Active Health Checks – six probe types with a status dashboard.
- NGINX Dynamic Upstream – NGINX Plus API parity for runtime upstream management.
What you won’t find here is “this thing is amazing because we said so.” Numbers or it didn’t happen.
Wrapping Up
Cloudflare’s nginx dynamic TLS records patch is a well-engineered solution to a real problem. In 2015, when TCP initial congestion windows were smaller, when HTTP/3 didn’t exist, when last-mile loss was higher and more correlated, the dynamic record sizing argument was strong. In 2026, with IW10 universal, HTTP/3 production-ready on every major browser, and Cloudflare themselves moved to a static 4K configuration, nginx dynamic TLS records is mostly a niche optimization for mid-size responses on lossy links – and only with non-default tuning.
If you want a one-line tail-latency improvement: ssl_buffer_size 4k; in your http {} block.
If you want a data-driven approach instead of guessing: install ngx_http_tuning v1.3.0, let it observe a few hours of real traffic, and paste nginx_config.snippet straight into your config – it now includes ssl_buffer_size alongside the proxy buffer suggestions.
If you want nginx dynamic TLS records anyway: NGINX-MOD ships it, the tuned profile is the only one we measured a clean win on, and we are not going to oversell what it does.
Get NGINX-MOD from the GetPageSpeed repository, browse the full NGINX-MOD feature set, and read the HTTP/3 setup guide for the actual fix.
