HTTP/3 runs over QUIC and UDP, commonly on port 443. HTTP/2 streams on a TCP connection share an ordered byte stream, so a missing segment can stall delivery for unrelated requests. QUIC provides ordered delivery per stream: losing data on one stream need not block delivery on another. Connection-wide congestion control and HTTP dependencies can still affect several streams; this is not independent congestion control for every stream.

Enabling a server module alone is insufficient. The client needs HTTP/3 support, an authoritative endpoint, acceptable TLS credentials, and a working UDP path. Discovery and fallback policies determine when that path is attempted.

Two transports, one port number

UDP:443 and TCP:443 use separate sockets with the same numeric port. An nginx server block can therefore combine listen 443 quic reuseport; with listen 443 ssl; http2 on;. The reuseport option permits per-worker listening sockets on supported systems; it is not a blanket remedy for duplicate server declarations or unrelated processes occupying the port. Check the actual configuration error and socket ownership before changing options.

QUIC integrates TLS 1.3 with transport establishment and can reduce setup round trips. Resumption may permit early application data, but does not eliminate the handshake or guarantee acceptance of 0-RTT. Retry, loss, and validation can add delay. Connection IDs support continuity across address changes when the endpoints and routing infrastructure support it.

Alt-Svc is how the client finds out

An origin can advertise Alt-Svc: h3=":443"; ma=86400 over an existing HTTP connection. This names protocol h3, the same host on port 443, and a 24-hour freshness lifetime. It does not require that connection to use HTTP/2, and prior knowledge can allow a direct HTTP/3 attempt. Clients may use a fresh alternative without waiting for a later page visit; an advertisement alone does not prove reachability.

Freshness permits reuse of the advertisement; it does not force a client to repeat a failed QUIC attempt on every connection. Clients can fall back or apply failure policies. QUIC probe timers depend on RTT estimates and backoff, while the decision to race or abandon a connection belongs to the implementation. The earlier 15-second, 300-seconds-per-day and 0.1-second figures were illustrative assumptions without a retained runnable model or trace, not measured or required fallback timings.

0-RTT and migration are the two sharp edges

0-RTT lets a returning client send application data before completing the new handshake. Early data has replay risk, so the application must define replay-safe handling and reject unsafe early operations. A previous ticket does not guarantee acceptance. An unprotected create-order request can duplicate a side effect; transport encryption alone does not provide exactly-once execution.

Migration can preserve a QUIC connection after a client address changes, subject to peer policy and path validation. A five-tuple balancer can instead send packets to a backend lacking that connection’s cryptographic state, causing interruption or reset. The earlier backend 7-to-4 example has no retained hash model or trace and is not a measured placement result.

When it breaks

A cached advertisement and blocked UDP can delay connection establishment, but neither a day-long penalty nor an HTTP/3 failure proves a middlebox fault. Missing client support, TLS errors, endpoint configuration, and server availability can produce failures too. Compare client errors and protocol-specific latency with server observations; absence from an access log alone is inconclusive. Keep a working TCP alternative and measure the actual client’s fallback policy.

Address changes can also expose incorrect routing affinity. Preserve routing to the QUIC endpoint that owns the connection state; an application cookie alone cannot route encrypted packets before QUIC termination. Validate the deployed connection-ID handling and path changes explicitly. These failures are not limited to mobile clients, and application-session continuity is a separate concern after transport succeeds.

Separate UDP and TCP listener cells lead to existing-HTTP advertisement and prior-knowledge discovery branches, then a shared-congestion panel separates blocked and potentially deliverable QUIC streams. Conditional early data, migration and diagnostic limits follow.
Direct h3 discovery is possible; h2 fallback depends on both endpoints and client policy. No fixed fallback timing or successful migration is guaranteed.

Check it quickly

Use Bash, Python 3 and curl 7.88+ built with HTTP/2 and HTTP/3. Set URL to one authorized direct HTTPS GET endpoint returning 200, including its path. Choose a repeatable read-only resource. No redirects or proxies are used:

(
set -euo pipefail
: "${URL:?Set one authorized HTTPS URL with an expected 200 response}"
umask 077
tmp=$(mktemp -d)
trap 'rm -rf -- "$tmp"' EXIT
probe() {
    local label=$1 mode=$2 expected=$3 rc check_rc
    if curl --disable --silent --show-error --fail --globoff \
        --noproxy '*' --proto '=https' --connect-timeout 10 --max-time 30 \
        "$mode" --output /dev/null --dump-header "$tmp/$label.headers" \
        --write-out '%{json}\n' --url "$URL" > "$tmp/$label.json"; then
        rc=0
    else
        rc=$?
        printf '%s: curl failed (exit %s)\n' "$label" "$rc" >&2
    fi
    if python3 - "$tmp/$label.json" "$label" "$expected" "$URL" "$rc" <<'PY'
import json, math, pathlib, sys
path, label, expected, url, curl_exit = sys.argv[1:]
print(label, "curl_exit", curl_exit)
d, headers = None, None
try:
    d = json.loads(pathlib.Path(path).read_text())
    if not isinstance(d, dict):
        raise ValueError("expected a JSON object")
except (OSError, UnicodeError, ValueError) as error:
    d = None
    print(label, "metadata unavailable:", str(error), file=sys.stderr)
keys = ("url_effective", "remote_ip", "remote_port", "http_version", "http_code",
        "ssl_verify_result", "time_appconnect", "time_starttransfer", "time_total")
if d is not None:
    print(label, json.dumps({k: d[k] for k in keys + ("exitcode", "errormsg") if k in d}))
    missing = [k for k in keys if k not in d]
    if missing:
        print(label, "metadata fields unavailable:", ", ".join(missing), file=sys.stderr)
try:
    headers = pathlib.Path(path).with_suffix(".headers").read_text(encoding="iso-8859-1")
    print(label, "Alt-Svc header observations:",
          [line for line in headers.splitlines() if line.lower().startswith("alt-svc:")])
except OSError as error:
    print(label, "header observations unavailable:", str(error), file=sys.stderr)
if int(curl_exit) != 0:
    # Observations from a failed transfer are not successful validation.
    raise SystemExit(0)
try:
    ok = (d is not None and headers is not None and d["http_code"] == 200 and
          d["url_effective"] == url and d["http_version"] in ("1.1", "2", "3") and
          d["remote_ip"] and d["remote_port"] > 0 and d["ssl_verify_result"] == 0 and
          (expected == "any" or d["http_version"] == expected) and
          all(math.isfinite(d[k]) and d[k] >= 0 for k in
              ("time_appconnect", "time_starttransfer", "time_total")))
except (KeyError, TypeError, ValueError):
    ok = False
if not ok:
    raise SystemExit(label + ": unavailable or unexpected endpoint, protocol, status or timing")
PY
    then
        check_rc=0
    else
        check_rc=$?
    fi
    if [ "$rc" -ne 0 ]; then
        return "$rc"
    fi
    return "$check_rc"
}
failed=0
probe h2 --http2 2 || failed=1
probe h3-only --http3-only 3 || failed=1
probe fallback-enabled --http3 any || failed=1
exit "$failed"
)

Each successful probe checks status, URL and protocol. Failed transfers retain available observations, original exit and stderr; missing or malformed metadata is labeled unavailable. Later probes still run. –http3-only forbids fallback; –http3 permits racing older protocols. A result of 2 or 1.1 records TCP selection, not a controlled blocked-UDP experiment; 1.1 does not prove h2. time_appconnect ends at handshake completion; time_starttransfer ends at first byte. Alt-Svc observations may include interim responses and do not establish reachability.

On the corresponding authorized server, inspect configuration and local sockets separately:

(
set -euo pipefail
umask 077
tmp=$(mktemp -d)
trap 'rm -rf -- "$tmp"' EXIT
if nginx -T > "$tmp/nginx.txt" 2> "$tmp/nginx.err"; then
    cat "$tmp/nginx.err" >&2
else
    rc=$?
    cat "$tmp/nginx.err" >&2
    exit "$rc"
fi
if grep -nE 'listen .*quic|http2|http3|Alt-Svc' "$tmp/nginx.txt"; then
    :
else
    rc=$?
    printf 'No matching directives, or grep failed (exit %s)\n' "$rc" >&2
    exit "$rc"
fi
ss -H -lunp 'sport = :443' > "$tmp/udp.txt"
cat "$tmp/udp.txt"
if [ ! -s "$tmp/udp.txt" ]; then
    printf 'No local UDP:443 listener observed\n' >&2
    exit 1
fi
)

Diagnostics stay visible. Configuration matches may include comments or inactive settings; socket owners may be hidden by permissions. Neither identifies the running public endpoint without further correlation. Treat configuration output as potentially sensitive. Only shell syntax and fake/file controls ran here; actual TLS, HTTP, NGINX and fallback validation remain held.

Sources