
TTFBは速いのに表示が遅いVercelのページ、関数リージョンが既定のままだった
こんにちは!製造ビジネステクノロジー部の石井です。
個人で運用しているサイトで、ページ送りがどうにも遅いのが気になっていました。フロントはVercel、APIはAWSの東京リージョンに置いている構成です。
とりあえずTTFBを測ってみると0.5秒でべつに遅くありません。
なのに、画面が出るまでは2秒以上かかります。
調べてみると原因は2つあって、片方を直しただけでは半分しか縮みませんでした。両方直して、2.2秒前後が0.5秒前後になっています。
TTFB(Time To First Byte)は、リクエストを投げてから最初の1バイトが返ってくるまでの時間です。
先に結論
フロントとAPIを別々の場所に置いているなら、まずは次の3つを順に確認してみてください。10分もかかりません。
-
総時間を測る。TTFBではなく
curl -w "%{time_total}"のほう -
動的に描画されるページで
x-vercel-idレスポンスヘッダの2つめを見る。ここが関数の実行リージョン -
APIのリージョンとずれていたら
vercel.jsonにregionsを書く
それでも遅ければ、1ページを描くのにAPIを何回呼んでいるかを数えます。今回は1ページ目で41回でした。
対象の構成
外部APIから毎分計測値を取ってきて貯め、日次のランキングにして見せているサイトです。題材そのものは今回の話に関係ないので、中身はぼかしておきます。
構成はこんな感じです。
- フロント: Next.js 15(App Router)をVercelにデプロイ
- API: API Gateway + Lambda(Hono)、AWS の ap-northeast-1
- データストア: DynamoDB
遅かったのはランキング一覧のページ送りで、1ページに20行並べています。
TTFBは速いのに表示が遅い
まずは curl で、TTFBと総時間を分けて測ってみます。
curl -s -o /dev/null \
-w "ttfb=%{time_starttransfer}s total=%{time_total}s\n" \
"https://example.com/?page=2"
修正前に10回前後測ると、TTFBは0.45〜0.69秒、総時間は1.79〜2.74秒でした。真ん中あたりの値で比べると、総時間はTTFBの4倍ほどかかっています。
ストリーミングしない普通のSSRなら、この2つはそこまで離れません。データを取ってHTMLを組み立ててから送り始めるので、最初の1バイトが出た時点でサーバー側の仕事はほぼ終わっています。
今回それが崩れていたのは、Next.jsのストリーミングのせいでした。app/ 直下に loading.tsx を置くとそこがSuspense境界になって、レスポンスが大きく2段階に分かれて届きます。
-
レイアウトと「読み込み中…」のシェルだけを先に送る。データ取得の結果を待たなくていいので、すぐ出せる
-
データが揃ったら、本体を同じ接続に流し込んでシェルと差し替える
最初の1バイトは1段階目で出ます。TTFBに表れるのはシェルを返し始めるまでの時間だけで、今回の重いAPI呼び出しはすべて2段階目の側にありました。TTFBと総時間の差の1.7秒前後は、接続を開いたままサーバーが裏で処理を続けていた時間でした。
TTFBが当てにならない、という話ではありません。測っているのが「応答が始まるまで」で、「ページが出るまで」ではないだけです。後半では、むしろコレが2つの原因が別物だという裏付けになります。
ただ、TTFBだけ見て総時間を見ないと、「0.5秒なら問題なし」で調べるのをやめてしまいます。自分も一度そこで止まりかけました。
ブラウザの開発者ツールでも取り違えやすい
普段は curl よりNetworkパネルで見る人のほうが多いと思うので、そちらでの見え方も書いておきます。
ドキュメントのリクエストを開いてTimingタブを見ると、内訳はこんなイメージです(DNSや接続の時間は省略しています)。
Waiting for server response (TTFB) 0.6s
Content Download 1.7s
─────
Total 2.3s
合計は2秒台と出るので、遅いことには気付けます。紛らわしいのは、1.7秒前後が「Content Download」に計上されるところです。そのまま読むと、ファイルが大きいか回線が遅いように見えます。実際のドキュメントは125KB程度なので、ここで「サイズのわりに遅い」と思えれば先に進めますが、「回線かな」で済ませると原因から遠ざかります。この1.7秒前後の大半は、接続を開いたままサーバーがAPIを40回以上叩いていた時間でした。
しかもそのAPI呼び出しは、Networkパネルに1本も出てきません。Server Components(RSC)のフェッチはサーバーの中で完結していて、ブラウザからは見えないからです。並んでいるのは、2秒以上かかったドキュメントが1本だけです。クライアント側でフェッチしていれば40本以上ずらっと並んで一目で分かったはずですが、RSCだとそれが隠れます。
原因は関数の実行リージョンだった
次は、関数がどこで動いているかを確認します。Vercelはレスポンスヘッダでこれを教えてくれます。
curl -sI "https://example.com/" | grep -i x-vercel-id
x-vercel-id: hnd1::iad1::ntv4b-1787731880469-2b0dea14cbc9
見るのは hnd1::iad1 の部分です。
- 1つめ
hnd1: リクエストを受け付けたエッジ(東京) - 2つめ
iad1: 関数が実際に実行されたリージョン(ワシントンD.C.。AWSでいう us-east-1、バージニア北部)
キャッシュから返った応答では関数が動かないので、関数のリージョンは載りません。x-vercel-cache が MISS になっている応答で見てください。
APIは東京にあるので、Server ComponentsからAPIを呼ぶたびに太平洋を往復していたことになります。

静的ファイルは東京のエッジから返ってくるので、遅くなるのはSSRを通るページだけです。サイト全体が重くなるわけではないぶん、遅いページがあっても見過ごしやすくなります。
iad1 はVercelの既定値
そもそも、なぜバージニアになっていたのでしょうか。VercelのAPIでプロジェクト設定を読んでみました。$TOKEN はVercelのAccount Settings → Tokensで発行したトークンです(チームのプロジェクトなら、URLに ?teamId=... も付けます)。
curl -s -H "Authorization: Bearer $TOKEN" \
"https://api.vercel.com/v9/projects/<project-name>" \
| jq '.serverlessFunctionRegion'
"iad1"
同じアカウントのほかのプロジェクトも見てみたら、このプロジェクトを含めて3つとも iad1 でした。誰かが選び間違えたわけではなく、プロジェクトを作ったときの既定値がそのまま残っていただけです。
Vercelのドキュメントにも、既定は iad1 と書かれています。理由は、外部のデータソースが米国東海岸にあることが多いからだそうです。バックエンドをAWS東京に置いた時点で、この想定からは外れます。
誰も間違った設定をしていないうえに、ダッシュボードの値はリポジトリにも出てこないので、コードレビューで見つかることもありません。
設定は2箇所にある
実行リージョンを指定できる場所は2つあります。
| 適用範囲 | リポジトリから見えるか | |
|---|---|---|
ダッシュボード(serverlessFunctionRegion) |
変更後の新しいデプロイ | 見えない |
vercel.json の regions |
そのファイルを含むビルドだけ | 見える |
両方に値があれば vercel.json が優先されます。今回はこちらを置きました。置く場所はVercelのRoot Directoryに指定したディレクトリで、モノレポなら apps/web/vercel.json のようになります。リポジトリ直下に置いても効かないので注意してください。
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"regions": ["hnd1"]
}
指定できるリージョンの数はプランごとに決まっていて、Hobbyは1つ、Proは5つ、Enterpriseは全リージョンまでです。1つだけなら無料のHobbyでも指定できます。
ただし vercel.json の設定は、そのファイルを含むビルドにしか反映されません。この変更より前に切ったブランチのプレビューや、コミットをrevertしたビルドは、ダッシュボード側の値(何もしていなければ iad1)で動きます。
なので、ダッシュボード側も同じ hnd1 にそろえておきます。役目はこう分けています。
- ダッシュボード:
vercel.jsonを含まないビルドも東京で動かすための保険 vercel.json: なぜ東京なのかをコードと一緒に残しておく記録
ダッシュボード側は、Project Settings → Functions の「Function Region」で選べます。変更が反映されるのは次のデプロイからです。

vercel.json に regions を書いていると、見出しの横に「Overridden」と表示されます。ダッシュボードの選択より vercel.json が優先されている状態です。
変更後
デプロイしたら、本当に東京で動いているかを確認します。
vercel inspect https://example.com
Builds
┌ . [0ms]
├── λ index (1.05MB) [hnd1]
└── λ index.rsc (1.05MB) [hnd1]
出力は抜粋です。index.rsc が別の関数として並んでいます。画面内のリンクでページ送りしたときのデータはこちらから返るので、ここが hnd1 になっているかも見ておきます。
計測し直した結果はこちらです。
| 修正前 | リージョン修正後 | |
|---|---|---|
| TTFB | 0.45〜0.69s | 0.12〜0.19s |
| 総時間(未訪問ページ) | 1.79〜2.74s | 0.74〜1.53s |
| 総時間(再訪・サーバー側キャッシュヒット) | 0.63〜0.83s | 0.27〜0.58s |
TTFBは約3.7倍速くなりました。エッジが東京でも、関数がバージニアにある限り、レスポンスを返し始める前に一度太平洋を往復していたことになります。
ただ、未訪問ページの総時間はまだ1秒前後かかっていて、半分になっただけです。
残りの1秒は一覧のN+1だった
ここからはVercelともリージョンとも関係ない話です。残りの1秒を追っていったら、一覧画面でおなじみのアレが出てきました。
一覧の各行には、アイコン・観測時間・計測値の推移グラフを出しています。このデータを、行ごとに取りに行っていました。ランキング取得の1コールに、20行×2コールが加わって41コールです(2ページ目以降は、値のバーの基準にする全体1位の取得がもう1本あって42コール。N+1とは別の話なので、以下は1ページ目の数で書きます)。

一覧を1回取ったあと、行の数だけ追加で呼び出す、いわゆるN+1です。同時に10本までは並列で投げていたものの、1行の中では日次集計から生データの取得キーを得てから生データを取りに行く直列だったので、待ち時間が積み上がっていきます。
転送量も測ってみました。
ranking fetch : 0.20s (1 call)
row details : 0.88s (40 calls, concurrency 10)
raw data JSON pulled: 798 KB for 20 sparklines
幅120pxの小さな折れ線を20本描くのに、798KBも読み込んでいました。生データのAPIが、間引かずに全点を返す作りだったためです。
対処は定石どおり
一覧に出す値は日次で確定するので、リクエストのたびに組み立てなくても、集計のタイミングで作っておけば足ります。
やったことは3つです。
-
日次集計に間引き済みの計測値列(最大60点)を持たせる。ロールアップ時に生データから作るので、追加の読み取りは発生しない
-
複数の対象ぶんをまとめて引くエンドポイントを追加する。DynamoDBの
BatchGetItem1回で日次集計とプロフィールを取る -
フロントの行ごとのフェッチを、そのまとめ取り1回に置き換える
これで1ページ目の描画は41コールから2コールになりました。
置き場所だけ少し迷った
最初は、ランキングの各エントリに表示用の値まで入れてしまえば1コールで済む、と考えていました。実際、そのほうが1往復ぶん速くなります。
でも、何件書くことになるかを数えてやめました。
確定実行1回で書かれるランキングアイテム: 148,604件
期間が10種類(日次・週間・月間・四半期など)、軸が4種類あるので、同じ値列が1対象につき最大40箇所に複製されます。
| ランキングに詰める | 日次集計に持たせる(採用) | |
|---|---|---|
| 増える容量 | +39.7 MB/日 | +0.38 MB/日 |
| ページ描画 | 1コール | 2コール |
書き込みの料金は、ランキングに詰める案でも増えません。DynamoDBの書き込みは1KB単位で切り上げて数えるので、ランキングの1アイテムが250バイトから530バイト程度に増えても、消費するWCUは1のままです。
ストレージだけは数十倍の差がつきます。一方、レイテンシの差はAPI呼び出し1回ぶん(約150ms)しかありません。150msのために容量を数十倍にするのは割に合わないので、日次集計の側に置きました。
まとめて取れる形に寄せようとすると、同じ値をあちこちに複製したくなります。どこまで広がるかは、先に件数を出してみると判断しやすいです。
結果
3段階の計測をまとめるとこうなります。どれも東京の自宅から curl で10回前後測った最小〜最大です。総時間は curl の time_total(HTMLを最後まで受け取るまで)で、ブラウザの描画までは含みません。
| 修正前 | リージョン修正後 | N+1解消後 | |
|---|---|---|---|
| TTFB | 0.45〜0.69s | 0.12〜0.19s | 0.12〜0.20s |
| 総時間(未訪問ページ) | 1.79〜2.74s | 0.74〜1.53s | 0.30〜0.77s |
| 総時間(再訪・サーバー側キャッシュヒット) | 0.63〜0.83s | 0.27〜0.58s | 0.17〜0.45s |
未訪問ページの総時間で見ると、2.2秒前後だったものが0.5秒前後になり、ざっくり4倍速くなりました。
2つの修正は、縮めたものが違います。
- リージョン修正: 往復1回あたりの時間(10本並列なので、直列に並ぶ約5回ぶん)と、レスポンスを返し始めるまでの時間(TTFB)
- N+1解消: 往復の回数(41回→2回)と、生データ798KBの読み込み
TTFBの行を見ると、リージョン修正では約3.7倍速くなったのに、N+1解消ではほぼ動いていません。TTFBはシェルを返し始めるまでしか測らないので、その手前にある太平洋の往復には反応しますが、シェルのあとのAPI呼び出しには反応しません。前半で書いたこの性質のおかげで、2つの原因が別物だと数字で確かめられました。
未訪問ページの総時間は、リージョン修正で約半分、N+1解消でさらに約半分になっています。どちらか片方だけでは、0.5秒台までは届きませんでした。
まとめ
TTFBは速いのに表示が遅い原因は、関数の実行リージョンのずれとN+1の2つでした。
振り返ると、手がかりはどれも普段見ているところには出ていませんでした。TTFBはシェルまでしか測りませんし、Networkパネルにはサーバー側の41回が出てきません。リージョンの既定値も、リポジトリのどこにも書かれていません。
もうひとつ、リージョンを直した時点で満足していたら、遅さの半分は残ったままでした。修正ごとに測り直していたので、N+1のぶんが残っていることにも気付けています。
ちなみに、Vercel/v0はクラスメソッドでも取り扱っています。
おまけ
クラスメソッド名古屋オフィス(伏見駅徒歩5分)で月イチペースで開催している「なごやクラメソゆる勉強会」、第6回を2026年11月4日(水)19:00から開催します!
今回のテーマは「Claude Codeで一歩踏み出す:手を動かしてつくってみよう」です。前回(Claude Codeの基本・活用事例)の続編で、その場でClaude Codeと対話しながらアプリを作る実践回です。作るもののテーマは自由で、PCがない人もクラスメソッドのメンバーとグループで開発できます(定員15名・先着順)。名前のとおりゆるめの雰囲気なので、気軽に参加できます!
詳細・申し込みはこちらから!











