Loading post
Sep 04, 2026

個人サイトの表示自体は壊れていない。でも、ホームを開くと少し待つ。記事カードを押しても、Mediumのようにすぐ本文へ切り替わらない。そんな曖昧な違和感から、meetyudai.comの公開130 URLとローカル87ルートを調べ、画像、データ取得、React Server Components、hydration、認証、i18n、モバイル表示まで横断して直した。結果、ホーム表示は約3.4秒から0.46秒、記事クリックは約3.3秒から0.52秒になった。この記事では、何が遅かったのか、何が効いたのか、そしてなぜ「もっと速くできる」のにここで止めたのかを、実測データとともに振り返る。English version: blog-nextjs-site-performance-audit.en.md
このタイプの性能問題は扱いにくい。
5秒間真っ白になるなら、誰が見ても遅い。APIが500を返すなら、調べる場所も分かりやすい。今回のサイトはそうではなかった。ページは開くし、画像も表示されるし、リンクも動く。ただ、クリックしてから画面が変わるまでに一拍ある。特に記事のようなサブページで、その一拍がMediumなどの読み物サイトより長く感じた。
最初は画像が原因だと思った。実際、画像には大きな問題があった。しかし、調べてみると遅さは一つのファイルではなく、複数の小さな待ち時間が積み重なってできていた。
ユーザーから見えるのは「クリックしても少し反応しない」だけだが、内側ではサーバー、ネットワーク、React、画像、認証が順番に少しずつ時間を使っていた。
性能改善では、コードより先に測定条件を決めないと、都合のよい数字だけを拾えてしまう。今回は次の三つに分けた。
| 操作 | 変更前 | 変更後の中央値 | 待ち時間の短縮 | 速度比 |
|---|---|---|---|---|
| ホーム初期表示 | 約3.4秒 | 0.462秒 | 86.4% | 約7.4倍 |
| ブログから記事へ遷移 | 約3.3秒 | 0.520秒 | 84.2% | 約6.3倍 |
変更後の5回分も隠さず載せておく。
| 環境 | 指標 | 5回の実測値 | 中央値 |
|---|---|---|---|
| デスクトップ相当 | ホーム表示 | 762 / 348 / 440 / 462 / 514ms | 462ms |
| デスクトップ相当 | 記事クリック | 443 / 548 / 449 / 523 / 520ms | 520ms |
| Pixel 5相当 | ホーム表示 | 605 / 419 / 508 / 697 / 391ms | 508ms |
| Pixel 5相当 | 記事クリック | 538 / 541 / 545 / 527 / 540ms | 540ms |
これは同時刻・同一ネットワーク条件で旧版と新版を並べた厳密なA/Bテストではない。変更前は問題発見時の単発観測で、変更後はデプロイ後の複数回測定だ。そのため「必ず7.4倍」と一般化するのではなく、3秒台だった待ちが、おおむね0.5秒前後まで下がったという大きさを見るためのデータとして扱っている。
| ページ | 8回の中央値 | 平均 | 最小 | 最大 |
|---|---|---|---|---|
| ホーム | 283ms | 477ms | 198ms | 1,209ms |
| ブログ一覧 | 258ms | 273ms | 201ms | 455ms |
| 記事詳細 | 373ms | 369ms | 303ms | 462ms |
| Projects | 349ms | 373ms | 214ms | 768ms |
中央値はかなり良くなった。一方で、ホームには1.2秒の外れ値もある。これは後で触れる「まだdynamic SSRである」という構造と一致している。
ホームにはプロフィール、最新記事、プロジェクト、職歴、学歴、ゲーム設定、履歴書リンクなど、多くの情報がある。以前は初期HTMLが届いた後、ブラウザ側で複数の取得と再描画が始まる構造が多かった。
そこで、公開情報は可能な範囲でサーバー側から並列取得し、最初の描画へ初期値として渡した。同一リクエスト中や短時間に同じデータを取り直す経路にはキャッシュを使い、匿名ユーザーには不要な認証トークン取得も省略した。
ここで重要なのは、サーバーで取れば何でも速いわけではないことだ。直列処理をサーバーへ移しただけなら、待つ場所がブラウザからサーバーへ移るだけになる。効いたのは、独立した取得を並列化し、重複取得をなくし、取得結果を最初の描画へ使ったことだった。
ブログ一覧に必要なのは、タイトル、概要、カテゴリー、日付、カバー画像などだ。本文全体は、記事を開くまで必要ない。
ところが以前は、一覧に表示しない本文や翻訳データまでクライアントへ渡る経路があった。サンプル5記事をシリアライズすると約257KB。これを要約中心のデータへ変更すると、約5.3KBになった。
| ペイロード | 変更前 | 変更後 | 削減率 |
|---|---|---|---|
| 記事一覧・5記事 | 257KB | 5.3KB | 97.9% |
| 記事詳細 | 305KB | 164KB | 46.2% |
記事詳細では、日本語と英語の本文を同時に埋め込まず、今表示している言語だけを渡すようにした。言語を切り替えると対応URLへ遷移してもう一方を取得する。その一回の追加取得と引き換えに、ほとんどの通常閲覧で不要な本文を運ばずに済む。
これは「gzipを強くする」といった転送上の工夫より前に、そもそも送らないという改善である。今回もっとも効率のよかった変更の一つだった。
記事カードをクリックしてから、遷移先のサーバーレスポンスを待ち始めると、ユーザーには無反応に見える時間が生まれる。
上位記事カードと関連記事はNext.jsのLinkでprefetchするようにし、route-levelのloading.tsxも追加した。Next.jsの公式ドキュメントでも、Linkのprefetch、loading UI、streamingは、dynamic routeの遷移待ちを隠し、操作への即時フィードバックを作る仕組みとして説明されている。
prefetchは無料ではない。見えている全リンクを無条件で完全取得すれば、クリックされないページの通信も増える。今回は「次に開かれる可能性が高い上位記事」と「読了後に選ばれやすい関連記事」に絞った。
参考:Next.js — Linking and Navigating
画像のlazy loadingは必要だった。ただし、lazy loadingだけではファイルそのものは軽くならない。画面外の2MB画像を後で読むようにしても、最終的には2MBを読む。
そこで記事一覧をNext.js Image Optimizationへ戻し、sizesとレスポンシブなsrcsetを使った。実際の画像では次の結果になった。
| 画像 | 転送量 |
|---|---|
| 原寸PNG | 2,193,208 bytes |
| 640px WebP | 23,438 bytes |
| 削減 | 約98.9% |
画面外の一覧画像、ゲームスライド、Markdown本文、古いHTML本文にはlazy loadingを適用した。一方、ファーストビューのヒーロー画像まで遅延させるとLCPを悪化させるため、LCP候補はeager/high priorityのままにした。
Next.jsのImageは端末に合うサイズと形式を生成し、デフォルトでブラウザのnative lazy loadingを使う。sizesを正しく渡すことで、ブラウザは必要以上に大きな候補ではなく、表示幅に合う画像を選びやすくなる。
参考:Next.js — Image Optimization、Image Component
最初の変換はローカル計測で約1.6秒かかった。キャッシュ済みでは約0.002秒だった。つまり、画像最適化は処理を消すのではなく、最初の一回で変換し、その後の転送量を大幅に減らす仕組みだ。ホスティング側の画像最適化使用量も増える。
それでも、2.19MBを毎回送るより23KBをキャッシュして送る方が、このサイトでは圧倒的に有利だった。
ネットワークだけ速くしても、メインスレッドが忙しければクリックは重く感じる。
監査では、共有していたi18nインスタンスがサーバーとクライアントで異なる状態を作り、複数ページでReact hydration error #418を起こしていた。i18nをリクエストとProvider単位のインスタンスへ変更し、サーバーのHTMLとクライアントの初期状態を揃えた。
同時に、ページビュー記録をmain threadのidle時へ遅らせ、ホーム専用のAOS CSSと初期化を全ページから外した。認証初期化も、匿名訪問者に必要な仕事とログイン後だけ必要な仕事を分けた。
一つひとつは小さな変更だ。しかし、初期表示直後に集中していた処理を散らすと、ブラウザが本当に必要な描画と操作へ先に時間を使える。
320px幅で87ルートを回ると、最初の巡回では3ページが横にはみ出していた。
| ページ | viewport | 修正前のページ幅 | 原因 |
|---|---|---|---|
| Roulette | 320px | 397px | 盤面とベット表の固定幅 |
| Kuizu custom new | 320px | 363px | 操作ボタンの横並び |
| Settli new | 320px | 322px | 狭い幅でのボタン配置 |
Rouletteは盤面をレスポンシブ化し、ベット面は読めないほど縮小せず、必要な部分だけ内部横スクロールにした。KuizuとSettliはモバイルで操作を縦積みにした。
さらに認証初期化を待って再検査すると、Flashcardsだけが未ログインでもAPIを呼び、Unauthorizedを表示し、351pxまで横にはみ出していた。未ログイン時はAPIを呼ばず、redirect付きのSign Inを表示するようにした。
性能テストで大切なのは、速くエラーを表示することではない。正しい画面が、正しい状態で、速く表示されることだ。
| 検証 | 対象 | 結果 |
|---|---|---|
| 公開HTTP巡回 | sitemapの130 URL | 130/130が200 |
| 公開モバイル巡回 | 130 URL | 全URLで本文描画を確認 |
| ローカルdesktop巡回 | 87ルート、1280px | 実行エラー0、横はみ出し0 |
| ローカルmobile巡回 | 87ルート、320px | 実行エラー0、横はみ出し0 |
| 認証待ちページの再確認 | 14ルート | 空表示0、認証エラー露出0、横はみ出し0 |
| Unit tests | Vitest | 394 files、5,288 passed、1 skipped |
| E2E | Playwright | 21/21 passed |
| Pull request checks | CI / Security / Vercel | 15/15 successful |
変更はPR #755としてレビューし、3件の有効な指摘を追加修正した後、通常のmerge commitでmainへ取り込んだ。Vercelの本番デプロイ完了後に、もう一度公開サイトを測った。
速度改善は、何かを消せば簡単に数字を作れる。今回は表示内容や意図した画質を削っていないが、トレードオフはある。
大事なのは「犠牲はない」と言い切ることではなく、何を守り、どのコストを受け入れたかを明示することだと思う。
build結果を見ると、公開ページの多くはまだdynamic SSRである。ルートlayoutが言語とセッション判定のためにcookies()とheaders()を読むので、リクエストごとにサーバー処理が必要になる。
言語をURLへ完全に寄せ、公開ページから認証依存を外せば、HTMLとRSC payloadをCDNから返しやすくなる。Next.jsも、prerenderされた結果はキャッシュしてCDN配信できる一方、dynamic APIsを使うページはprivateな動的レスポンスになると説明している。
参考:Next.js — Linking and Navigating、Next.js — Self-Hosting and CDN caching
では、なぜ今すぐやらないのか。
最初の改善は3秒以上の待ちを約0.5秒へ縮めた。次の変更は、中央値をさらに数百ms縮め、1.2秒のような遅い外れ値を減らす可能性がある。しかし、URL、canonical、hreflang、言語切替、認証境界、キャッシュ無効化を同時に設計する必要がある。得られる改善に対して、変更範囲とSEOリスクが大きい。
これは「もう改善できない」ではない。改善できるが、今は測定なしで着手するほど優先度が高くないという判断だ。
今後は実ユーザーのp75を集め、次の条件が出たら再開する。
GoogleはCore Web Vitalsをp75で評価し、Goodの目安をLCP 2.5秒以下、INP 200ms以下、CLS 0.1以下としている。ラボの一台だけでなく、遅い側のユーザーも含むp75で見るのが重要だ。
参考:web.dev — Core Web Vitals thresholds、Vercel — Speed Insights
画像は大きな要因だったが、遅さの全てではなかった。サーバー待ち、RSC payload、hydration、認証、analyticsが積み重なっていた。性能は一つのスコアではなく、経路全体の問題だった。
257KBを少し圧縮するより、一覧に不要な本文を外して5.3KBにする方が効いた。最適化の第一問は「どう速く送るか」ではなく、「これは本当に送る必要があるか」だった。
prefetchは実際の待ちを前倒しし、loading UIは残る待ちに即時反応を与える。どちらか一方だけでは足りない。数字と手触りを同時に見る必要がある。
横はみ出し、未ログインAPI、hydration errorのような問題は、代表ページだけのLighthouseでは見逃しやすい。ルート全体を同じ条件で回ると、サイトを「ページの集合」ではなく「一つの製品」として見られる。
3.4秒から0.46秒は大きい。0.46秒からさらに数百msを削る価値は、実ユーザーが困っているかで決まる。速さそのものを目的にせず、次の改善を始める条件を決めて止めることも、性能設計の一部だ。
今回の改善で、僕の個人サイトは「動くけれど、一拍待つサイト」から「普通に触るとほぼすぐ反応するサイト」になった。
効いたのは派手な全面書き換えではない。データを並列に取る。重複して取らない。使わない本文を送らない。次のページを先に準備する。画面外の画像は後で読み、表示幅に合うサイズを送る。hydration errorをなくし、初期描画と競合する処理を後ろへずらす。そしてデスクトップだけでなく、全ルートをモバイルでも回る。
この積み重ねで、ホームと記事遷移の待ち時間は約84〜86%減った。
まだstatic/ISR+CDNという大きなカードは残っている。でも、今は切らない。次は実ユーザーのデータを集め、本当に遅い場所が見つかったときだけ直す。その方が、速さのための最適化ではなく、読者のための最適化になる。
engineering-practicesnextjs、web-performance、performance-audit、image-optimization、core-web-vitalshow-i-made-my-nextjs-site-6x-faster