Gadeninアプリを入れる

プロダクト読了 6 分

PageSpeed Insights の LCP を7.5秒から1.5秒に。遅いのは D1 ではなく AdSense と CSS だった

Cloudflare Workers と D1 で動く gadenin.com の LCP 改善の記録。サーバーの応答は2ms で、画像を WebP にすると PageSpeed Insights の点数は79から48に下がった。効いたのは AdSense のスクリプトを後から読むことと、Vite の CSS を公開ページ用と管理画面用に分けることだった。

Gadenin for WEB、つまりこのWebアプリだが、Cloudflare Workers にホスティングしているわけだが、モバイルアプリのバックエンドシステムと同居している。システムとしては別物だが、参照している DB は同じもので、API を WEB と分けているくらいでほぼ同一システムと言ってしまっても過言ではない。

BE 側のデータ量はかれこれ数万件規模までに膨れ上がってきており、WEB の表示スピードが若干気になるようになってきた。

そこで、まず一般的に取り掛かるべく Core Web Vital のスコアを上げるところに取り掛かろうと、 PageSpeed Insights を使って計測を行い、いろんな施策を打っていった際のビフォーアフターを見れるようにした。

当然、策を色々打っていくにあたり、一番の原因は Cloudflare D1 だと思っていた。作物は400種類以上、品種は9,000件以上あり、それが多言語対応してるわけだから、個人で運用しているプロダクトにしてはそこそこ多いデータ量になってきている。データ量が増えてくるとデータベースのチューニングを怠るとひどい目にあう。

ということで実際に計測をし始めるとサーバーの応答は2ms。

D1 はいきなり容疑者から解放されてしまった。一番手をつけたかったところではなかった。それにしても Cloudflare の D1 は、中身が sqlight のクセしてめちゃくちゃハイパフォーマンスだな。よくこのデータ量をめちゃくちゃ複雑なRDBの構成を取っているにも関わらず問題なくさばけてるなと感心してしまう。

最初の計測で、D1 は容疑から外れた

10月5日に、スマホウェブを対象に PageSpeed Insights の API キーを作って4ページを測った。

ページ 点数 LCP
トップ / 98 1.5秒
ブロッコリーの徒長のガイド 89 3.1秒
大根の種まきの解説 79 4.9秒
スナップエンドウの系統ページ 61 7.5秒

LCP は、画面で一番大きい要素が出るまでの時間だ。2.5秒を超えると遅い扱いになる。

キャッシュを外して HTML だけを取り直しても、0.1〜0.5秒で返ってきた。 D1 から作物を引いて HTML を組み立てる部分は、全体の1割もない。残りの数秒は、HTML が届いたあとのブラウザの中で起きていた。

大根の種まきの解説で、見出しの写真が出るまでの内訳の帯グラフ。左端のサーバーの応答 (Worker と D1) は2ms で細い線1本。写真の読み込みが始まるまで待たされる時間が1,204ms、写真の読み込みが551ms、描画が13ms。待たされた理由は、広告と計測のスクリプト (adsbygoogle.js 58KB、show_ads_impl 168KB、gtag/js 180KB) と、展開すると185KB ある CSS

画像を軽くしたら、点数が下がった

ということで次に疑ったのは画像。大根の種まきの解説の図は、PNG で3.6MB あった。

ここまでくればやることは明確で、以下3つのことを実施。王道のWebP。

  1. 解説の図と LP の見出しの画像を WebP にした。3.6MB の図は262KB になった
  2. 見出しの画像に fetchpriority="high" を付け、遅延読み込みを外した
  3. 作物の写真445枚を、全部 WebP に変換した

3つ目は少し迷った。作物の写真は、モバイルアプリの Plant のサムネイルにも使っている。というのも、そもそも WebP を WEB アプリケーションではないモバイルアプリからでも問題なく表示できるのかどうかすら知らなかったし、何ならできないんだと思ってた。だけど、Opusさんが問題ないと自信満々に言うもんだから、多分大丈夫なんだろうとこの方針でいった。 ファイルの名前ごと変えたので、アプリの写真のキャッシュも新しくなる。アプリで見え方が変わらないことを確かめてから、本番の写真を差し替えた。

445枚で355MB あったものが、68MB になった。大根の見出しの写真は290KB から154KB。 これで速くなるはず!!

ページ 点数 LCP
大根の種まきの解説 79 → 48 4.9秒 → 6.7秒
スナップエンドウの系統ページ 61 → 45 7.5秒 → 6.4秒

よくわからなくて混乱してくる。 画像の容量を半分にしたら、点数も半分近くになった(悪化した)。 WebP にしたら、逆に悪化してるじゃないか。まあ、もちろん PageSpeed Insights は推定値でしかないことと、WebP 自体が原因で遅くなったと短絡的に決めつけてはいけない。実際にLCPは改善されているわけなので。

6.7秒は、本当の時間ではなかった

結果の中身を開いて、ひとつ分かったことがある。 PageSpeed Insights の LCP は、遅い回線をまねて計算した推定値だ。同じ回にツールが実際に測った値は、別の欄に出ている。

ちゃんと調べてみると、PageSpeed Insights は、まず Google のサーバーから速い回線でページを開く。その記録をもとに、「遅い回線のスマホで開いたら何秒かかるか」を計算する。点数は、この計算した値で決まる。

実際に開いたときの時間も、結果の別の欄に出ていた。

推定 LCP 6.7秒 / 実測 LCP 1.8秒
推定 FCP 3.4秒 / 実測 FCP 1.5秒

速い回線なら、写真は1.8秒で出ていた。 ただ、遅い回線のスマホで開く人には、6.7秒に近い待ち時間になる。縮めるべきはこっちだ。

計算した値を押し上げていたのは、写真より先に読み込まれる2つのファイルだった。

  • 広告のスクリプト。AdSense の adsbygoogle.js が58KB、そこから読まれる show_ads_impl が168KB。さらに GA4 の gtag/js が180KB
  • CSS。転送は36KB だが、展開すると185KB ある。読み終わるまで、ページは何も描かない

広告と計測のスクリプトは、見出しの写真と同じ時間帯に回線を取り合っていた。写真の読み込みが始まるまでに、1.2秒待たされていた。

79から48に落ちた理由は、ここまで調べても説明しきれていない。この推定は、測るたびにかなり揺れる。 少なくとも、写真を軽くしたせいで遅くなったわけではなかった。

広告は、読み終わってから読む

AdSense は、9月20日に審査に落ちている。10月中旬にもう一度申請する予定だ。 まだ1円も出ていない広告のスクリプトが、ページで一番先に回線を取っていた。

広告の枠は、本文より下にある。写真と本文が出てから読めば足りる。 ページの <head> からは AdSense のスクリプトを外し、サイトの持ち主を示す <meta name="google-adsense-account"> だけを残した。スクリプトは、ページの load が終わってから読む。

ページ 点数 LCP FCP
大根の種まきの解説 48 → 77 6.7秒 → 5.3秒 3.4秒 → 1.5秒
ブルーベリー 70 → 97 6.5秒 → 2.3秒 1.7秒
スナップエンドウの系統ページ 45 → 65 6.4秒 → 7.1秒 4.2秒

ブルーベリーは一気に改善した。スナップエンドウの系統ページだけ、ほとんど変わらなかった。

系統ページの犯人は、管理画面の CSS だった

系統ページで一番大きい要素は、写真ではなかった。冒頭の説明の段落だ。 遅れの1.46秒が、まるごと「描画の遅れ」だった。文字はとっくに届いているのに、CSS を読み終わるまで描けない。

その CSS の中身を数えた。

185KB のうち、公開ページで使っていない規則が500件以上あった。管理画面の表、品種の登録フォーム、記事の執筆画面、アプリの設定画面。gadenin.com の読者は一生開かない画面の見た目を、全員に毎回配っていた。

CSS をページに直接埋め込む手は使えない。サイトの CSP はリクエストごとの nonce で動いていて、キャッシュされる HTML には埋め込めないからだ。 で、ファイルを2つに分けた。

  • 公開ページの CSS (styles.css)。公開ページはこれだけを読む
  • 管理画面とアプリの画面だけの CSS (styles-app.css)。管理画面は両方を読む

CSS を分ける前と後の図。前は185KB の CSS 1本に公開ページの規則と管理画面などの規則500件以上が入っていて、公開ページも読み終わるまで描けない。後は公開ページが styles.css から作る141KB だけを読み、描画が止まる時間は約1秒から0.3秒になった。管理画面は styles.css と styles-app.css をこの順に1本にまとめて読む。落とし穴として、別々に読み込むと規則の順番が逆になること、87か所に絡む規則は移さず480件を移したこと、本番の公開ページ270枚で確かめたことを書いている

移す規則は、公開ページを組み立てるコードを全部集めて、そこに出てこないクラスだけにした。Worker の HTML、プリレンダリング、public/ の JavaScript、BE が作るブログ本文の HTML まで含めた。

1か所、分けただけで管理画面の見た目が変わるところがあった。 CSS を2つの import に分けると、Vite が公開ページ側を後ろに置く。規則の順番が元と逆になる。同じ強さの規則がぶつかると、後ろが勝つ。負けるおそれのある組み合わせを洗い出すと、印刷用の表や写真の枠など87か所あった。

そこで、管理画面は2つを決まった順番で1本にまとめて読むようにした。その87か所に絡む規則は移さずに残した。最終的に移したのは480件だ。

最後に、本番の公開ページ270枚の HTML を取ってきて、移したクラスが1つも使われていないことを確かめた。0件だった。

前 後
公開ページの CSS 185KB (gzip 36KB) 141KB (gzip 26KB)
CSS で描画が止まる時間 (推定) 約1秒 0.3秒

PageSpeed Insights の点数の推移の折れ線グラフ。スナップエンドウの系統ページは61、画像を WebP にして45、広告を後回しにして65、CSS を分割して97。大根の種まきの解説は79、48、77、80。画像を軽くした段階で両方とも下がっている

系統ページの LCP は、7.5秒から1.5秒になった。

デプロイ直後の1回目は、CSS がまだ Cloudflare の各拠点に配られていないので遅く出る。ブロッコリーは1回目が65、2回目が95だった。直後の数字を見て落ち込まないほうがいい。

残っているのは写真の大きさ

大根の解説は、まだ80点だ。LCP は4.5秒。 犯人は今度こそ見出しの写真だ。

ページは幅1200px の写真を頼んでいる。ところが、配信の途中で縮める Cloudflare の機能 (Image Resizing) を有効にしていない。だから、保存してある1600px の写真がそのまま届いている。

次は、写真を1200px で保存し直すか、Image Resizing を有効にするかを決める。 D1 には、まだ一度も触っていない。

関連記事

Gadenin 栽培記録アプリ・無料アプリを入れる