Astro移行後のブログを実測でチューニングした記録

CloudflareAstroPerformance

パーマリンク

はじめに:移行完了の「その後」

前回の記事「はてなブログをCloudflare Workersへ移行した1日」では、長年運用してきたブログの公開基盤をはてなブログからCloudflare Workersへと移行した経緯を書きました。

執筆や下書きプレビューのフローは使い慣れたはてなブログ側に残しつつ、GitHub ActionsでMarkdownを取得し、静的サイトジェネレータのAstroでビルドしてCloudflare Workersへデプロイする、という構成です。記事内の画像もCloudflare R2へ集約しました。

その前編は、こんな一文で締めくくっていました。

この状態でしばらく運用してみようと思います。

移行作業を終えた直後は、無事にページが表示され、ドメインの切り替えやOGP画像の表示トラブルも解決して一息ついていました。しかし、公開基盤の引っ越しが終わったあとに待っていたのは、「実際のところ、このサイトはどれくらい快適に閲覧できているのか?」という現実の検証でした。

今回は、移行後のブログをPageSpeed Insightsで実際に測定しながら、段階的にパフォーマンスを改善していった試行錯誤の記録です。

なぜAstroを選んだのか

前回の記事では「Astroを採用した」とだけ触れて、選定理由を書いていませんでした。少しだけ補足しておきます。

以前Vercel上でAstroを試した経験があり、そのときの開発体験にとても良い感触を持っていたことが大きなきっかけでした。

このブログは、複雑なクライアントサイドの動的処理をほとんど必要としません。必要なのは、読みやすく整形された静的なHTMLと、記事ごとのメタデータ、そしていくつかのリンクや画像です。AstroはデフォルトでクライアントサイドへJavaScriptを出力しない「Zero-JS by default」を掲げており、Markdownとの親和性も抜群です。

さらに決め手となったのは、生成されるHTMLやhead内のSEOメタデータを、開発者が自分の手で1行単位で制御できること、そして公式アダプタ(@astrojs/cloudflare)を使ってCloudflareのエッジ環境へすっきりと載せられることでした。

「開発体験が気持ちよくて、自分のブログの要件に合っていそうだから選んだ」というスタートでしたが、今回のパフォーマンスチューニングを進める中で、この「生成HTMLを完全に掌握できる」というAstroの特性が、想像以上に良い効果をもたらしてくれました。

移行が終わったので性能を測ってみた(初期ベースライン)

「静的サイトジェネレータ(SSG)で作ってCloudflareのCDNから配信しているのだから、圧倒的に速いはずだ」

本番環境のURLをGoogleのPageSpeed Insights(PSI)に入力してみました。

初期の測定結果(本番ベースライン)

測定環境 Performance Score FCP LCP TBT CLS Speed Index
Mobile 55 11.6s 20.8s 0ms 0 11.6s
Desktop 66 2.0s 5.6s 0ms 0 2.0s

モバイルのPerformanceスコアは55点。しかも、最初のコンテンツが表示されるまでの時間(FCP: First Contentful Paint)が11.6秒、最大コンテンツの描画時間(LCP: Largest Contentful Paint)が20.8秒…か…

では、改善していきましょう。

まず測定のルールを決める

ここから改善作業に入るわけですが、闇雲にネットで見かけた「高速化テクニック」を片っ端から試すのはやめようと決めました。

PageSpeed Insights(Lighthouse)を使ったことがある方ならご存知の通り、測定結果には少なからず実行ごとのブレがあります。ネットワークの揺らぎやテスト端末の負荷によって、何も変更していなくてもスコアが数点上下することは珍しくありません。

スコアの数字だけに一喜一憂していると、「何が効いて何が効かなかったのか」がわからなくなってしまいます。そこで、作業を進めるにあたって以下のような測定ルールを敷きました。

  1. 必ず本番環境(production URL)で実測する: ローカル環境やステージング環境の数値だけで判断せず、本番のデプロイを経てから測定する。
  2. Mobileは原則2回、Desktopは1回測定する: 特にモバイル環境はブレが大きいため、連続して2回測定を行い、再現性を確認する。
  3. Core Web Vitalsと詳細診断を併せて追う: Performanceスコア単体ではなく、FCP、LCP、TBT、CLS、Speed Indexの各指標、さらにDOM要素数、総転送量、未使用JS、メインスレッドのLong Tasks、個別リソースの転送バイト数まで確認する。
  4. 一度に一つのテーマだけを変更する: 複数の施策を同時に混ぜず、「仮説 → 実装 → 本番デプロイ → 同条件で再測定」というサイクルを愚直に回す。
  5. スコアではなく根本原因(Root Cause)を見る: たとえスコアが上振れ・下振れしても、狙ったリソースの転送量が確実に削れているか、実行順序が意図通りに変わっているかを重視する。

この方針を固め、Claude Codeと二人三脚でボトルネックを1つずつ解体していくことにしました。

第1の施策:画像の読み込み優先度・寸法・レスポンシブ化

初期測定のLCP 20.8秒という数字から考えて、真っ先に疑ったのは画像でした。トップページには記事カードがずらりと並び、各記事にもアイキャッチや本文画像が含まれています。

画像周りの最適化は、大きく分けて3つのステップで進めました。

1. LCP画像の特定と読み込み優先度の制御

ブラウザに対して「どの画像を最優先で読み込み、どの画像を後回しにすべきか」を明示しました。

  • 記事の最初の本文画像、およびトップページ一覧の先頭カード画像のみ、loading="eager" かつ fetchpriority="high" を指定する。
  • それ以外の画像(ファーストビューに入らない画像、Amazonのアフィリエイトカード、関連記事カードなど)はすべて loading="lazy" かつ decoding="async" を徹底し、LCP候補から明示的に除外する。

2. Intrinsic Dimensions(実寸)の付与によるレイアウトシフト防止

画像が表示される際に画面がガタッと揺れる現象(レイアウトシフト)を防ぐには、HTMLの<img>タグにwidthとheightが書かれている必要があります。

しかし、通常ビルドのたびに外部のR2やはてなフォトライフから画像サイズを取得しにいくと、ビルド時間が肥大化してしまいます。そこで、画像の実寸情報をあらかじめ抽出したマニフェストファイル(image-dimensions.json)をリポジトリ内で管理し、Astroのビルド時にその値を注入する仕組みを構築しました。本文画像199枚中196枚に実寸が付与され、CLS(Cumulative Layout Shift)は常に「0」を維持できるようになりました。

3. Cloudflare Images + R2 によるレスポンシブ画像配信

これまではR2に保存された元画像をそのまま配信していましたが、スマホの小さな画面に対してデスクトップ用の大きな画像をそのまま送るのは帯域の無駄です。

Worker内にCloudflare Imagesのバインディングを組み込み、/img/{width}/{key}(幅320, 480, 640, 960, 1200px)というエンドポイントを新設しました。Astro側では画像の表示幅に応じたsrcsetとsizesを出力し、ブラウザの画面幅や回線状況に応じて最適なサイズのWebP/AVIF画像が自動で届くようにしました。

画像最適化後の結果

この画像最適化を本番環境へデプロイし、再測定を行いました。

  • Mobile LCP: 20.8s → 13.8s(約34%改善)
  • Desktop LCP: 5.6s → 2.4s(約57%改善)

LCPの待ち時間は大きく縮まりました。デスクトップでは目に見えて表示が軽快になり、施策の方向性が正しかったことが確認できました。

しかし、モバイルのFCP(最初のコンテンツ描画)が、11.6秒のままです。

画像が小さくなっても、最初の画面が白く抜けたまま11秒以上待たされる状態が続くということです。 さてその原因はといいますと…。

第2の施策:Google Fonts

「なぜ静的なHTMLを返しているのに、最初の描画までに11秒もかかるのか?」

詳細な診断ログを掘り下げていくと、驚くべき事実が判明しました。

サーバーの応答速度(TTFB: Time to First Byte)を調べると約10ms、ドキュメント自体の取得も約106msで、Cloudflare Workersの応答は極めて高速でした。インフラ側には何の問題もありません。

モバイルでの総転送量約2,570 KiBのうち、なんと1,923 KiB(全体の約75%)を外部の「Google Fonts」が占めていたのです。

なぜGoogle Fontsがそれほど重かったのか?

当初ブログでは本文に「Noto Sans JP」、コードブロックに「M PLUS 1 Code」を指定していました。もちろん、Webフォントの遅延描画を避けるためのfont-display: swapは指定していましたし、Google Fontsへのpreconnectも記述していました。

それでも遅かった原因は、日本語フォント特有の仕組みと、ページの構造にありました。

アルファベットだけの欧文フォントと違い、日本語フォントには数千字に及ぶ漢字やひらがな・カタカナが含まれます。Google Fontsはこれを数百個の小さなファイル(Unicodeサブセット)に分割し、ページ内で実際に使われている文字を含むファイルだけを動的に取得させる仕組みをとっています。

しかし、私のブログのトップページには多数の記事タイトルや抜粋文が並んでおり、扱われている文字の種類が非常に多かったわけです。その結果、ページを開いた瞬間に膨大な数のフォントサブセットが次々とリクエストされ、外部スタイルシートのパース待ちと合わさって、ブラウザのレンダリングパイプラインを完全に詰まらせていました。

誤解のないように強調しておきますが、これは「Google Fontsというサービスが遅い」とか「使うべきではない」という話ではありません。 「文字種類の多い日本語サイトで、ファーストビューに大量のテキストを並べた場合、動的なフォントサブセット取得がクリティカルパスの致命的な障害になり得る」という条件を重ねた場合には、遅くなるよね…ということです。

システムフォントスタックへの移行

ブログの読みやすさを考えたとき、外部フォントに10秒以上のブロッキングを許容する価値があるだろうかと考え、思い切ってGoogle Fontsをすべて取り外す決断をしました。

HTMLからGoogle Fontsのスタイルシートリンクとpreconnectをすべて削除し、OS標準のフォントスタックへ切り替えました。macOSやiOSではヒラギノ、WindowsではYu Gothicやメイリオ、AndroidではRobotoやNoto Sansで表示されるはずです。

/* システムフォントスタックの指定例 */
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 
  "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Yu Gothic", sans-serif;

あわせて、CI(GitHub Actions)のSEOテストに「生成HTML内にfonts.googleapis.comやfonts.gstatic.comが混入していないこと」を確認する回帰テストを追加し、本番へデプロイしました。

結果:大幅な改善

指標 Google Fonts 削除前 (Mobile) 削除後 Run 1 (Mobile) 削除後 Run 2 (Mobile)
Performance 55 98 86
FCP 11.6〜13.0s 0.4s 1.2s
LCP 13.8〜14.4s 1.1s 4.2s
Speed Index 11.6〜13.0s 0.8s 2.4s

測定ごとのブレはあるものの、11〜13秒台にとどまっていたMobile FCPが、0.4秒〜1.2秒へと大幅な改善です。デスクトップのFCPも2.2秒から0.4秒に短縮されました。

第3の施策:巨大なDOM対策

PageSpeedの診断項目を眺めていると、次はこれだなとなりました。 「DOMサイズの過大(約3,481 elements)」。

トップページ全件表示しているから

当初のトップページは、過去記事の一覧をすべて(約104件のカード)1枚のページに展開していました。

カード1枚あたりの中には、サムネイル、タイトル、公開日、カテゴリバッジ、抜粋文、リンクタグなど、数十個のHTMLタグが含まれます。それが104件分並ぶと、トータルのDOM要素数は約3,481個、HTMLファイルのサイズも約245KB(文字数にして約24.5万文字)に膨らんでいました。

サーバー側の負荷がゼロであっても、受信した巨大なHTMLをパースし、数千個のノードからなるDOMツリーをメモリ上に展開してレイアウト計算を行うブラウザ側の負荷までは消えてくれません。特に低スペックなモバイル端末では、これがメインスレッドの貴重なリソースを奪う原因になります。

ページネーションの導入

そこで、トップページにページネーションを導入しました。

  • トップページ(/)に表示するカードを最新の20件に制限。
  • 残りの記事は /page/2/ 〜 /page/20/ のアーカイブページへ分割。
  • サイト内検索エンジン(Pagefind)の検索インデックス対象(661ページ)はそのまま維持。

結果:DOMが91%スリム化

この変更により、トップページの構造は劇的にスリム化されました。

  • DOM要素数: 約3,481個 → 323個(約91%削減)
  • トップページHTMLサイズ: 約245KB → 約45KB

HTMLサイズが約5分の1になり、ブラウザのパースコストが激減しました。「SSGであっても、クライアントに渡すHTMLの規模には気を配らなければならない」という貴重な学びでした。

第4の施策:画像の品質調整

大きな問題が片付いてくると、「数十KiB単位の小さな無駄」がPageSpeedの診断リストの上位に浮上してきます。

1. レスポンシブ画像の圧縮品質チューニング

Cloudflare Imagesを経由した画像配信の仕組みはすでに動いていましたが、生成される画像バリアントの品質(quality)を精査しました。

URLのキャッシュ互換性を保つためにエンドポイントを /img/v2/{width}/{key} へ更新し、AVIF・WebP・JPEGの変換品質を明示的に quality=75 に設定しました(PNGはそのまま維持)。

本番デプロイ後の測定では、以下のような成果が得られました。

  • PageSpeedの画像改善余地(Improve image delivery): 84 KiB → 32 KiB
  • 代表的なLCP画像サイズ: 60.2 KiB → 47.4 KiB(約21%削減)

2. 「Favicon」の最適化

続いて診断項目に残っていたのが、Faviconに関する警告でした。 「ファビコン1枚で約10.3 KiBの削減余地がある」と指摘されていたのです。

確認してみると、サイトヘッダーで参照していた favicon.png は、PWAやGoogleの検索結果(構造化データ)で推奨される高解像度(192×192px、約11.1KB)のファイルでした。PCのタブやスマホのブラウザUIに小さく表示させるだけの用途としては、確かに大きすぎますね…

そこで、構造化データやマニフェスト用の192×192pxファイルは維持したまま、HTMLの<link rel="icon">用として、UI表示に特化した64×64pxの軽量画像(favicon-64.png、約2.8KB)を用意して差し替えました。

わずか8KBほどの削減ですが、これによりPageSpeedの画像配信診断からFaviconの警告が完全に消え去り、全体の画像改善余地も32 KiBから約21 KiBへと減少しました。

第5の施策:Google tag(gtag.js)

ここまで改善を進めてきた段階で、診断項目に最後に残った大きな塊がありました。Google Analytics(GA4 / direct gtag.js integration)です。

  • 転送量: 約180 KiB
  • 未使用JavaScript: 約73〜74 KiB
  • メインスレッド処理時間: 約137ms

現代のWebサイトにおいて、アクセス解析タグがパフォーマンスに与える影響は常々議論の的になります。

測定の完全性(Measurement Integrity)を壊さない

Webの高速化テクニックの中には、

  • 「requestIdleCallback や setTimeout で読み込みを5秒遅らせる」
  • 「ユーザーが画面をスクロールするかクリックするまでスクリプトを一切読み込まない」

といった手法がよく紹介されています。

確かに、そうした設定を入れればPageSpeedのラボスコアは跳ね上がります。しかし、それをやってしまうと**「ページを開いて数秒以内に離脱した読者」のアクセスが一切計測されなくなってしまいます**。

ブログを運営している以上、読者がどこから来てどの記事を読んでくれたのかを知るアクセス解析は大切な機能です。スコアの数値を良くするためだけに解析の正確性を壊すのは、手段と目的が逆転しています。

async から defer への変更のみに留める

今回は、タグの読み込み属性を <script async> から <script defer> へ変更するだけに留めました。

Measurement IDの指定や window.dataLayer、自動的な page_view 送信といったGA4本来の挙動は一切変更していません。

ダウンロードされるファイルの中身自体は同じなので、約74 KiBの未使用JSや約180 KiBの転送量そのものは減りません。しかし、deferにすることでHTMLのパースをブロックすることなくバックグラウンドで並行ダウンロードされ、HTMLの解析完了後に実行されるようになります。

結果:メインスレッドの負荷が軽減

この変更を本番へデプロイし、モバイル環境で独立した2回の測定を行いました。

指標 PR 適用前(Mobile) PR 適用後 Run 1 (Mobile) PR 適用後 Run 2 (Mobile)
Performance Score 80〜81 87 87
FCP 2.5〜2.6s 0.9s 0.9s
LCP 3.9〜4.0s 3.9s 3.9s
TBT (Total Blocking Time) 90〜100ms 70ms 70ms
Speed Index 4.6〜4.8s 2.2s 2.2s
Long Tasks 約3回 2回 2回

2回の測定で見事に同じ数値が再現されました。 FCPは0.9秒で安定し、TBT(ブロッキング時間)は20〜30ms減少し、50msを超えるLong Tasksの発生回数も3回から2回へと減りました。Lighthouseの実行ログからも、GA4のデータ収集エンドポイントへ正常にビーコンが飛んでいることが確認できています。

アクセス解析としての正確性を一切損なうことなく、実行タイミングの適正化だけで着実にユーザー体験を底上げすることができました。

最終結果:ビフォーアフター

すべての施策を終えた段階で、初期の本番ベースラインと最終状態を比較してみます。

モバイル環境(Mobile)の推移

一番の懸念であったモバイル環境の改善状況は以下の通りです。

測定指標 初期状態(Baseline) 最終状態(Final) 改善度
Performance Score 55 87 +32 pt
FCP(最初のコンテンツ描画) 11.6s 0.9s 約92% 短縮
LCP(最大コンテンツの描画) 20.8s 3.9s 約81% 短縮
DOM 要素数 約3,481 323 約91% 削減
CLS(レイアウトのズレ) 0 0 ゼロを維持

デスクトップ環境(Desktop)の推移

デスクトップでは、最終的に Performance 97〜99、FCP 0.3秒、LCP 1.0〜1.1秒、TBT 30〜70ms、CLS 0 という極めて快適な水準に到達しました。

振り返りとまとめ

今回のパフォーマンスチューニングを通して得られた最大の教訓は、「Astroを採用したから自動的に高速だったわけではない」ということです。

初期の測定結果が示していた通り、たとえモダンな静的サイトジェネレータを使い、強力なCDNエッジから配信していたとしても、ブラウザが解釈するリソースに無駄があれば、簡単にMobile LCP 20秒超えの「激重サイト」になってしまいます。

では、今回の取り組みにおいてよかったと感じた点は以下の通りです。

  1. Astroのおかげで、生成されるHTMLと読み込み順序を完全にコントロールできたこと
  2. Cloudflare Workers / Imagesのおかげで、画像配信の最適化をインフラ側で自在に組み立てられたこと
  3. GitHub Actionsのおかげで、回帰テスト(テストゲート)を設けて意図しない劣化を防ぎ続けられたこと
  4. PageSpeed Insightsで本番を実測し、一番大きなボトルネックから順に解体できたこと

まず測定して、効果が大きいところから手をつけるという、地道ではありますが、パフォーマンスチューニングにおいてこれ以上に確実な近道はないのだと、実感できました。

なお、技術SEOや基盤改善を扱っていたGitHubのメインIssueはこれで完了としてクローズしましたが、細かい課題は、それぞれ独立したIssueへ切り出して今後も継続していく予定です。

ブログの基盤が整い、表示速度の足回りも軽快になりました。これからも試行錯誤を楽しみながら、記事も投稿していこうと思います。