Supercherenkoスマレジアプリと解説メディアの個人開発

HTTP/3 にしても TTFB はほとんど縮まない — QUIC が畳んでいるのは接続確立の 1RTT だけ

HTTP/3 を有効にしたのに、TTFB がほとんど動かなかった話です。CDN やサーバーの設定を触る人に向けて書いています。

測ったのはこのサイト(supercherenko.com)で、Cloudflare Workers の無料プランで配信しています。数字はすべて 2026-09-06 に手元で取り直したものです。

  • 計測に使った curl は Homebrew の 8.22.0(ngtcp2 1.25.0 / nghttp3 1.18.0)
  • クライアントは macOS 26、回線は光
  • 接続先の Cloudflare エッジは KIX、ICMP の往復時間は最小 14.4ms
  • 対象は静的にビルドされたページ(/about)と、D1 を引く動的なページ(トップ)の2種類

HTTP/3 に切り替えても、TTFB は 0.1ms しか変わらなかった

先に結論から出します。同じページを HTTP/2 と HTTP/3 で20回ずつ叩いて、中央値を取ったものです。

prerender された /about(n=20・ミリ秒)
             DNS   接続確立   サーバー処理    TTFB
HTTP/2       2.9     39.9        31.5       74.7
HTTP/3       3.0     35.3        33.8       74.6
差                   -4.6        +2.2       -0.1

TTFB の差は 0.1ms です。計測の揺れに完全に埋もれています。

D1 を引くトップページでも測りました。こちらは動きましたが、幅は小さいままです。

SSR のトップページ(n=20・ミリ秒)
             DNS   接続確立   サーバー処理    TTFB
HTTP/2       2.9     42.0       111.1      160.8
HTTP/3       2.8     33.1       115.3      152.8
差                   -8.9        +4.2       -8.0

8ms、率にして 4.9% です。プロトコルを丸ごと入れ替えた対価としては小さい。ただ、この数字が小さいこと自体は失敗ではありませんでした。分解して見ると、QUIC は縮むはずのところをちゃんと縮めています。

手元の curl が HTTP/3 を話せない

測り始める前に2つ躓いたので、先に書いておきます。

macOS に最初から入っている curl は HTTP/3 に対応していません。バージョンを見ると分かります。

curl --version
curl 8.7.1 (x86_64-apple-darwin25.0) libcurl/8.7.1 (SecureTransport) LibreSSL/3.3.6
Features: alt-svc AsynchDNS GSS-API HSTS HTTP2 HTTPS-proxy IPv6 ...

Features の行に HTTP2 はありますが HTTP3 がありません。この状態で --http3 を付けても、オプションが認識されずに終わります。Homebrew の curl は ngtcp2 と nghttp3 を組み込んでビルドされているので、そちらを絶対パスで呼びました。

/opt/homebrew/opt/curl/bin/curl --version
curl 8.22.0 (aarch64-apple-darwin25.6.0) libcurl/8.22.0 OpenSSL/3.6.4 ... ngtcp2/1.25.0 nghttp3/1.18.0
Features: alt-svc AppleSecTrust AsynchDNS brotli GSS-API HSTS HTTP2 HTTP3 ...

もう1つは、QUIC が3回に1回ほど落ちたことです。

curl: (56) QUIC: recvmsg() unexpectedly returned -1 (errno=61; Connection refused)

errno=61 は ECONNREFUSED で、UDP を投げた先から到達不能が返ってきている状態です。IPv4 と IPv6 を強制して切り分けたところ、原因の所在ははっきりしました。-4 を付けた8回は全部 HTTP/3 で繋がり、-6 を付けた8回は3回落ちました。IPv6 の経路のどこかで UDP 443 が通っていません。

自分の回線と Cloudflare のあいだのどこで落ちているかまでは特定できていません。ルータなのか、ISP なのか、Cloudflare 側のどれかのエッジなのかは分かりませんでした。ここは未解決のまま、計測は -4 で固定しています。

ただ、この躓きは QUIC の設計そのものと繋がっています。QUIC が UDP に乗っているのは、新しいトランスポートを既存の網に通すための選択でした。RFC 9000 はこう書いています。

QUIC packets are carried in UDP datagrams [UDP] to better facilitate deployment in existing systems and networks.

TCP 443 はどんな経路でも通りますが、UDP 443 にその保証はありません。だから HTTP/3 は「繋がったら使う」形で配られます。オリジンは Alt-Svc ヘッダで自分が HTTP/3 を話せることを広告し、クライアントは試してよい、という規定です(RFC 9114 §3.1.1)。

On receipt of an Alt-Svc record indicating HTTP/3 support, a client MAY attempt to establish a QUIC connection to the indicated host and port

MAY です。義務ではありません。このサイトも Cloudflare が既定で広告を出していますが、初回の応答自体は HTTP/2 で返ってきます。

HTTP/2 200
server: cloudflare
alt-svc: h3=":443"; ma=86400

ブラウザは QUIC が通らなければ黙って HTTP/2 に戻ります。私が --http3-only を使って初めてエラーが見えたのは、フォールバックを禁止したからでした。裏を返すと、普段ブラウザで見ているぶんには、HTTP/3 が使われていなくても気づけません。

TTFB を4つに割って測る

TTFB という1つの数字を見ているかぎり、プロトコルを変えて何が起きたのかは読めません。curl の -w は区間ごとの累積時間を出せるので、4つに割りました。

FMT='%{time_namelookup} %{time_connect} %{time_appconnect} %{time_starttransfer}\n'
curl -so /dev/null -4 --http2 -w "$FMT" https://supercherenko.com/about
curl -so /dev/null -4 --http3-only -w "$FMT" https://supercherenko.com/about

累積値なので、隣との差を取ると各区間の所要時間になります。

  • 名前解決: time_namelookup まで
  • TCP 接続: time_namelookup から time_connect まで
  • TLS ハンドシェイク: time_connect から time_appconnect まで
  • サーバー処理と往路復路: time_appconnect から time_starttransfer まで

HTTP/3 で測ると、この分け方が崩れます。time_connect が time_namelookup とほぼ同じ値になり、TCP 接続の区間が消えます。QUIC には TCP の接続確立が存在しないので、curl は埋めようがありません。そこで以降は、TCP と TLS をまとめた「接続確立」で両者を比べます。time_appconnect から time_namelookup を引いた値です。

HTTP/2 側の内訳はこうでした。

/about での接続確立の内訳(HTTP/2・中央値)
TCP 接続        16.1ms
TLS ハンドシェイク  23.6ms
合計            39.9ms

TCP の 16.1ms は、ping で測った往復時間の最小 14.4ms とほぼ一致します。接続確立に1往復かかっている、という教科書どおりの値です。

QUIC が畳んでいるのは、TLS ハンドシェイクの1往復

なぜ QUIC で接続確立が縮むのか。RFC 9000 の導入部が理由を1文で書いています。

The QUIC handshake combines negotiation of cryptographic and transport parameters. QUIC integrates the TLS handshake [TLS13], although using a customized framing for protecting packets.

TCP の上に TLS を乗せる構成では、まず TCP が1往復して経路を作り、その上で TLS がもう1往復して鍵を交換します。TLS 1.3 は前の版より速く、RFC 9001 が「ロスが無ければ新規接続の大半は1往復で確立できる」と書いています。

Absent packet loss, most new connections can be established and secured within a single round trip

速くなった TLS 1.3 でも1往復は要る。そこに TCP の1往復が積み上がって、合わせて2往復になります。QUIC は暗号のやりとりを最初のパケットに同居させたので、この2往復が1往復になります。

つまり QUIC が削っているのは1往復ぶん、この環境なら 14.4ms です。TTFB 全体ではありません。

ここで実測と理論が食い違いました。減るはずの 14.4ms に対して、実際に減ったのは /about で 4.6ms、トップで 8.9ms です。方向は合っていますが、幅が理論の3分の1から6割にとどまります。

理由は特定できませんでした。curl の time_appconnect が QUIC でどの時点を指しているのかを疑ってはいます。TLS 1.3 のハンドシェイクのどこを完了と数えるか次第で、この幅は説明がつく余地があります。ただ確かめていないので、食い違ったまま書いておきます。

削れる量が1往復ぶんだと分かると、最初の表の読み方が変わります。/about の TTFB 74.7ms のうち、接続確立は 39.9ms、残りの 31.5ms はサーバーが応答を作って返すまでの時間でした。ここから 4.6ms を削っても、全体では 6% です。トップページに至っては、TTFB 160.8ms のうち 111.1ms がサーバー処理でした。プロトコルをどう入れ替えても、この 111.1ms には触れません。

接続を張り直さないほうが、10倍大きく縮む

では TTFB を縮めたいときにどこを見るか。同じ計測の途中で、答えが出ていました。

curl は複数の URL を1コマンドに並べると接続を再利用します。同じページを4回続けて取ったときの TTFB です。

U=https://supercherenko.com/about
FMT='conn=%{num_connects} ttfb=%{time_starttransfer}\n'
curl -so /dev/null -4 --http2 -w "$FMT" $U -so /dev/null -w "$FMT" $U \
     -so /dev/null -w "$FMT" $U -so /dev/null -w "$FMT" $U
conn=1 ttfb=0.077846
conn=0 ttfb=0.029766
conn=0 ttfb=0.033919
conn=0 ttfb=0.027058

1本目は 77.8ms、2本目以降は 27ms から 34ms です。接続確立の 40ms が丸ごと消えています。HTTP/3 でも同じで、1本目 74.9ms に対して2本目以降は 22ms から 27ms でした。

QUIC が削った 4.6ms に対して、接続を張り直さないことで消えるのは約 50ms です。10倍以上の差があり、しかもこれは HTTP/2 でも起きます。ブラウザは1つのサイトに対して接続を保ったまま複数のリクエストを流します。だから実際のページ表示で HTTP/3 の恩恵を受けるのは、最初の1本だけです。

この計測から、優先順位はこう並びました。

  • サーバーが応答を作る時間を削る: トップページでは TTFB の69%(111.1ms)がここでした。プロトコルの選択では1ミリ秒も動きません
  • 接続を使い回す: 2本目以降の TTFB から接続確立の約 50ms が消えます。プロトコルを問わず起きます
  • HTTP/3 にする: 新規接続の1往復ぶん、この環境で 4.6ms から 8.9ms です

順序が逆になっているところを直さないまま HTTP/3 を有効にすると、私が最初に見た「TTFB が 0.1ms しか変わらない表」が出てきます。

サーバー処理の中身は、応答ヘッダに出していれば外から読めます。このサイトは Server-Timing を吐いているので、トップページの内訳がそのまま見えます。

server-timing: ... render;dur=84;desc="Page render", mw;dur=189;desc="Total middleware",
               db.total;dur=254;desc="DB total", db.count;dur=12;desc="Query count"

クエリが12本で、データベースの合計が 254ms。削る先はここだと分かります。

残っている疑問

書ききれなかったことが2つあります。

1つは、HTTP/3 がロスのある回線で有利になるとされる部分を測っていないことです。QUIC はストリームごとに独立して届くので、パケットが1つ落ちたときの巻き添えが TCP より小さくなります。RFC 9000 の記述はこうです。

When a packet loss occurs, only streams with data in that packet are blocked waiting for a retransmission to be received, while other streams can continue making progress. Note that when data from multiple streams is included in a single QUIC packet, loss of that packet blocks all those streams from making progress.

後半に条件が付いているところが読みどころで、1つの QUIC パケットに複数ストリームのデータを詰めていれば、そのパケットが落ちたときは結局まとめて止まります。「HTTP/3 なら詰まりが無くなる」ではありません。ただ、この節は原文を読んだだけで、手元でロスを起こして測ってはいません。有線で 0.0% ロスの環境だったので、今回の数字にこの効果は入っていないはずです。

もう1つは、上に書いた理論値と実測値のずれです。1往復ぶん減るはずが3分の1しか減らなかった理由は分かっていません。

どちらも、モバイル回線と意図的なパケットロスを用意して測り直す必要があります。

記事の一覧へ