Mastering QUIC and HTTP/3 with Nginx: The Complete Guide
QUIC makes every stream independent at the transport layer, so one lost packet stalls only its own stream instead of all of them as in HTTP/2 over TCP. Nginx needs 1.25.0+ and UDP/443 open, or it falls back to HTTP/2 without telling you.

The web has relied on TCP for over four decades. But modern applications—with their demands for real-time interactivity, mobile connectivity, and instant page loads—have exposed TCP’s fundamental limitations. Enter QUIC and HTTP/3: a complete reimagining of web transport that abandons TCP in favor of UDP to deliver a faster, more secure, and resilient internet.
In this comprehensive guide, we’ll explore the history and architecture of QUIC, understand why it solves problems that TCP cannot, and walk through a complete production-ready Nginx configuration.
TL;DR — QUIC and HTTP/3 on Nginx
6 key points- QUIC runs over UDP, not TCP: HTTP/3 maps HTTP semantics onto QUIC, which is built on UDP and mandates TLS 1.3.
- Solves HOL blocking: QUIC streams are independent at the transport layer, so a lost packet only stalls its own stream — not all multiplexed streams as in HTTP/2 over TCP.
- Faster handshakes and connection migration: combined transport+crypto handshake (1-RTT, or 0-RTT on resume) plus Connection IDs that survive network changes (Wi-Fi → mobile).
- Nginx needs 1.25.0+ with
--with-http_v3_moduleand a QUIC-capable SSL library (OpenSSL 3.5.1+, BoringSSL, QuicTLS, or LibreSSL 3.6+). - Core config: add
listen 443 quic reuseport;, enablehttp3 on, TLS 1.3, and theAlt-Svcheader;reuseportmust appear on exactly one listener per address:port. - Open UDP/443 on the firewall (server and cloud provider) or QUIC fails silently and clients fall back to HTTP/2; verify with
curl --http3, DevTools, orquic="h3"in the access log.
How I run this on my own infrastructure
jmrp.io serves HTTP/3 itself: both server blocks in production carry listen 443 quic; alongside the standard TCP listeners, so the site accepts QUIC and TLS-over-TCP connections side by side depending on what the client — and whatever sits between the client and the server — actually supports.
I didn’t take the Alt-Svc header’s word for it. Running curl --http3 straight against the live site returns HTTP/3 200: a completed QUIC handshake, not an inference drawn from a response header that a misconfigured server could still send while quietly falling back to HTTP/2 underneath. That distinction matters — plenty of “HTTP/3 enabled” checklists stop at the header and never confirm the protocol actually negotiated. It’s a small thing to verify, and it’s also the only way to know a QUIC deployment is real instead of aspirational: the header costs nothing to add, but only a client-side handshake proves UDP/443 is actually open and negotiating end to end.
Serving HTTP/3 and having it used are different questions, and only the second one needs data. jmrp.io sits behind Cloudflare, which terminates the client connection, so the protocol a visitor actually negotiates shows up in Cloudflare’s zone analytics rather than in my own access log. Over eight days — 19 to 27 August 2026, filtered to the jmrp.io host so the rest of the zone doesn’t drown it out — HTTP/3 carried 33.5% of the requests that negotiated either HTTP/2 or HTTP/3: 11,208 out of 33,411.
That denominator needs two sentences of defense, because the honest version of this measurement is mostly about what has to be thrown away. I excluded HTTP/1.1 entirely, and the exclusion is not cosmetic: current browsers settle on h2 through ALPN against Cloudflare, so a request that claims a browser user-agent and still arrives over HTTP/1.1 is almost always a scraper with a forged one. There were plenty of those; one “desktop Chrome” bucket came in at 45.8% HTTP/1.1, which is not something desktop Chrome does. Dropping HTTP/1.1 removes the obvious forgeries, but it cannot promise the rest are clean: the Free plan exposes no bot score, and the browser dimension is itself derived from the user-agent string, so no independent field is left to filter on. That is also why there is no adoption-by-browser table here — I’m not publishing per-browser percentages built on a denominator I can’t audit. The window is eight chained 24-hour queries, the longest range that plan will return, so it has eight two-minute seams in it: roughly 16 minutes missing in total.
Two things about that number. The first is that it is a snapshot of that window, not a standing property of the site. Run the same query over the seven complete UTC days from 26 August to 1 September 2026 and the share is 20.1% — 10,508 of 52,341 — and it swings from day to day:
| Day (UTC) | HTTP/3 | HTTP/2 + HTTP/3 | HTTP/3 share |
|---|---|---|---|
| 2026-08-26 | 2,013 | 9,046 | 22.3% |
| 2026-08-27 | 1,765 | 10,457 | 16.9% |
| 2026-08-28 | 1,630 | 7,142 | 22.8% |
| 2026-08-29 | 882 | 6,902 | 12.8% |
| 2026-08-30 | 1,544 | 6,423 | 24.0% |
| 2026-08-31 | 1,272 | 6,778 | 18.8% |
| 2026-09-01 | 1,402 | 5,593 | 25.1% |
| Total | 10,508 | 52,341 | 20.1% |
The second is that neither reading can be re-derived later. The Free plan retains httpRequestsAdaptiveGroups for a rolling eight days and refuses anything older with cannot request data older than 1w1d, so both figures are dated observations that the source itself will not serve again. The table is here for that reason, and it publishes both counts rather than only the daily percentage, because the percentages on their own would not be enough: the busiest day carries almost twice the traffic of the quietest, so the seven rounded shares cannot reconstruct the weighted 20.1% — their unweighted mean is 20.4%. With every numerator and denominator on the page, the total is arithmetic anyone can check.
One measured detail I did not expect. My origin does send the header this guide tells you to add — add_header Alt-Svc 'h3=":443"; ma=86400' always; sits in both TLS server blocks, and a request straight to the origin comes back carrying it. Through Cloudflare, that header is simply not there:
On the origin host itself, bypassing the CDN entirely:$ curl -sS -kD- -o /dev/null -H 'Host: jmrp.io' https://127.0.0.1/ alt-svc: h3=":443"; ma=86400 $ curl -sS -D- -o /dev/null --resolve jmrp.io:443:104.21.40.251 https://jmrp.io/ server: cloudflare (no alt-svc header in the response) $ dig +short HTTPS jmrp.io @1.1.1.1 1 . alpn="h3,h2" ipv4hint=104.21.40.251,172.67.158.146 ech=…
QUIC works anyway, as the handshake above already showed, because the advertisement lives somewhere else: the DNS HTTPS record for the domain carries alpn="h3,h2", and browsers that resolve that record learn about HTTP/3 before they open a single connection. This is Cloudflare’s behavior across its platform, not a peculiarity of my configuration — the edge terminates the connection and decides for itself what to advertise, and nothing in my Nginx config can change that. The practical lesson is narrow but worth having: if a CDN sits in front of the origin you configured for HTTP/3, the header you are testing for may not be the one your visitors ever see. Check the DNS record too.
Why did QUIC replace TCP for HTTP/3?
The QUIC protocol has an interesting evolution from a proprietary Google experiment to a full IETF standard.
| Year | Milestone | Significance |
|---|---|---|
| 2012 | Google begins QUIC development | Internal project to reduce web latency |
| 2013 | First QUIC traffic in Chrome | Early experiments with Google services |
| 2014 | Wide-scale gQUIC deployment | Chrome desktop uses QUIC for Google properties |
| 2016 | IETF QUIC Working Group formed | Formal standardization process begins |
| 2017 | IETF QUIC diverges from gQUIC | TLS 1.3 integration, general-purpose transport |
| 2021 | RFC 9000 published | QUIC Transport Protocol standardized |
| 2022 | RFC 9114 published | HTTP/3 standardized |
The QUIC RFC family
The complete QUIC specification spans multiple RFCs:
| RFC | Title | Description |
|---|---|---|
| RFC 9000 | QUIC Transport | Core protocol: packets, frames, streams, connection management |
| RFC 9001 | Using TLS to Secure QUIC | TLS 1.3 integration, key derivation, encryption levels |
| RFC 9002 | Loss Detection and Congestion Control | Packet loss recovery, RTT estimation, congestion algorithms |
| RFC 8999 | Version-Independent Properties | Behaviors common across all QUIC versions |
| RFC 9114 | HTTP/3 | HTTP semantics over QUIC |
Why QUIC? Understanding TCP’s limitations
To appreciate QUIC, we must understand why TCP—the backbone of the internet since 1974—struggles with modern web demands.
What is head-of-line (HOL) blocking?
This is the fundamental issue that QUIC solves. Imagine a highway with a single lane (TCP connection). If one car breaks down (packet loss), everyone behind must stop and wait, even if they’re going to different destinations.
The handshake latency problem
Establishing a secure TCP connection requires multiple round trips:
| Protocol | New Connection | Resumed Connection | Total RTTs |
|---|---|---|---|
| TCP + TLS 1.2 | TCP handshake + TLS handshake | TCP + abbreviated TLS | 3 RTT → 2 RTT |
| TCP + TLS 1.3 | TCP handshake + TLS 1.3 | TCP + TLS 0-RTT | 2 RTT → 1 RTT |
| QUIC | Combined handshake | 0-RTT early data | 1 RTT → 0 RTT |
The connection migration problem
TCP connections are identified by a 4-tuple: (source IP, source port, destination IP, destination port). When you switch from Wi-Fi to mobile data, your IP changes—and your TCP connection breaks.
QUIC architecture deep dive
Transport over UDP
QUIC is built on UDP rather than creating a new IP protocol. This was a pragmatic choice:
- No kernel changes required: UDP is universally supported
- Userspace implementation: Faster iteration and deployment
- Middlebox traversal: UDP passes through most NATs and firewalls
Packet structure
QUIC uses two packet types:
| Type | Header | Used For | Encryption |
|---|---|---|---|
| Long Header | Full connection info | Initial, Handshake, 0-RTT, Retry | Level-specific keys |
| Short Header | Minimal (post-handshake) | Application data (1-RTT) | Application keys |
Streams and flow control
QUIC streams are lightweight, multiplexed channels within a connection:
Stream ID Decoding: The Stream ID is a 62-bit integer where the least significant 2 bits function as a type header:
- Bit 0 (Initiator):
0= Client,1= Server - Bit 1 (Direction):
0= Bidirectional,1= Unidirectional
This creates 4 distinct address spaces:
- …00: Client Request/Response
- …01: Server Push (Deprecated)
- …10: Client Control Stream
- …11: Server Control Stream (QPACK)
Dual-Layer Flow Control: For a packet to be processed, it must pass two independent checks:
- Connection-Level: Is there
MAX_DATAcredit for the entire connection? - Stream-Level: Is there
MAX_STREAM_DATAcredit for this specific stream? If either is exhausted, transmission blocks until aWINDOW_UPDATEframe arrives.
The QUIC handshake: speed meets security
QUIC combines transport and cryptographic handshakes into a single exchange, dramatically reducing latency.
1-RTT handshake (first connection)
0-RTT handshake (resumed connection)
For clients that have connected before, QUIC allows sending encrypted data in the very first packet:
Security architecture
QUIC’s security is not optional—it’s built into the protocol from the ground up.
Four encryption levels
| Level | Keys Derived From | Used For | Forward Secrecy |
|---|---|---|---|
| Initial | Destination Connection ID | First packets, version negotiation | No |
| 0-RTT | Pre-shared key (PSK) | Early application data | No |
| Handshake | TLS handshake secrets | Handshake completion | Partial |
| Application (1-RTT) | TLS traffic secrets | All post-handshake data | Full (ECDHE) |
Packet protection
Every QUIC packet (except Initial) is protected with AEAD encryption:
| Packet Section | Component | Protection Level |
|---|---|---|
| Header | Flags | Protected (Header Protection) |
| Connection ID | Cleartext (for routing) | |
| Packet Number | Encrypted (Header Protection) | |
| Payload | Application Data | Fully Encrypted (AEAD) |
| Footer | Authentication Tag | 16-byte Integrity MAC |
Connection ID and privacy
DoS protection mechanisms
QUIC includes several anti-amplification and anti-spoofing measures:
- Address Validation: Servers send
RETRYpackets to validate client addresses - Anti-Amplification: Servers limit data sent before address validation (3x client data)
- Stateless Reset: Clean connection termination without state
HTTP/3: HTTP over QUIC
HTTP/3 is the mapping of HTTP semantics onto QUIC transport. It replaces HTTP/2’s binary framing with QUIC streams.
| Feature | HTTP/2 | HTTP/3 |
|---|---|---|
| Transport | TCP + TLS | QUIC (UDP + TLS 1.3) |
| Multiplexing | Stream frames in single connection | Native QUIC streams |
| HOL Blocking | Yes (at TCP layer) | No (independent streams) |
| Header Compression | HPACK | QPACK (handles out-of-order) |
| Server Push | Supported | Deprecated (rarely used) |
| Connection Migration | No | Yes |
Who uses HTTP/3 today?
As of 2026, HTTP/3 adoption is significant and growing:
| Metric | Value | Source |
|---|---|---|
| Websites using HTTP/3 | 36.6% | W3Techs (2026) |
| Chrome QUIC usage (subsequent) | 40% | APNIC Labs |
| Page load improvement (global) | 12.4% | Cloudflare Benchmarks |
| High-latency region improvement | 13.8% | DebugBear |
Nginx configuration: complete guide
Now let’s implement HTTP/3 on Nginx. This section covers everything from prerequisites to production deployment.
Prerequisites
Verify your Nginx installation:
nginx version: nginx/1.28.0 built with OpenSSL 3.5.0 8 Apr 2025 (running with OpenSSL 3.5.4 30 Sep 2025) configure arguments: … --with-http_v3_module …
How to install Nginx with HTTP/3 support
The packages nginx.org publishes for Debian and Ubuntu are built --with-http_v3_module against OpenSSL 3.5, so HTTP/3 needs no custom build. The same commands work on both distributions — /etc/os-release supplies the vendor and the codename.
sudo apt install curl gnupg2 ca-certificates
DISTRO=$(. /etc/os-release && echo "$ID")
CODENAME=$(. /etc/os-release && echo "$VERSION_CODENAME")
# Nginx signing key in its own keyring
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://nginx.org/keys/nginx_signing.key -o /tmp/nginx.key
# Check the fingerprints against the list nginx publishes at
# https://nginx.org/en/pgp_keys.html BEFORE trusting the key. The bundle
# carries several keys and they rotate, so compare the output rather than
# a number copied from a blog post.
gpg --show-keys --with-fingerprint /tmp/nginx.key
sudo gpg --dearmor -o /etc/apt/keyrings/nginx.gpg /tmp/nginx.key
# Mainline repository, restricted to that keyring
echo "deb [signed-by=/etc/apt/keyrings/nginx.gpg] https://nginx.org/packages/mainline/$DISTRO $CODENAME nginx" \
| sudo tee /etc/apt/sources.list.d/nginx.list
sudo apt update && sudo apt install nginx
# Confirm the build you just installed
nginx -V 2>&1 | tr ' ' '\n' | grep -E 'http_v3|OpenSSL'Only needed if you want a module the packages don’t carry. Nginx builds HTTP/3 against any SSL library exposing a QUIC API: OpenSSL 3.5+, BoringSSL or LibreSSL 3.6+. On a current distribution the system OpenSSL is enough — the official Debian 13 package is itself built against OpenSSL 3.5.6.
openssl version # 3.5.0 or newer
wget https://nginx.org/download/nginx-1.31.4.tar.gz
tar xzf nginx-1.31.4.tar.gz
cd nginx-1.31.4
./configure \
--with-http_v3_module \
--with-http_ssl_module \
--with-http_v2_module
make -j"$(nproc)"
sudo make installBasic configuration
Here’s the minimal configuration to enable HTTP/3:
server {
server_name example.com;
root /var/www/example.com;
# ==========================================
# LISTENERS: TCP (HTTP/1.1, HTTP/2) + UDP (HTTP/3)
# ==========================================
# Standard HTTPS over TCP
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
# QUIC/HTTP/3 over UDP
# 'reuseport' is critical for multi-worker performance
listen 443 quic reuseport;
listen [::]:443 quic reuseport;
# ==========================================
# SSL/TLS CONFIGURATION
# ==========================================
# TLS 1.3 is REQUIRED for QUIC
ssl_protocols TLSv1.3 TLSv1.2;
ssl_prefer_server_ciphers off;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# ==========================================
# HTTP/3 CONFIGURATION
# ==========================================
http3 on;
# Advertise HTTP/3 support to browsers
# ma=86400 means "cache this info for 24 hours"
add_header Alt-Svc 'h3=":443"; ma=86400' always;
location / {
try_files $uri $uri/ =404;
}
}Complete production configuration
Here’s a comprehensive configuration with all optimizations. The configuration is split into two files: the main nginx.conf for global settings and a site-specific configuration file.
/etc/nginx/nginx.conf
# Main context
worker_processes auto;
error_log /var/log/nginx/error.log warn;
events {
worker_connections 4096;
use epoll;
multi_accept on;
}
http {
# ==========================================
# HTTP/3 GLOBAL SETTINGS
# ==========================================
# Explicit for clarity (http3 defaults to 'on' in nginx 1.25.0+)
http3 on;
# Custom log format to track HTTP/3 connections
log_format quic '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" '
'proto="$server_protocol" quic="$http3"';
# MIME types
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Performance optimizations
sendfile on;
tcp_nopush on;
tcp_nodelay on;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css application/json application/javascript
text/xml application/xml application/xml+rss text/javascript;
# Include site configurations
include /etc/nginx/sites-enabled/*;
}The site-specific configuration includes all the directives for QUIC, TLS, headers, and logging:
/etc/nginx/sites-available/example.com.conf
server {
server_name example.com www.example.com;
root /var/www/example.com;
# ==========================================
# DUAL-STACK LISTENERS
# ==========================================
# TCP: HTTP/1.1 and HTTP/2 fallback
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
# UDP: QUIC/HTTP/3
listen 443 quic reuseport;
listen [::]:443 quic reuseport;
# ==========================================
# TLS CONFIGURATION (Required for QUIC)
# ==========================================
ssl_protocols TLSv1.3 TLSv1.2;
# TLS 1.3 ciphersuites (handled separately for better OpenSSL compatibility)
ssl_conf_command Ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256;
# TLS 1.2 ciphers only (ECDHE for forward secrecy)
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_ecdh_curve X25519:P-256:P-384;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# Session resumption for performance
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off; # Better security
# No OCSP stapling. Let's Encrypt removed the OCSP URL from its
# certificates in May 2025 and shut its responders down in August 2025,
# so with this certificate ssl_stapling only logs "no OCSP responder URL"
# and does nothing. Keep it only for a CA that still runs OCSP.
# ==========================================
# QUIC-SPECIFIC SETTINGS
# ==========================================
# Enable 0-RTT early data (with security considerations)
ssl_early_data on;
# DoS protection: require address validation
quic_retry on;
# Performance: Generic Segmentation Offload (Linux 4.18+)
quic_gso on;
# ==========================================
# HEADERS
# ==========================================
# Advertise HTTP/3 availability
add_header Alt-Svc 'h3=":443"; ma=86400' always;
# Warn backends about 0-RTT replay risk
add_header Early-Data $ssl_early_data always;
# Security headers
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
# ==========================================
# LOGGING
# ==========================================
access_log /var/log/nginx/example.com.access.log quic;
error_log /var/log/nginx/example.com.error.log;
# ==========================================
# LOCATIONS
# ==========================================
location / {
try_files $uri $uri/ =404;
}
# Static assets with long cache
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable" always;
add_header Alt-Svc 'h3=":443"; ma=86400' always;
# Security headers (inherited from server block may not apply with add_header in location)
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
}
}
# HTTP to HTTPS redirect
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}All QUIC directives reference
| Directive | Default | Context | Description |
|---|---|---|---|
http3 | on | http, server | Enables HTTP/3 protocol negotiation |
http3_hq | off | http, server | Enables HTTP/0.9 over QUIC (testing only) |
http3_max_concurrent_streams | 128 | http, server | Maximum concurrent streams per connection |
http3_stream_buffer_size | 64k | http, server | Buffer size for reading/writing streams |
quic_active_connection_id_limit | 2 | http, server | Max connection IDs stored per connection |
quic_bpf | off | main | eBPF routing for connection migration (Linux 5.7+) |
quic_gso | off | http, server | Generic Segmentation Offload (Linux 4.18+) |
quic_host_key | - | http, server | File with secret key for address validation tokens |
quic_retry | off | http, server | Enables address validation via Retry packets |
Firewall configuration
Critical: HTTP/3 uses UDP on port 443, not TCP. If your firewall only allows TCP/443, QUIC will fail silently and clients will fall back to HTTP/2.
# Check current rules
sudo ufw status
# Allow UDP on port 443
sudo ufw allow 443/udp comment 'QUIC/HTTP3'
# Verify
sudo ufw status verbose# Allow incoming UDP 443
sudo iptables -A INPUT -p udp --dport 443 -j ACCEPT
# Save rules (Debian/Ubuntu)
sudo iptables-save | sudo tee /etc/iptables/rules.v4
# For IPv6
sudo ip6tables -A INPUT -p udp --dport 443 -j ACCEPT
sudo ip6tables-save | sudo tee /etc/iptables/rules.v6# Add UDP 443 to default zone
sudo firewall-cmd --permanent --add-port=443/udp
# Reload
sudo firewall-cmd --reload
# Verify
sudo firewall-cmd --list-portsAdd an inbound rule:
- Type: Custom UDP
- Port Range: 443
- Source: 0.0.0.0/0 (or your CIDR)
- Description: QUIC/HTTP3
Kernel tuning for high traffic
For high-traffic servers, increase UDP buffer sizes:
# Increase UDP buffer sizes for QUIC
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
net.core.rmem_default = 1048576
net.core.wmem_default = 1048576
# UDP memory limits
net.ipv4.udp_mem = 65536 131072 262144
net.ipv4.udp_rmem_min = 8192
net.ipv4.udp_wmem_min = 8192
# Allow more local ports for connections
net.ipv4.ip_local_port_range = 1024 65535Apply the settings:
Verification and testing
After configuration, verify HTTP/3 is working correctly.
Method 1: curl
Modern curl (7.66+ with HTTP/3 support) can test directly:
HTTP/3 200 server: jmrp.io date: Wed, 14 Jan 2026 20:02:32 GMT content-type: text/html; charset=utf-8 alt-svc: h3=":443"; ma=86400 strict-transport-security: max-age=63072000; includeSubDomains; preload
Method 2: browser DevTools
- Open Chrome or Firefox
- Navigate to your site
- Open DevTools (F12) → Network tab
- Right-click column headers → Enable Protocol column
- Reload the page
- Look for
h3in the Protocol column
Method 3: online tools
| Tool | URL | Features |
|---|---|---|
| HTTP/3 Check | http3check.net | Quick pass/fail test with details |
| Cloudflare HTTP/3 Test | cloudflare-quic.com | Live connection test |
| Qualys SSL Labs | ssllabs.com | Comprehensive TLS analysis |
Method 4: check Nginx logs
Use the custom log format to verify HTTP/3 traffic:
192.168.1.100 - - [14/Jan/2026:20:02:32 +0000] "GET / HTTP/3" 200 15234 "-" "Mozilla/5.0…" proto="HTTP/3" quic="h3"
Troubleshooting guide
| Symptom | Likely Cause | Solution |
|---|---|---|
| Protocol stays HTTP/2 | Firewall blocking UDP/443 | Open UDP port 443 on server and cloud provider |
| Protocol stays HTTP/2 | Missing Alt-Svc header | Add add_header Alt-Svc 'h3=":443"; ma=86400' always; |
| Connection errors | TLS 1.3 not enabled | Enable TLSv1.3 for QUIC (required); TLSv1.2 may remain enabled for HTTP/2 fallback |
| Browsers refuse to use QUIC | Self-signed certificate | Use a valid certificate (Let’s Encrypt) |
| Nginx fails to start | Missing --with-http_v3_module | Rebuild Nginx with HTTP/3 module |
| High CPU usage | GSO not supported by kernel | Disable quic_gso or upgrade kernel |
| 0-RTT not working | OpenSSL version too old | Use OpenSSL 3.5.1+, QuicTLS, or BoringSSL |
Debug mode
Enable debug logging temporarily:
error_log /var/log/nginx/error.log debug;Check for QUIC-specific messages:
2026/01/14 10:42:15 [debug] 12345#0: *1 quic handle packet: fd:16, addr:192.0.2.1:54321 2026/01/14 10:42:15 [debug] 12345#0: *1 quic packet rx dcid len:8 87654321 2026/01/14 10:42:15 [debug] 12345#0: *1 quic packet rx scid len:8 12345678 2026/01/14 10:42:15 [info] 12345#0: *1 quic SSL_do_handshake() failed: SSL_ERROR_SSL: error:14094416:SSL routines:ssl3_read_bytes:sslv3 alert certificate unknown 2026/01/14 10:42:15 [debug] 12345#0: *1 quic close connection: 0:
Production deployment checklist
Before deploying HTTP/3 to production, verify:
| Category | Item | Verification |
|---|---|---|
| Build | Nginx compiled with --with-http_v3_module | nginx -V 2>&1 | grep http_v3 |
| Build | Compatible SSL library (QuicTLS/BoringSSL/OpenSSL 3.5.1+) | nginx -V 2>&1 | grep -i ssl |
| Network | UDP/443 open on server firewall | sudo ss -ulnp | grep 443 |
| Network | UDP/443 open on cloud provider (AWS/GCP/Azure) | Check security group/firewall rules |
| Config | listen 443 quic reuseport; present | nginx -T | grep quic |
| Config | TLS 1.3 enabled | nginx -T | grep ssl_protocols |
| Config | Alt-Svc header configured | curl -I https://example.com | grep alt-svc |
| Certificate | Valid (not self-signed) certificate | openssl s_client -connect site:443 |
| Test | HTTP/3 confirmed working | curl --http3 https://example.com |
| Monitoring | Log format includes $http3 | Check log format configuration |
When should you not use QUIC?
While QUIC offers significant benefits, there are scenarios where TCP may be preferable:
Frequently asked questions
What do I need to enable HTTP/3 in Nginx?
You need Nginx 1.25.0 or newer (HTTP/3 support was added mid-2023) compiled with the --with-http_v3_module, plus an SSL library with QUIC support such as OpenSSL 3.5.1+, BoringSSL, QuicTLS, or LibreSSL 3.6+. TLS 1.3 is required for QUIC.
Does HTTP/3 use TCP or UDP?
HTTP/3 runs over QUIC, which is built on UDP rather than TCP. You must open UDP port 443 on both the server firewall and any cloud provider firewall, or QUIC fails silently and clients fall back to HTTP/2.
How does QUIC solve TCP's head-of-line blocking?
TCP treats everything as one ordered byte stream, so a single lost packet blocks all multiplexed HTTP/2 streams until it is retransmitted. QUIC implements streams at the transport layer, giving each stream independent delivery, so a lost packet only affects its own stream.
Why does my browser still show HTTP/2 on the first visit?
Seeing HTTP/2 on a first visit is expected: a browser that has never connected to the origin has no way of knowing it speaks HTTP/3, so it opens a TCP connection first. During that request the browser learns about HTTP/3 — from the Alt-Svc response header, or from a DNS HTTPS record advertising alpn=h3 — and switches to HTTP/3 on subsequent requests or after a reload.
Why is the reuseport flag critical for QUIC in Nginx?
UDP is connectionless, so without reuseport all QUIC packets are handled by a single Nginx worker, creating a bottleneck. The reuseport kernel feature distributes packets across workers, but it must appear on exactly one listen directive per address:port globally or Nginx fails to start.
How do I verify HTTP/3 is working?
Use modern curl with curl -I --http3 https://yoursite, enable the Protocol column in browser DevTools and look for h3, use online tools like http3check.net or cloudflare-quic.com, or grep your Nginx access log for quic="h3".
Further Reading & Resources
- RFC 9000 IETF
- RFC 9001 IETF
- RFC 9002 IETF
- RFC 8999 IETF
- RFC 9114 IETF
- W3Techs (2026) w3techs.com
- APNIC Labs blog.apnic.net
- Cloudflare Benchmarks blog.cloudflare.com
- DebugBear debugbear.com
- http3 Nginx
- http3_hq Nginx
- http3_max_concurrent_streams Nginx
- http3_stream_buffer_size Nginx
- quic_active_connection_id_limit Nginx
- quic_bpf Nginx
- quic_gso Nginx
- quic_host_key Nginx
- quic_retry Nginx
- http3check.net http3check.net
- cloudflare-quic.com cloudflare-quic.com
- ssllabs.com Qualys SSL Labs