Web Speed Hackathon 2025参加記
CyberAgentさんが開催するWeb Speed Hackathon 2025に参加しました。意図的に重くなるよう作られているアプリケーションを改善し、Lighthouseスコアの改善を競うイベントです。
スコア上の私の順位は15位でしたが、残念ながらデグレを発生させてしまい順位対象外となりました。同様にデグレで上位16人のうち15人が順位対象外となったようで、波乱の展開でした。
タイムライン
まずは、私が行った改善を競技開始から時系列で振り返ってみます。
スコア計測
初期実装を運営のHerokuにデプロイしたところ、スコアは67.00で暫定3位でした。
開発環境構築
とりあえず以下の二項目を行いました。
- Dev Container導入
- GitHub ActionsでLint/Type check/Testを実行
Dev Containerは隔離された環境でClineを使うために入れています。誤って重要なファイルを削除してしまうリスクを減らせて安心できます。
デプロイ
例年Web Speed Hackathonでは無料で利用可能なPaaS等にデプロイすることが求められます。有料のサービスを使う場合の費用は自己負担というレギュレーションです。昨年はKoyebが推奨されていたのですが、今年は運営が用意したHerokuのアカウントに無料でデプロイできる仕組みが用意されていました。一方で、デプロイ方法のドキュメントには自身で用意したHerokuのアカウントに有料でデプロイする選択肢も記載されていました。
どの選択肢を選ぶか迷いましたが、自分でHerokuのアカウントを作成し運営と同じサイズのインスタンスを用意することにしました。運営のHerokuはログを確認しづらいことや、使うにはリポジトリを公開する必要がありシークレットの扱いに神経を使うなどの懸念があったためです。大会期間中インスタンスを動かし続けて50円程度のコストで済みました。
最近は無料で使えるサービスも減っていますし、こういったイベントを開催するのは大変そうです。VercelやCloudflare Workersは無料で使えますが、Herokuと比べると柔軟性が低いですね。
Clineでアプリケーションの概要把握
デプロイ作業をやっている間に、Clineに既存ソースコードの調査を行ってもらいました。結果を少し修正して.clinerules
に残しています。今大会の概要を知らない方は一読をおすすめします。
デプロイに時間を要してしまい、この時点で2時間が経過していました。
無駄なPrefetchの削除
「推測するな計測せよ」はパフォーマンス改善における格言ですが、Web Speed Hackathonの初期実装はまともに計測できないほど遅いです。最初はあたりをつけて改善していく必要があります。
まずは、サーバーに存在する画像をすべてPrefetchする処理が入っていたので消しました。
SSR修正
サーバー上でSSRの結果を捨てているコードがあったので、きちんとクライアントに送るよう修正しました。うまくいけばLCPを大きく改善できるはずです。しかし実際にはこのコミットでは正しく修正できておらず、このあと何度もHydration Errorを含むSSR関連の問題に対処することになってしまいます。
Webpack Bundle Analyzer導入
今回はWebpackが使われていたのでBundle Analyzerを導入しました。フロントエンドのパフォーマンス改善には必須のツールです。
インラインソースマップの削除、Minify実施、チャンク分割
インラインソースマップが有効になっていたので削除しました。Minifyは設定されていなかったので有効化します。また、すべてのコードが単一のmain.js
に入っていたので、チャンク分割がされるよう設定を修正しました。
WASM版ffmpeg削除
シークバーに表示するサムネイルを生成する処理にWASM版ffmpegを使っていたので削除しました。バンドルの中でかなりの割合を占めていました。サムネイルは事前にffmpegで生成するようにしました。
スコア計測 (2)
この時点でデプロイしたところ、スコアは僅かに悪化し65.75で暫定48位でした。
テストのシャーディング
CI上で実行しているVRTがかなり遅かったので、シャーディングを導入しました。Playwrightでのシャーディングの方法は事前に調べておいたためスムーズに導入できました。時系列でテスト結果を確認するためのダッシュボードも事前に作っておいたので導入しました。
ただ、アプリケーション自体が重いため自動テストはまともに動かず、結局競技終了までテスト結果を確認することはほとんどありませんでした。
Webpackのキャッシュ有効化
ビルドを高速化するためにWebpackのキャッシュを有効化しました。実際に早くなったのかはよくわかりませんでした。
リコメンドAPIから不要なデータを削除
Chrome DevToolsのNetworkタブを見ると、リコメンドAPIから取得しているデータが非常に大きいことがわかりました。実際には使われていないデータがかなり多かったため削除しました。
画像のLazy Load
すべての<img>
タグにloading="lazy"
をつけて周りました。本来はabove the foldの画像はlazy loadしないべきですが、とりあえず深く考えずにすべて遅延読み込みさせています。
画像のAspect Ratioを指定
CLSを改善するために画像のAspect Ratioを指定しました。この改善は目に見えてレイアウトシフトが減るので楽しくなります。
Cache-Control: no-store
の削除
バックエンドのミドルウェアでCache-Control: no-store
を設定している箇所がありました。各エンドポイントを見て回ったところ、no-store
が必要そうなものはなかったので削除しました。
この設定により、2回目以降の静的リソースへのリクエストには304 Not Modifiedが返ってくるようになります。
asset/inline
をasset/resource
に変更
PNG画像をasset/inline
でData URLとして埋め込んでいる箇所があったため、別のURLから読み込むasset/resource
に変更しました。ただ、Bundle Analyzerで確認したところPNGの割合は多く見えなかったので効果は少ないかもしれません。
スコア計測 (3)
この時点でデプロイしたところ、スコアは29.50で暫定82位でした。何故か前回から下がっていますが、Lighthouseのスコアが不安定なのはある程度仕方ないことです。
UnoCSSのランタイム削除
初期実装ではUnoCSSというCSSエンジンが使われていました。UnoCSSはTailwindのようなUtility CSSフレームワークに近く、柔軟にカスタマイズ可能できることが特徴のようです。
これがCSSファイルをビルド時ではなくランタイムで生成する方式になっており、更に巨大なアイコンデータをすべて読みこんでいたため、バンドルサイズのかなりの割合を占めていました。ネットワーク負荷の面でもCPU負荷の点でも問題があるため、ビルド時に生成するよう修正します。
ビルド時にCSSファイルを生成する際はソースコードを静的解析する必要があります。その際、Tailwindと同様にクラス名を動的に生成している箇所は検出できないため生成されるCSSファイルに含めることができません。以下のように修正する必要があります。
// NG
className={`i-fa-solid:${isSignedIn ? 'sign-out-alt' : 'user'} m-[4px] size-[20px] shrink-0 grow-0`}
// OK
className={classNames(isSignedIn ? 'i-fa-solid:sign-out-alt' : 'i-fa-solid:user', 'm-[4px] size-[20px] shrink-0 grow-0')}
正規表現で修正が必要な箇所を検索したところそれほど多くなかったため、すべて手動で修正しました。
BabelからSWCに移行
ビルド時間短縮のためにSWCに移行しました。Babelの設定に無駄なPolyfillを追加するものがありましたが、一旦そのまま移行しています。
Polyfill削除
今回のテスト環境はChrome 133だったので、不要なPolyfillを削除しました。
CSSのMinify
css-minimizer-webpack-plugin
を入れました。
JavaScript/CSSのファイルをimmutableにする
WebpackでJS/CSSのチャンクファイル名にハッシュを付与するよう設定しました。これにより同じURLなら必ず同じコンテンツが返されるようになるため、ブラウザからサーバーにファイルが変更されていないか確認する必要がなくなります。Cache-Control: public, max-age=2592000, immutable
を付与することで、ブラウザからサーバーにリクエストを送り、サーバーが304 Not Modified
を返す動作が不要になります。
ただ、実際にはJavaScriptのチャンクファイル名にハッシュを付与すると何故かCSSが壊れたため、一旦revertしています。後述するRspack導入時にこの問題は解決したため、ツールチェインのどこかにバグがあったのだと思います。
動画ファイルをimmutableにする
動画ファイルは競技中全く変更されないためimmutableとしました。
スコア計測 (4)
この時点でデプロイしたところ、スコアは280.30で暫定4位でした。突然スコアが上がっており、バンドルサイズを減らしたのが効いているようです。
念のため同じ環境に対して再度スコア計測を行ったところ、278.25とほとんど変わらない値でした。
ESLintの設定修正
ESLintがreact/jsx-sort-props
の警告を大量に発しており、エラーが鬱陶しい状況でした。しかしreact/jsx-sort-props
はautofixに対応していないため、代わりに同等の機能があるperfectionist/sort-jsx-props
を導入しました。
Rspack導入
ビルド高速化のためRspackを導入しました。Watch modeでdevelopment buildが300msで終わるようになり爆速です。
なぜかbuiltin:swc-loader
が動かなかったので素のswc-loader
をつかっていますが、それでも十分速いです。
無駄なSleep削除
DevToolsのPerformanceタブを見ると、ネットワークリクエストをしているわけでもCPU負荷がかかっているわけでもないのにレンダリングが遅れている謎の時間がありました。どこかでSleepしているのではないかと考え、勘で1000
でソースコードを検索したところ、Dynamic importを必ず1000msかかるよう遅延させているコードがあったので削除しました。
スコア計測 (5)
この時点でデプロイしたところ、スコアは238.45で暫定4位でした。
画像をWebPに変換
大量の画像がJPEGで配信されていたので、WebPに変換しました。
当時は見逃していましたが、404ページにあるGIF画像がかなり重かったようです。WebPはアニメーションにも対応しているので、同様に変換すべきでした。また、後から考えるとデグレと判断されない範囲で解像度を落としておくべきだったと思います。そもそもWebpではなくAVIFにすべきだったかもしれません。
無駄なSleep削除 (2)
Performanceタブを再度見たところまだ謎の遅延があったので再度調べたところ、Schedule Pluginと称してリクエストを1000ms遅延させるコードがありました。こちらも削除しました。
無駄なSleep削除 (3)
まだ謎の遅延があったので調べたところ、リクエストをバッチで送信する処理が入っていることがわかりました。GraphQLのDataLoaderと同様の仕組みのようです。完全に削除すると大量のリクエストが送られそうだったので、窓長を1000msから16msに変更しました。
この処理にはBatshitというライブラリが使われていました。名前が酷い。
SSR修正 (2)
今年は状態管理ライブラリとしてZustandが使われていました。このストアの中身がSSR時にサーバーからクライアントに送られていなかったので、初期HTMLに含めて送るよう修正しました。データを受け取るコードの残骸が最初から残っていたので、この修正は作問者が意図したものだったのだと思います。
しかし実際には、React Routerのloaderでfetchしてストアに入れたはずのデータが、SSRやHydrationの最初のレンダリングでは取得できず、ストアが空に見えるという問題が生じていました。そのためこの変更はこの時点では無意味でした。
暫定的に、レンダリング時にはZustandのストアの中身をuseStore
で取り出すのではなく、loaderで取得したデータをuseLoader
から取り出すようにしています。ただ、この場合は深いコンポーネントまでデータをバケツリレーする必要があり面倒です。実際、この時点では修正漏れが多くあり、サーバー側でエラーが発生してClient Renderingに切り替わってしまうページもありました。また、ZustandとReact Routerで同じデータを二重にクライアントに送信する無駄もあります。
スコア計測 (6)
この時点でデプロイしたところ、スコアは162.60で暫定12位でした。
Cloudflare導入
Cloudflareを導入しました。ImmutableなデータをCDN上でキャッシュしてくれる利点と、zstdによる圧縮やHTTP/3による多重化を自動で行ってくれる利点があります。
また、Cloudflareとオリジン間の帯域を節約するため、Herokuにnginxを導入しgzipで圧縮しました。@fastify/compress
で圧縮するのが作問者の想定だったようですが、エラーが発生したため諦めてnginxを入れました。GitHubのIssueで2週間前に同じ症状の報告があったのですが、原因は現時点で不明なようです。
スコア計測 (7)
この時点でデプロイしたところ、スコアは176.95で暫定12位でした。
ReDoS修正
ReDoSを修正しました。Web Speed Hackathonでは毎年恒例で発生しているようなので、eslint-plugin-regexp
を入れてみたら検出してくれました。
問題があったのはメールアドレスとパスワードのバリデーションでした。メールアドレスのほうは正規表現だけで対処するのが難しそうだったので、他の方法と組み合わせて初期実装と同様のバリデーションを実装しています。
スコア計測 (8)
この時点でデプロイしたところ、スコアは179.85で暫定11位でした。
データベースにインデックス追加
APIレスポンスが遅かったため、データベースにインデックスを追加しました。今回はSQLite (LibSQL)が使われていました。本来はNode.jsのプロファイリングを使って遅い原因を調べるべきですが、時間が限られた中でコスパが悪そうだったので今回は見送っています。
スコア計測 (9)
この時点でデプロイしたところ、スコアは177.90で暫定12位でした。
JavaScriptで行っている処理をCSSで再実装
いくつかCSSで実装できる機能がJavaScriptで実装されていたため、CSSで再実装しました。マウスホバー時にスタイルを変更する処理と、ウィンドウサイズが変わった際に要素のアスペクト比を保つようリサイズする処理です。JavaScriptでは250msごとにポインタの位置を取得して処理を行っており、非常に筋が悪いです。
再レンダリング削減
scroll
イベントが発生するたびにsetState
でスクロール位置を保存している処理がありました。一定以上スクロールした際にヘッダーを透過させるために使われています。頻繁に再レンダリングが行われるため重そうです。実際にはスクロール位置が一定の閾値を前後した際にのみ再レンダリングすればいいので、そのように修正しました。最近CSSでスクロールに応じてアニメーションできるAPIが追加されていたようですが、もしかしてこれもCSSだけで実装できたのでしょうか?
APIレスポンスをImmutableにする
今回はユーザ登録以外でデータベースを変更することがなく、ユーザごとに表示を出し分けることもなかったため、ほとんどのAPIエンドポイントにCache-Control: public, max-age=2592000, immutable
を設定することができました。実際のプロダクトではこんなことは難しいでしょうね……
ReactDOMServer.renderToPipeableStream()
を使う
SSRに
初期実装ではSSRにReactDOMServer.renderToString()
を使っていたのですが、エラーが出ていることに気づいてReactDOMServer.renderToPipeableStream()
を使うようにしました。<Suspense>
を使っていたことが原因だったようです。後からわかったのですが、実際にはsuspendしている箇所はないので<Suspense>
を消してしまってもよかったようです。
動画プレイヤーライブラリのチャンク分割
このアプリケーションは動画再生にHLSを使っています。Safari以外のブラウザはHLSに対応していないため、再生のために何らかのライブラリを入れる必要があります。初期実装ではページによってVideo.js, HLS.js, Shaka Playerの3つのライブラリを使い分けているカオスな状況でした。これらはいずれもサイズがかなり大きく、バンドルサイズのかなりの割合を占めていました。
まずはじめの一歩として、それぞれをDynamic Importすることでチャンク分割を行いました。
JavaScriptで行っている処理をCSSで再実装 (2)
JavaScriptで行っているがCSSで実装できる処理がまだまだ残っています。テキストが一定の行数を超える際に省略記号を表示する処理と、カルーセルのスクロールスナップがJavaScriptで実装されていたのでCSSで実装しました。
SSR修正 (3)
useSyncExternalStore
の第3引数が省略されておりSSRが失敗している箇所があったので修正しました。
スコア計測 (10)
この時点でデプロイしたところ、スコアは304.18で暫定8位でした。
Luxonをdate-fnsに移行(失敗)
Luxonはバンドルサイズを増加させてしまうライブラリとして有名です。今回もバンドルの中である程度の割合を占めていました。
そこでtree shakingが可能なdate-fnsに移行しようとしたのですが、結局変更点が多くデグレが怖かったので途中で諦めました。特にタイムゾーン周りの処理が怖いです。実際、上位陣の中でも番組表画面が9時間ずれてしまった方が数名いました。
TypeScriptのProject Reference導入(失敗)
型チェックに時間がかかっているのが気になったためTypeScriptのProject Reference導入を試みましたが、解決できないコンパイルエラーが発生してしまったため諦めました。後から考えると、ここに1.5時間近くの時間を割いてしまったのが悔やまれます。
動画をMP4に変換
初期実装では動画をHLSで配信していましたが、先述したようにHLSの再生には外部ライブラリを使う必要があります。これはバンドルサイズを増加させてしまうため、ライブラリなしで再生できるMP4に変換して配信するようにしました。これで各ページのリコメンドセクションで使っていたShaka Playerと、見逃し視聴ページなどで使っていたHLS.jsを消すことができます。ライブ配信では引き続きHLS/Video.jsを使います。
この変更のお陰でバンドルサイズは大幅に減ったのですが、残念ながらHLS.jsを消す際にバグを埋め込んでしまい、デグレの原因となってしまいました。
なお、MP4にするとブラウザはファイルを先頭から読み込む必要があり、動画をシークした際にその部分だけをダウンロードすることができなくなるのでは?と考える方がいるかもしれません。しかし、MP4をエンコードする際にメタデータをファイルの先頭に移動するよう設定したうえで、HTTPのrange requestに対応しているサーバーで配信すれば、ブラウザは必要な部分だけをダウンロードすることができます。
トップページのBelow the foldの動画を遅延再生
トップページにはリコメンドで動画を自動再生している箇所が3箇所あります。このうち2箇所はスクロールしないと表示されないため、IntersectionObserver
をつかって遅延再生するようにしました。
スコア計測 (11)
この時点でデプロイしたところ、スコアは306.36で暫定11位でした。
Zodを削除
ログイン画面のバリデーションにZodを使っていましたが、バリデーションの内容が簡単だったため、Zodを削除して手動でバリデーションするようにしました。Zodもバンドルサイズを増加させてしまうライブラリとして有名です。
APIスキーマをクライアントJSから削除
バンドルの中でAPIスキーマの割合が大きかったため、削除しました。クライアントサイドでAPIリクエストやレスポンスを検証する必要性は低いです。
スキーマによりリクエストやレスポンスの型が付与されるメリットもありますが、これはファイルをうまく分割することで最終的なバンドルサイズを増やすことなく実現できます。
lodashからlodash-esに移行
lodashもバンドルサイズを増加させてしまうライブラリとして有名です。lodash-esはtree shakingが可能なためそちらに移行しました。
スコア計測 (12)
この時点でデプロイしたところ、スコアは325.54で暫定9位でした。
クライアントサイドでAPIリクエストを発行しない
初期実装では、サーバーサイドでのSSR時とクライアントサイドでのHydration時であわせて2回同じAPIリクエストを発行していました。しかし、サーバーサイドで実行したAPIリクエストの結果はZustandのストアに格納しており、その中身はクライアントサイドに送っているため、クライアントサイドでAPIリクエストを発行する必要はないはずです。そこで、ストアに既にデータが入っている場合はそれを利用するよう修正しました。
ただ、この時点の実装ではストアに入れたはずのデータがSSR時や初回のHydration時に取得できない問題があったため、実際には意味がありませんでした。
APIリクエストの並列化
初期実装ではAPIリクエストを順番に発行していました。これを並列化することで、リクエストの待ち時間を削減しました。
スコア計測 (13)
この時点でデプロイしたところ、スコアは336.78で暫定13位でした。
SSR修正 (4)
初期実装では、React RouterのloaderでAPIからデータを取得し、その結果をZustandのストアに入れていました。各コンポーネント内でストアからデータを取り出し、画面に表示するという流れになっていました。
しかし、実際にはSSR時や初回のHydration時にはにストアの中身は空になってしまう問題が起きていました。これまで暫定的にloaderの返り値を使うという対処を行っていましたが、対処漏れでエラーが発生しているページもありました。この問題の原因は結局判然としなかったのですが、おそらくReactのライフサイクルの問題で、React Routerのloaderでストアに設定したデータがコンポーネントで確認できるようになるまでタイムラグがあるのだと思います。
解決策として、SSR時と初回Hydration時にはstore.getState()
で取得したデータを使うよう修正しました。ZustandのSSRの情報はインターネット上にほとんどなく、この問題の対処には苦労しました。作問者はここまで想定していたのでしょうか?それとも私が変な遠回りをしてしまっているのでしょうか……?
また、これでReact Routerのloaderで取得したデータをクライアントに送る必要がなくなったため、送らないよう修正しました。これで初期HTMLのサイズが大幅に小さくなります。
スコア計測 (14)
この時点でデプロイしたところ、スコアは274.98で暫定15位でした。
Zustandのセレクタ最適化
ZustandのuseStore
を使ってストアのデータを取得する際、セレクタで取得範囲を絞らずストア全体を取得している箇所がほとんどだったため、セレクタを使って必要なデータだけを取得するよう修正しました。これにより、ストアのデータが変更された際に再レンダリングされるコンポーネントの数を減らすことができます。
こういった単純作業はClineにやらせたかったのですが、指示が悪いのかうまく動かず、結局手動で修正することになって骨が折れました。
スコア計測 (15)
この時点でデプロイしたところ、スコアは318.15で暫定15位でした。
やり残したこと
ここまでが競技時間中に行った変更です。時間が足りずやり残した改善として以下の点が挙げられます。
- 自動テストがHydaration前にログインボタンを押してしまい、テストが先に進まなくなる問題の修正
- 2日目の途中で気づいて直したつもりが、なぜか修正コードがコミットされずどこかに消えていました。
- 高頻度で再レンダリングを引き起こしている箇所の修正
- CSSで実装できる処理をJavaScriptで実装しているなどの原因で、250msごとに再レンダリングを引き起こしている箇所が数か所ありました。具体的には以下の3点です。
- 動画シークバーのマウスホバー時のサムネイル表示(mousemoveイベント+CSSで実装可能)
- 番組表ページの画像表示/非表示(コンテナクエリで実装可能)
- 番組ページの番組開始/終了判定(useTimeoutで開始/終了時刻まで待機することで代替可能)
- 移植に時間がかかりそうで後回しにしていましたが、CPUが制限されているLighthouseではこれが不利に働いたのかもしれません。
- CSSで実装できる処理をJavaScriptで実装しているなどの原因で、250msごとに再レンダリングを引き起こしている箇所が数か所ありました。具体的には以下の3点です。
- ライブ配信ページの最適化
- HLS.jsを使っているライブ配信ページはほとんど触れませんでした。m3u8ファイルの中に
X-AREMA-INTERNAL
という謎の項目があるのが気になっていたのですが、後から聞いたところによるとここに巨大な不要データが入っていたようです。
- HLS.jsを使っているライブ配信ページはほとんど触れませんでした。m3u8ファイルの中に
- APIレスポンスの不要データ削除
- APIレスポンスの中に不要なデータが含まれているようでしたが、時間をとって調査できませんでした。
- スクロール位置復元の最適化
- カルーセルのスクロール位置復元に外部ライブラリを使っているのですが、どうも
<script>
タグを高頻度で生成したり消したりしているようで様子が変でした。調査すれば改善できたかもしれません。
- カルーセルのスクロール位置復元に外部ライブラリを使っているのですが、どうも
まとめ
振り返ってみるとかなり多くのことをやったのですが、その割にスコアの変動が思い通りにはならなかった印象です。結果的に何が効いていたのかよくわかっていません。私のスコアが300点だったところ、上位陣には500点~700点を獲得している方がいました。何をしていたのか気になります。
おまけ:Clineとの会話
最近Clineを使い始めて、ハッカソン中もかなり活用できたので履歴の一部を載せておきます。コードを書かせることはうまくできなかったのですが、既存の実装を調査するのに役立ちました。簡単な指示を出すだけで自律的に多くのファイルを読んでまとめてくれるのが便利です。モデルは3.7 Sonnetを使っており、ハッカソン期間中にかかったAPI料金は400円ほどでした。ちょっと高いですね。
使ってみた感想として、Clineは既存コードの意図を尊重して問題点の指摘はしてくれない印象です。調査して報告しろとだけ伝えると、既存実装のメリットは何点か挙げてくれるものの、問題点には全く触れません。初めから「問題点を見つけたら報告に含めてください」とプロンプトに書くべきだったかもしれません。
Discussion