個人ブログを実測ベースでパフォーマンスチューニングした話 〜PageSpeed Insights で Mobile スコアを 48 → 99 まで押し上げた〜

目次

これはなに

  • このブログ (naoki11o.com) のパフォーマンスを PageSpeed Insights で計測したら衝撃のスコアが出ていたので、地道に直した話です
  • Astro + Cloudflare Pages 構成のブログで「画像系・サードパーティJS系のチューニング」をやった人には、まあまあ参考になるかと思います
  • ついでに、今後の記事公開で同じ罠にハマらないように PR で PSI を自動実行する仕組みも整えました

はじめに

n_11oです。

少し前にこのブログを super.so から Astro 6 + Cloudflare Pages に移管しました。

「Astro は SSG で爆速」「Cloudflare Pages は CDN で配信が速い」「もうパフォーマンスは何も気にしなくていいはず」と思って、しばらく満足していました。

しばらく経ってからふと思い立って、PV 上位の記事を PageSpeed Insights API で計測したら…

🙍‍♂️ < あ、なんか思ったより低いやつあるな…?

ということで、「思い立ったが吉日」の精神でちゃんと直すことにしました。

そもそも PageSpeed Insights とは

ご存じの方は読み飛ばしてください。

PageSpeed Insights (略して PSI) は、Google が提供しているウェブページのパフォーマンス計測ツールです。

URL を 1 つ放り込むだけで、Google の Lighthouse エンジンがそのページを実際にロードし、以下の 4 カテゴリに分けて 0〜100 のスコアを出してくれます。

カテゴリ内容
Performance表示速度・体感速度。LCP、CLS、TBT などの指標から算出される総合点
Accessibility視覚障害者・キーボード操作などへの配慮 (alt 属性、コントラスト比、ARIA 等)
Best PracticesHTTPS の使用、コンソールエラーの有無、安全な API 使用、等
SEOmeta description・viewport・robots.txt 等の SEO 基本設定

Performance スコアを左右する主要な指標

特に Performance スコアは、ユーザー体験に直結する以下の指標から算出されます (Core Web Vitals と呼ばれる)。

指標意味良 / 改善要 / 不良
LCP (Largest Contentful Paint)一番大きな要素 (画像やテキスト) が表示されるまでの時間≤ 2.5s / ≤ 4.0s / > 4.0s
CLS (Cumulative Layout Shift)ページ表示中のレイアウト崩れ (画像が後から入ってきて文字位置がずれる、等)≤ 0.1 / ≤ 0.25 / > 0.25
INP (Interaction to Next Paint)クリック・タップに対する反応速度 (代替指標 TBT もよく使われる)≤ 200ms / ≤ 500ms / > 500ms

スコアの色分けは Google 共通で、

  • 90〜100 → 緑 (Good)
  • 50〜89 → オレンジ (Needs Improvement)
  • 0〜49 → 赤 (Poor)

…と相場が決まっており、「90 以上を目指す」のが一般的なゴールラインです。

NOTE

PSI は Mobile / Desktop の 2 つの環境を別々に計測できます。Google がモバイル優先インデックスを採用しているのと、ふつうモバイルのほうが帯域・CPU の制約が厳しくスコアが落ちやすいため、Mobile スコアを基準に最適化するのが一般的です。

ここまでが前置き。

やったこと① 上位記事を一斉に計測

PV 上位の 8 記事 (learning-sql, 5-months-have-passed, join-layerx, ga4-setting, …) を Mobile / Desktop の両方 で計測しました。

PSI API は curl で叩けるので、シェルスクリプトをサクッと書いて 16 計測一気に流す方式に。

NOTE

PSI API、匿名コール枠が事実上廃止されている (429 Quota exceededquota_limit_value: "0") ので、API キー必須です。gcloud alpha services api-keys createpagespeedonline.googleapis.com に target 制限をかけたキーを作るのが、漏洩時のリスクを最小にできて安心です。

結果: 1 記事だけ外れ値で Perf 48

記事Mobile Perf
5-months-have-passed90
join-layerx97
good-thing-about-podcast92
learning-sql48 🔴

learning-sql だけ 48 で Poor 判定。他は割と良かったので、「learning-sql に何かあるな」と原因を絞り込めました。

詳細を見ると Total bytes が 5,024 KiB、画像が圧倒的に重い。

重いリソースサイズ
techblog.recruit.co.jp の OG 画像1.55 MiB
Google Cloud blog の OG 画像1.46 MiB
その他外部 OG 画像多数

learning-sql は記事内に 31 個のリンクプレビューカードを埋め込んでいて、そのすべてが eager load されていました。1 ページで 5 MiB 超の外部画像が初期ロードに乗るので、モバイル帯域が飽和して LCP 8.2s 😇

Phase 1: LinkPreview 画像を lazy 化

使っているのは @astro-community/astro-embed-link-preview という Astro Integration。これがリンクプレビューカードを <img> で吐き出すのですが、loading="lazy" を付けていない仕様でした。

GitHub Issue 立てて待つよりは、patch-package で 1 行 patch を当ててしまうのが手っ取り早い。

 <img
   src={meta.image}
+  loading="lazy"
+  decoding="async"
 />

postinstallpatch-package を仕込んでおけば、Cloudflare Pages のビルド時にも自動で patch が当たります。

結果:

  • Total bytes: 5,024 KiB → 367 KiB (-93%) 🚀
  • LCP: 8.2s → 3.9s
  • Mobile Perf: 48 → 73

ここまでで満足してもよかったのですが、せっかくなので 80+ までは押し上げたい欲が出てきました。

Phase 2: GA4 (gtag.js) を遅延ロード

次のボトルネックを探すと、全ページで gtag.js が 151 KiB 。これが全記事共通の最大リソースになっていました。軽い記事だとこれだけで Total の 70-80%。

requestIdleCallback (フォールバック setTimeout 1500ms) でブラウザがアイドルになるまで読み込みを遅延させました。

// Before: ページ表示直後にロード
<script async src="https://www.googletagmanager.com/gtag/js?id=..."></script>

// After: ブラウザがアイドルになるまで遅延
const initGA = () => {
  const s = document.createElement('script');
  s.async = true;
  s.src = `https://www.googletagmanager.com/gtag/js?id=${gaId}`;
  document.head.appendChild(s);
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());
  gtag('config', gaId);
};
if ('requestIdleCallback' in window) {
  requestIdleCallback(initGA, { timeout: 2000 });
} else {
  setTimeout(initGA, 1500);
}

WARNING

GA4 を遅延ロードすると「ページ表示〜遅延発火までの数百 ms 以内に離脱したユーザーの page_view を取りこぼす」リスクがあります。私の用途(個人ブログのアクセス傾向把握)では実質ゼロですが、シビアな計測が必要なメディアでは注意。

これで残りの記事もすべて、Mobile Perf が 94 以上 に到達。

記事Phase 0Phase 2 後
learning-sql48 🔴99
5-months-have-passed9097
anti-patterns-in-delegation8395
ga4-setting9096

learning-sql の LCP は 8.2s → 1.8s、TBT は 460ms → 20ms に。

やったこと② 自動化: PR で PSI を走らせる

ここまでは「直した」記事の話。問題は今後書く記事です。

毎回手動で PSI に貼って計測するのは絶対に続かないので、GitHub Actions で 記事 PR を出すたびに自動で PSI が走り、結果が PR コメントに投稿される仕組みを作りました。

PR 作成

Cloudflare Pages がプレビュー URL を発行(既存)

GitHub Actions が
  ├─ PR diff から変更 slug を抽出
  ├─ プレビュー URL 200 を待つ(最大 15 分)
  ├─ PSI で Mobile / Desktop を計測
  └─ PR コメントとしてスコア表を投稿

人間(私)が結果を見て、
  ├─ OK なら merge
  └─ NG なら直してまた push (同じコメントが上書き更新される)

判定基準は Mobile Perf ≥ 80 を推奨ライン、< 50 は致命警告。CI failure にはせず、人間判断で merge する設計にしました。

実はこの記事自体もその仕組みの dogfood で、PR を作った瞬間に PSI が自動で走って「✅ 全パターン推奨ラインクリア」のコメントが付くのを確認してから merge しています。

学び・所感

  • アクセス上位記事をベースにスコープを決めるのが正解だった: 「悪い記事を直す」より「アクセスがある記事に共通する課題を直す」ほうが影響範囲が大きい
  • Lighthouse Lab Data には ±5 程度のばらつきがある: 1 回の数値で意思決定せず、境界値なら 2-3 回計測して中央値を取るほうがいい
  • patch-package + Cloudflare Pages の組み合わせはハマる: postinstall で勝手に patch が当たるので、node_modules の挙動を 1 行だけ変えたいときに気持ちいい
  • 計測を自動化しておくと気が楽: 「気にしないと劣化する」じゃなく「気にしなくても警告が来る」状態にしておくと健全

おわりに

「Astro + Cloudflare で爆速やろ」と思っていたのに、計測してみたらガッツリ Poor が出ていて、「思い込みでパフォーマンス語っちゃダメだな〜」と反省しました。

数字が出ると改善の方向性が見えるし、改善後の数字が出るのは単純に嬉しいので、計測 → 直す → 計測のループはおすすめです 💪