Metronome.fm の LP(トップページ)を PageSpeed Insights で測ったところ、モバイルのパフォーマンスが 31 点でした。アプリやドキュメントのページは 70〜78 点で、LP だけが大きく下回っていました。
LP には GSAP で作ったスクロールの演出がたくさんあります。演出の見た目と動きは変えずに、どこまで点数を上げられるかを試しました。結果は、本番の PageSpeed Insights(モバイル)で、日本語の LP(/ja)が 31 点から 61〜69 点、英語の LP(/en)が 40 点から 61〜69 点です。
この記事では、原因の見つけ方と、直した 4 か所(演出を動かす前の準備の処理・ヘッダー・Clerk・日本語フォント)を順に書きます。
手元で測って、原因を関数まで絞る
PageSpeed Insights の結果では、TBT(メインスレッドが塞がっていた時間)が 2.6 秒ありました。アプリのページは 0.3 秒ほどなので、LP では読み込み直後に何かの処理が長く動いていることになります。
PageSpeed Insights が示すのは、圧縮されたチャンクの中の位置までです。どの部品の処理かまではわかりません。そこで手元で本番ビルドを作り、ソースマップを付けて、CPU のプロファイルを元のコードに対応づけて、どの関数にどれだけ時間を使ったかを数えることにしました。
next.config.tsにproductionBrowserSourceMaps: trueを一時的に足してビルドする- Playwright で LP を開き、Chrome DevTools Protocol で CPU を 4 倍遅くし、回線を下り 1.6Mbps に絞る(PageSpeed Insights のモバイルに近づける)
Profilerのサンプルと、トレースのLayoutのイベント(後で書く強制リフローで起きたレイアウトには、呼び出し元のスタックが付く)を、ソースマップで元のファイルと行に戻して集計する
await cdp.send("Emulation.setCPUThrottlingRate", { rate: 4 });
await cdp.send("Network.emulateNetworkConditions", {
offline: false,
latency: 150,
downloadThroughput: (1.6 * 1024 * 1024) / 8,
uploadThroughput: (750 * 1024) / 8,
});
await cdp.send("Profiler.start");手元でも、読み込み直後に 3.0 秒ほどの長いタスクが 1 本出ました。中身を元のコードに戻すと、半分以上が 1 つの部品の処理でした。
重かったのは、1 年分のマスに週ごとに付けた演出
時間を使っていたのは、LP の下の方にある練習の記録のセクションです。1 年分の練習カレンダーを、スクロールに合わせて左から塗っていく演出があります。

- マスは数百個あり、週ごとに塗りの演出を付けていた
演出は、週ごとに fromTo で「大きさ 0 から 1 へ」の動きを付けていました。
columns.forEach((column, index) => {
timeline.fromTo(
column.querySelectorAll("[data-fill]"),
{ scale: 0 },
{ scale: 1, duration: 0.6, stagger: 0.05, ease: "back.out(2)" },
index,
);
});プロファイルでは、このセクションだけで約 1.7 秒かかっていました。いちばん時間を使っていた関数は GSAP の CSSPlugin の中で、getComputedStyle を呼んでいるところです。
GSAP(3.15.0)は、要素の transform を初めて動かすときに、今の値を getComputedStyle で読みます。そのとき、個別に指定する CSS の変形プロパティ(translate・rotate・scale)を transform にまとめるため、要素の style.scale などに none を書き込みます。fromTo は開始の値をすぐ書き込むので、マスの数だけ「読む → 書く」が交互に起きます。書いたあとに読むと、ブラウザは読む前にスタイルとレイアウトを計算し直します(強制リフロー)。LP には要素が 2,500 ほどあり、この計算し直しが数百回くり返されていました。
見えない演出は、ブラウザの手が空いてから用意する
このセクションはページのずっと下にあり、読み込み直後には見えません。そこで、カレンダーの縦の線と、ピン留め(スクロールのあいだセクションを画面に留め、その分のスクロールの長さを取る設定)だけをすぐ作り、マスの塗りの動きは、ブラウザの手が空いたときに 4 週ずつ足すことにしました。
export const runInIdleBatches = <T>(
items: readonly T[],
batchSize: number,
run: (batch: readonly T[]) => void,
): (() => void) => {
const state = { next: 0, cancel: () => {}, cancelled: false };
const step = () => {
if (state.cancelled) return;
run(items.slice(state.next, state.next + batchSize));
state.next += batchSize;
if (state.next < items.length) state.cancel = scheduleIdle(step);
};
if (items.length > 0) state.cancel = scheduleIdle(step);
return () => {
state.cancelled = true;
state.cancel();
};
};scheduleIdle は requestIdleCallback を使い、ない環境では代わりに setTimeout を使います。塗りの動きをタイムラインのどこに置くか(週の番号)は前と同じです。タイムラインの長さも変わらないので、スクロールと演出の対応は前のままです。
同じ形の処理が、ほかにも 3 つありました。
- 機能を紹介する横長の帯: 帯に並ぶパネルごとに
offsetLeftを読んではgsap.fromで書いていたので、位置を先にまとめて測り、中身を出す演出は手が空いてから 1 枚ずつ作る - ヒーロー(ページの最初の大きな区画)の線の描画: 毎フレーム
getBoundingClientRectで画面の外かを調べていたので、画面の外かどうかをIntersectionObserverで受け取って覚えておく形にする - 操作ガイドの録画(約 1.5MB): マウントした直後にまるごと取りに行っていたので、ページの読み込みが終わって手が空いてから取る。それまではポスターの画像を出している
ここまでで、いちばん長いタスクは 3.0 秒から 1.3 秒ほどになりました。残りは、オープニング(読み込んだ直後に流れる最初の演出。次の節で書きます)や、ヒーローの文字の分割など、最初の画面に見える演出の準備です。これは後回しにすると見た目が変わるので残しました。
演出の見た目が変わっていないことは、直す前と後で LP をスクロールした 25 か所を PC とスマホの幅で撮り、画素で比べて確かめました。違いが出たのは、ヒーローのメトロノームが鳴っていて、撮った瞬間の拍がずれていたところだけです。
LCP は、隠していたヘッダーだった
TBT が減っても、LCP(いちばん大きい要素が描かれた時刻)はほとんど縮みませんでした。LCP として数えられていた要素を見ると、ヘッダーの右端の「アプリに移動」のボタンでした。
LP の最初の画面は、線が画面を横切ってロゴを組み上げるオープニングです。ヘッダーはオープニングのあいだ隠しておき、ロゴが組み上がったところで出していました。ロゴは線と四角で描いているので、LCP の候補になりません(LCP の候補になるのは、画像や文字を含む要素です)。最初の画面でいちばん大きな候補はヘッダーの「アプリに移動」で、その表示がオープニングの終わりまで遅れていたため、LCP も遅れていました。
「アプリに移動」を早く出せば、LCP も早まります。LCP は検索の評価にも使われる指標なので、「アプリに移動」は、オープニングのあいだも隠さずに最初から出すことにしました。その左のボタン群(アクセントの色・テーマ・言語の切り替え)は、スマホでオープニングの線と重なるので、これまでどおりオープニングが終わってから出しています。あわせてヘッダーの背景色を外し、オープニングの線がヘッダーのところで切れないようにしています。

- LCP として数えられていた「アプリに移動」。読み込んだ直後から見えるようにした
- ヘッダーの背景色を外し、線が画面の上の端まで伸びるようにした
手元の計測では、LCP が 12.7 秒から 4.3 秒になり、最初の描画(FCP)と同じ時刻になりました。
LP では Clerk を読み込まない
次に、LP が読み込む JS を見直しました。サインインに使っている Clerk のスクリプト(約 84KB)が、LP でも読み込まれていました。LP にサインインの機能はありません。ルートのレイアウトに ClerkProvider を置いていて、すべてのページで読み込んでいたためです。
ClerkProvider、サイト共通のヘッダー、サインインのモーダルを 1 つのコンポーネント(AppChrome)にまとめ、アプリ・ドキュメント・ブログのレイアウトにだけ置くことにしました。
export const AppChrome = ({ children }: AppChromeProps) => (
<AuthProvider>
<Header />
<div className="flex flex-1 flex-col">{children}</div>
<AuthDialogs />
</AuthProvider>
);ここで 1 つつまずきました。404 のページ(app/not-found.tsx)にもヘッダーを出そうとして、同じ AppChrome で包んだところ、LP に Clerk のライブラリのコードが再び入っていました。ルートの not-found.tsx は、ルートのレイアウトと一緒にどのページでも読み込まれます。そこから import した Clerk のコードも、LP のバンドルに入っていました。404 を AppChrome で包まない版と比べると、LP の JS が圧縮前で 2,023KB から 1,673KB になります。この差は大きいので、404 にはサイト共通のヘッダーを出さないことにしました。ホームとドキュメントへのリンクは、404 のページの中にあります。
サーバーの側でも、proxy.ts の clerkMiddleware が LP を通っていました。Clerk はセッションを確かめ直すときにページを Clerk へリダイレクトさせる(handshake)ので、LP を出す前に往復が 1 回増えることがあります。LP を開く GET だけ、Clerk を通さないようにしました。
export const proxy = (request: NextRequest, event: NextFetchEvent) =>
request.method === "GET" && isLpPath(request.nextUrl.pathname)
? withoutClerk(request)
: withClerk(request, event);POST まで外さなかったのは、Server Action が LP のパスに届くことがあるからです。アプリで鳴らしたままブラウザの「戻る」で LP に来ると、プランごとの再生時間の上限を確かめる Server Action が、そのとき開いている LP のパスへ送られます。その中で Clerk の auth() を呼んでいるので、ミドルウェアを通らないとエラーになります。
日本語フォントを、LP の文字だけにする
最後はフォントです。LP を最後まで見ると、フォントのファイルを 63 個(約 570KB)読み込んでいました。ほとんどが IBM Plex Sans JP です。
next/font/google で読み込む日本語のフォントは、文字の範囲(unicode-range)ごとに細かいファイルに分かれています。ページに出てくる文字が含まれるファイルだけを読み込む仕組みですが、LP の文は 3 つの太さ(300・400・500)で書いていて、太さごとにファイルが分かれるので、合わせて 61 ファイルになっていました。描画は止めない設定(font-display: swap)ですが、回線の細い環境では JS と回線を取り合います。
そこで、LP で使っている文字だけを含むフォントを、太さごとに 1 つずつ作りました。Google Fonts の API は、text= に文字を渡すと、その文字だけを含むフォントを返します。next/font が配っているのと同じ Google のフォントから作られるので、字形は変わりません。
const cssUrl = `https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+JP:wght@${weight}&text=${encodeURIComponent(chars)}`;
const css = await fetch(cssUrl, { headers: { "User-Agent": USER_AGENT } }).then((r) => r.text());
const fontUrl = css.match(/url\((https:\/\/fonts\.gstatic\.com\/[^)]+)\)\s*format\('woff2'\)/)?.[1];文字は、スクリプトで本番ビルドの LP を最後までスクロールし、IBM Plex Sans JP で描いている文字を太さごとに集めます(/ja と /en の両方)。太さごとに、作ったフォントが含む文字の範囲を unicode-range に書いた @font-face を書き出し、LP の書体の並びの先頭に置きます。
const LP_FONT_STACK: CssVariables = {
"--mfm-font-sans":
'"IBM Plex Sans JP LP", var(--font-ibm-plex-sans-jp, "IBM Plex Sans JP"), "Hiragino Sans", sans-serif',
};LP の文言を変えて、サブセットにない文字が出てきても、その文字は続くふだんの書体で描きます。unicode-range に入っていない文字には、ブラウザが次の書体を使うためです。作り直しを忘れても、崩れずに少し遅くなるだけで済みます。
できたフォントは 3 ファイルで約 110KB です。LP を最後まで見たときのフォントは、IBM Plex Mono を合わせて 5 ファイル、約 130KB になりました。
next/font/local を使わなかったのは、unicode-range などの設定がすべてのファイルに共通になり、太さごとに違う範囲を渡せないからです。そのスクリプトが書き出す CSS を、レイアウトで読み込む形にしました。太さを減らす案と、font-display: optional で遅いときにフォントを諦める案は、見た目が変わるので採りませんでした。
結果
本番の PageSpeed Insights(Lighthouse)の結果です。直す前は 1〜2 回、直した後は 2 回測りました。Lighthouse の値は 1 回ごとにぶれます。
ページ | 直す前 | 直した後 |
|---|---|---|
/ja(モバイル) | 31・36 | 61・69 |
/ja(PC) | 45 | 67・75 |
/en(モバイル) | 40 | 61・69 |
/en(PC) | 54 | 69・70 |
手元の本番ビルドを、CPU を 4 倍遅く、回線を 1.6Mbps に絞って測った値です(3 回の平均。TBT は Long Tasks API から出した概算)。
項目 | 直す前 | 直した後 |
|---|---|---|
FCP | 約 4.6 秒 | 約 2.9 秒 |
LCP | 約 13.4 秒 | 約 2.9 秒 |
TBT | 約 3.4 秒 | 約 1.4 秒 |
いちばん長いタスク | 約 3.0 秒 | 約 1.0 秒 |
JS(圧縮前) | 2,023KB | 1,673KB |
フォント | 63 ファイル・約 570KB | 5 ファイル・約 130KB |
残っているのは、最初の画面に見える演出の準備と、オープニングが終わって、止めていたスクロールを動かせるようにするときの、ScrollTrigger(GSAP のスクロールに合わせて動かす仕組み)の位置の測り直し(手元で 0.4 秒ほど)です。どちらも演出に手を入れることになるので、今回はここで止めました。
この続きで、サイト全体で読み込んでいたフォントの定義と zod を外し、ScrollTrigger の測り直しも減らしました。続きの記事に書いています。