はじめに
😩「リリースしたら、ページの表示に数秒かかる…」
🤔「コードはそこまで重くないはずなのに、なんで?」
😱「loading.tsx を足して体感は良くなったのに、LCP が倍になってる…!?」
Next.js の App Router で作ったアプリが、思ったより遅い。
しかも、良かれと思って入れた改善で数値が悪化することもあります💦
自分が個人開発している食の口コミサービス「あじぴた」でも、まさにこれが起きました。
リリース当初はページ表示に数秒かかっていたのが、今はほぼストレスなく使えるところまで来ています🚀
この記事は、そこに至るまでに実際に踏んだ 4 つの罠と、その計測値の記録です📊
| 項目 | 内容 |
|---|---|
| フレームワーク | Next.js 16(App Router)/ React 19 |
| ホスティング | Vercel |
| DB・認証 | Supabase(東京リージョン) |
| 計測 | Sentry(実ユーザー)+ Playwright の自作スクリプト |
結論:通信 1 回の時間 → 通信の回数 → 見せ方の順に直す
先に結論です👇
パフォーマンス改善は、順番を間違えると効果が見えない。
まず「1 回の通信にかかる時間」を縮める。次に「通信の回数」を減らす。最後に「見せ方」を整える。
ページの表示が遅いとき、時間の多くはサーバーと DB・認証サーバーとの通信に使われています。
表示にかかる時間は、ざっくり言うと「1 回の通信にかかる時間 × 通信の回数」です。
なので、まず 1 回あたりの時間を縮めるのが一番効きます。
1 回が 10 倍遅いままだと、回数を減らす工夫の効果も小さく見えてしまうからです💡
踏んだ罠と、効いた度合いはこうでした👇
| 罠 | 内容 | 効果 |
|---|---|---|
| 0 | サーバーのプログラムが米国で動き、東京の DB と太平洋を往復していた | DB からデータを取るページが 1,688ms → 253ms |
| 1 |
loading.tsx を足したら LCP が 2 倍になった |
/home の LCP が 10.4 秒 → 4.2 秒
|
| 2 | ログイン確認のたびに、認証サーバーと通信していた | ログイン確認が 32〜38ms → 約 2ms |
| 3 | ローカルの数値で、本番の効果を見積もっていた | (判断の誤りを防ぐ) |
圧倒的に効いたのは罠 0です。
アプリのコードは 1 行も変えていません。設定を 1 つ変えただけです⚙️
計測の 2 系統
罠の話の前に、どう測っていたかを書いておきます📏
| 種別 | 手段 | 用途 |
|---|---|---|
| 実ユーザー(field) | Sentry | 実際のユーザーの LCP / CLS / TTFB / INP |
| 再現用(lab) | Playwright の自作スクリプト | 改善の前後比較 |
計測専用のパッケージは入れていません。
Sentry はエラー監視のためにすでに入っていて、その設定で Web Vitals も自動で集まるからです👍
ただ、実ユーザーのデータだけでは前後比較ができません。
アクセスが少ない時期は数字が安定しないし、改善した直後にすぐ結果が見られないからです🤔
そこで、同じ条件で何度でも測れるスクリプトを別に持っています。
- Playwright でブラウザを起動し、各ページを 3 回読み込んで中央値を取る
- 回線をスマホの 4G 相当に絞る(下り 1.6Mbps / 遅延 150ms / CPU 4 倍遅く)
- LCP・CLS・TTFB を取る(INP は実際の操作が要るので、実ユーザー側でだけ見る)
npm run vitals # 主要なページをまとめて計測
npm run vitals -- --runs 5 # 計測回数を増やす
npm run vitals -- --no-throttle # 回線を絞らずに素の速度を見る
罠 0: サーバーが太平洋を往復していた🌊
順番としてはこれが最初で、他のどの施策よりも効きました。
リリース当初の「数秒かかる」の原因は、ほとんどがこれです😇
Vercel のサーバー関数 … 米国東部(既定値のまま)
Supabase の DB … 東京
→ DB からデータを取るたびに、太平洋を往復していた
Vercel では、サーバー側のプログラム(サーバー関数)が動く場所を指定しない限り、米国東部になります。
DB は東京に置いていたので、データを 1 回取るたびに、通信が太平洋を渡って戻ってきていました😇
なぜ気づくのが遅れたか
「DB の近くでプログラムを動かす」という考え方自体は知っていました。
それでも見落としたのは、探す場所が想定と違ったからです🔍
Vercel では、画像や JavaScript などのファイルの配信は、自動で利用者の近くから行われます。
なので「どこにデプロイするか」を選ぶ設定は、実質ありません。
一方で、サーバーのプログラムがどこで動くかは、別の設定です☝️
| どこで動くか | 設定 | |
|---|---|---|
| 画像・JS などのファイル配信 | 世界中(利用者の近く) | 自動 |
| サーバー関数 | 1 か所だけ | 指定しないと米国東部 |
つまり、ファイルは自動で近くから届くのに、プログラムが動く場所は自動では近くならないんです。
「サーバーを近くに寄せよう」と考えても、ファイル配信のイメージで設定を探すと、たどり着けません💦
症状の出方も紛らわしいものでした🌀
- ファイルは近くから届くので、ページ自体はそれなりに速く感じる
- 遅いのは DB からデータを取る処理だけ
- App Router では、Server Component がどこで動いているか画面からは見えない
- 設定していないので、コードに痕跡がない
既定値は、コードに現れません。
「書いていないもの」を疑うのは、書いてあるものを読むより難しい。
東京に変えた結果
サーバー関数が動く場所を、東京(hnd1)に変えました🗼
| 変更前(米国東部) | 変更後(東京) | ||
|---|---|---|---|
| DB を使わないページ | 512ms | 142ms | -72% |
| DB からデータを取るページ | 1,688ms | 253ms | -85% |
| DB との通信 1 回あたり | 約 590ms | 約 55ms | 約 10 分の 1 |
料理の詳細ページは 1,690ms → 253ms、約 6.7 倍速くなりました🎉
一番速かった計測どうしで比べても 1,031ms → 233ms なので、
たまたま遅かったのではなく、設定のせいで毎回遅かったことが分かります。
DB を使わないページまで速くなっているのは、
ユーザーからサーバーまでの通信も、太平洋を渡らなくなったためです。両方が同時に縮みました✨
アプリのコードをいくら工夫しても、1 回の通信が 10 倍遅ければ勝負になりません。
通信の回数を減らす工夫(罠 2)の効果も、この 1 回の時間に比例します。
この設定はコードに残らない
厄介なのは、この設定がリポジトリに残らないことです😵
ダッシュボードで選ぶ項目で、当時の環境ではコードに書いて指定する手段が実質ありませんでした。
つまり、環境を作り直したら、また既定値に戻ります。
そこで、確認する方法をドキュメントに残しています👇
curl -s -o /dev/null -D - https://<あなたのドメイン>/ | grep -i x-vercel-id
# hnd1::hnd1::… ← 2 つ目が東京なら正しい
# hnd1::iad1::… ← 2 つ目が iad1(米国東部)なら、既定値のまま
レスポンスヘッダの x-vercel-id に、実際にプログラムが動いた場所が出ます。
1 コマンドで確認できるので、ぜひ自分のアプリでも一度見てみてください👀
ダッシュボードでしか設定できないものは、確認方法をドキュメントに残すのがおすすめです。
コードに残らない設定は、「設定したこと」自体が忘れられます。
罠 1: loading.tsx を足したら LCP が 2 倍になった
ここが、この記事のタイトルの話です🪤
なぜ loading.tsx を足したか
ログインが必要なページは、App Router ではアクセスのたびにサーバーで組み立てるページになります(動的レンダリング)。
そして Next.js 16 は、こうしたページの先読み(prefetch)をしません。
その結果、loading.tsx が無いと、リンクを押してもサーバーの応答が返るまで画面が変わりません。
押したのに反応が無い、という体感になります😩
そこで主要なページに loading.tsx を置いて、読み込み中の仮の画面(スケルトン)を出すようにしました💀
体感はたしかに良くなりました。 ところが…😨
LCP が倍になった
LCP は、ページで一番大きな要素(多くは画像)が表示されるまでの時間です。
| 状態 |
/home の LCP |
|---|---|
loading.tsx 導入前 |
5.3 秒 |
loading.tsx 導入後 |
10.4 秒 ← 倍近く悪化😱 |
一番大きな画像に preload を追加 |
4.2 秒 ← 導入前より速い🎉 |
※ スマホの 4G 相当に回線を絞った条件での値です。
原因:画像の読み込みが後回しにされていた
loading.tsx を置くと、ページ本体は準備できた部分から順に届くようになります(ストリーミング)。
このとき本体は、最初は隠された状態でブラウザに届きます。
ここで、next/image の <Image> が既定で付ける loading="lazy"(画面に入るまで読み込まない設定)が効いてしまいます💦
① 仮の画面(スケルトン)が表示される
② 本体が「隠された状態」で届く
③ <Image> の loading="lazy" が「この画像は画面内に無い」と判断する
④ 仮の画面が本体に差し替わるまで、画像の読み込みが始まらない ← ここで待たされる
つまり、LCP の対象になる一番大きな画像の読み込みが、差し替えの後まで後回しにされていたんです🐢
解決:一番大きな画像に preload を付ける
各ページで最初に目に入る画像に preload を付けました👇
// Before: 既定の loading="lazy" のまま。差し替えまで読み込みが始まらない
<Image src={heroUrl} alt={dishName} fill />
// After: preload で、本体が隠れていても先に読み込みを始める
<Image src={heroUrl} alt={dishName} fill preload />
これで画像の読み込みが先に始まり、loading.tsx を入れる前よりも速くなりました⚡
priority は Next.js 16 で非推奨になり、preload に置き換わっています。
内部的には同じ扱いですが、新しく書くなら preload を使います。
注意点として、一覧のカードのように、どれが一番大きく表示されるかが画面サイズで変わる画像には付けていません。
全部に付けると、本当に優先したい画像の読み込みが埋もれてしまうからです⚠️
loading.tsxは、一番大きな画像のpreloadとセットで入れる。
片方だけだと、体感は良くなるのに数値は悪化します。
これは計測していなければ気づけなかった罠でした。体感は良くなっているからです📊
罠 2: ログイン確認のたびに通信していた🔐
proxy は、すべての画面遷移の前に動く
Next.js 16 の proxy(旧 middleware)は、すべての画面遷移で、画面を組み立てる前に動きます。
ここで待たされた時間は、そのまま**最初の応答が返るまでの時間(TTFB)**に上乗せされます。先読みのリクエストも同じです。
最初は、ログイン状態の確認に Supabase の getUser() を使っていました。
これは毎回、認証サーバーに問い合わせます。つまり、すべての画面遷移に通信が 1 回増えていました😵
getClaims() で、サーバーの中で確認する
これを getClaims() に変えました👇
// Before: 毎回、認証サーバーに問い合わせる(通信 1 回)
const { data } = await supabase.auth.getUser()
// After: ログイン情報(JWT)の署名を、公開鍵を使ってその場で確認する(通信なし)
const { data: claims } = await supabase.auth.getClaims()
getClaims() は、ログイン情報(JWT)が本物かどうかを、公開鍵を使ってその場で確認します。
公開鍵は一度取得したらメモリに保存されるので、認証サーバーとの通信が要らなくなります✨
手元で本番用にビルドして測った結果(15 回の中央値)です📊
getUser() |
getClaims() |
|
|---|---|---|
| ログイン確認 | 32〜38ms | 1.8〜2.4ms |
/home の先読みの応答全体 |
84.6ms | 48.0ms |
「通信ゼロ」ではなく「実行環境ごとに 1 回」
ここは正確に理解しておきたい点です☝️
「リクエストのたびに 1 回」が「ゼロ」になるのではなく、
「サーバーの実行環境 1 つにつき 1 回」になる。
公開鍵の保存先は、サーバーの実行環境のメモリです。
なので、実行環境が新しく立ち上がったときや、別の実行環境にリクエストが振り分けられたときは、改めて公開鍵を取りに行きます🔁
引き換えに受け入れていること
その場で確認する方式では、ログイン情報の期限が切れるまで(最大 1 時間)、削除や BAN されたユーザーでも proxy を通過します⚠️
これを許容できるのは、proxy はあくまで入口の簡易チェックだからです。
本当の権限の判定は、ページ側のログイン確認と、データベースの行ごとのアクセス制御(RLS)が担っています。
何重にも守っているので、入口を軽くしても最終的には止まります🛡️
罠 3: ローカルの数値で本番の効果は測れない🧮
罠 2 の改善をローカルで測ると、本番より効果が小さく出ます。
ローカルでは、DB が同じパソコンの中にあるからです💻
| 環境 | DB との通信 1 回にかかる時間 |
|---|---|
| ローカル | 3〜5ms |
| 本番(東京) | 約 55ms |
| 本番(米国東部だった頃) | 約 590ms |
通信 1 回の時間が 10 倍以上違うので、「通信を 1 回減らす」工夫の効果は、ローカルでは小さく見えます。
本番か、実ユーザーのデータで評価する必要があります🌐
1 回の時間が変わると、工夫の価値も変わる
この表から分かることが、もう 1 つあります💡
罠 2 の後、まだ残っていた「オンボーディングが済んでいるか」を DB に確認する通信も、
ログイン情報(JWT)の中に入れて消す案がありました。
通信 1 回が 590ms だった頃なら、明らかにやる価値がありました👍
でも罠 0 を直して 55ms になった後では、話が変わります🤔
この案は、Supabase 側に追加の仕組み(フック)を登録して、開発用と本番用の両方で維持し続ける必要があります。
55ms のために、その手間を背負う価値は無いと判断して、見送りました🙅
同じ工夫でも、通信 1 回にかかる時間によって価値が変わる。
だから、1 回の時間を縮める対策(罠 0)を先にやるべきでした。
Suspense は入れなかった
罠とは別に、入れなかった判断も書いておきます✍️
順番待ちのデータ取得を、同時に取る形にする
Server Component でのデータ取得は、await で順番に待つ回数だけ、最初の応答が遅れます。
同時に取れるのに順番待ちになっていたものを、Promise.all でまとめて取るようにしました📦
| ページ | 順番待ちの回数(前) | 順番待ちの回数(後) |
|---|---|---|
/home |
5 回 | 2 回 |
/plates/[id](料理詳細) |
5 回 | 2 回 |
/spots/[id](店舗詳細) |
4 回 | 2 回 |
/me(マイページ) |
3 回 | 2 回 |
「1 本だけ遅い」ものが無いなら、Suspense は効かない
ここで Suspense を使うことも考えました🤔
Suspense は、すぐ出せる部分と時間のかかる部分があるときに、すぐ出せる部分を先に表示する仕組みです。
そこで /home で同時に取っている 13 本のデータ取得を、1 本ずつ測りました👇
getActiveBadgeCount 21.9 / getMyPageStats 21.8 / getMyNextTitle 21.6 /
getMyNextBadge 21.3 / getMyEvaluationCount 20.3 / getHomeUnevaluatedPicks 19.2 /
getHomeActivityFeed 18.8 / getMyTasteProfile 18.4 / getHomeArea 18.4 /
getHomeRecommendedDishes 15.7 / getMyReviewActivity 15.1 / getMatchCounts 13.1 /
getMyProfile 10.3 (平均 ms・15 リクエスト)
すべて 10〜22ms に横並びでした。後回しにして先に表示できるような「1 本だけ遅い」ものがありません🤔
しかも Suspense で区切るたびに、罠 1 と同じ「画像の読み込みが後回しになる」問題を踏むリスクが増えます。
効果の裏付けが無いので、入れていません🙅
最適化は、計測で「1 本だけ遅い」ものを見つけてから入れる。
横並びのところを区切っても、複雑になるだけです。
計測で踏んだ落とし穴
最後に、計測そのものの失敗も 1 つ🙈
ログインが必要なページを測るとき、ログインが切れていると / に飛ばされます(リダイレクト)。
以前のスクリプトは警告を出すだけで、その値も集計に入れていました😇
その結果、トップページの数値が、ホーム画面の数値として記録されていたんです。
出力された数字だけを見ても、気づけません👀
今は、別のページに飛ばされた計測は、中央値の計算から外しています✅
// 別のページへ飛ばされたら、測っている対象が変わっている。
// 警告だけでは中央値に混ざったままになるので、エラー扱いにして除外する
const landedOn = normalizePath(new URL(page.url()).pathname)
const requested = normalizePath(new URL(url).pathname)
if (landedOn !== requested) {
throw new Error(`${requested} → ${landedOn} にリダイレクトされた`)
}
計測結果を信じる前に、「測りたいものを測れているか」を確認する。
どこで止めたか
今のアプリは、ほぼストレスなく使えるところまで来ています😌
数値上は、まだ改善の余地があるはずです。
でも体感できるかどうか怪しい領域に入ったので、ここで止めています✋
0.2 秒を 0.15 秒にする作業と、その時間で機能を作る・不具合を直すことを比べたとき、
今のフェーズでは後者のほうが価値が大きいと判断しました。
「速くできる」と「速くする価値がある」は別。
体感の限界を超えたら、そこが止めどきだと考えています。
ユーザーが増えて条件が変われば、また再開します。
計測の仕組みはもう手元にあるので、いつでも同じ条件で測り直せます📊
まとめ
- 改善は順番が大事。通信 1 回の時間 → 通信の回数 → 見せ方の順に直す
- Vercel のサーバー関数は、指定しないと米国東部で動く。
x-vercel-idで一度確認する🌊 -
loading.tsxは、一番大きな画像のpreloadとセットで入れる。片方だけだと LCP が悪化する🪤 - proxy のログイン確認は
getClaims()でその場で確認。ただし「実行環境ごとに 1 回」は通信が残る🔐 - ローカルの数値で本番の効果を見積もらない。通信 1 回の時間が違えば、工夫の価値も変わる🧮
-
Suspenseは、「1 本だけ遅い」ものを計測で見つけてから入れる - 計測結果は、測りたいものを測れているかを先に確認する
- 体感の限界を超えたら止める✋
「体感は良くなったのに、数値は悪化していた」という罠は、計測していなければ気づけませんでした。
Next.js 16 でパフォーマンスを詰めている方の参考になれば嬉しいです💡
シリーズの他の記事も、よろしければ📚
このシリーズでは、個人開発サービス「あじぴた」の設計をテーマごとに書いています。
サービスの全体像や、ほかの記事の一覧はハブ記事にまとめています👇
🔗 「低評価を公開しない」口コミサービスを個人開発した話 — 4,300コミット・6リポジトリの全体像
前回は、Google Places API のコストを抑えた話を書きました💸
🔗 Google Places APIで月$1,440の請求が来る前に — 個人開発で従量課金を「呼ばない」8層の防壁
次回は、1 つのサービスを 6 つのリポジトリで運用している話を書く予定です。
同じ DB を、アプリ本体と運営コンソールで異なるやり方で扱っている理由を書きます🔑
設計の中身を通して読みたい方には、解剖ドキュメントも公開しています🔬
🔗 あじぴたの内側(ajipita-inside)
参考になれば幸いです🙏