--- title: "Web Speed Hackathon 2026参加記" published: 2026-03-30 revised: 2026-03-30 tags: [ハッカソン, パフォーマンス] author: kubosho --- # Web Speed Hackathon 2026参加記 3月20日から21日にかけて開催された[Web Speed Hackathon 2026](https://cyberagent.connpass.com/event/371488/)に参加しました。 結果は **764.80 / 1150点** で、[issueに記録された時点](https://github.com/CyberAgentHack/web-speed-hackathon-2026-scoring/issues/8#issuecomment-4102937253)では暫定32位(132人中)になりました。最終計測で自己最高スコアを出せました。 ただし、レギュレーション違反によりランキングから除外されてしまいました。レギュレーション違反していなければ5位になれていた可能性があったんですけどね。トホホ〜。 ## どこでレギュレーション違反になったか 違反の原因は、ユーザープロフィールのヘッダー背景色が初期状態と異なっていたことです。 `UserProfileHeader.tsx` でプロフィール画像の平均色をTailwindの動的クラス `bg-[${averageColor}]` として適用していましたが、`@tailwindcss/browser`(ランタイム)を `@tailwindcss/postcss`(ビルド時コンパイル)に置き換えたタイミングで、動的に組み立てたクラスが検出できなくなっていたことに気づけませんでした。 ビルド時コンパイルに置き換えるなら `style={{ backgroundColor: averageColor }}` のようにインラインスタイルに切り替える必要がありました(`averageColor` 自体は `useState()` で管理されています)。 とはいえレギュレーションに即しているかどうかを判定してもらえる点数を超えることはできたので、そこに至るまでの道のりを書きます。 ## スコアを伸ばした段階 今回スコアを伸ばした段階は大きく3つあります。 ### 1日目 18:08時点「219 → 498.75点」 初期ロードに関わる改善をまとめてデプロイした結果です。積み上げた改善によってユーザーフローをテストする閾値(300点)を超えました。 ### 1日目 18:57時点「498.75 → 576.6点」 FFmpeg/ImageMagick WASMのdynamic importを実施し、数十MB単位でバンドルファイルを初期読み込みから除外したのと、ReDoSの修正を入れました。 ### 1日目 22:26時点「576.6 → 750.85点」 GIF→MP4変換(TBT 6.7s→2.5s)、波形データの事前計算によるAudioContextデコード排除(TBT 2.5s→49ms)、JPEG→AVIF変換+リサイズ(画像容量93%削減、LCP 42s→7.3s)が中心です。 とりあえずこの3つをやった上で、より改善を積み重ねつつ、テストも継続的に実行して、レギュレーション違反しないかが重要です。それは毎年変わらないですね。 ## リポジトリのクローン まずリポジトリをクローンしてきたときに、リポジトリのサイズがおかしそうなことに気づきました。412個のオブジェクトに対して407.12 MiBは大きく感じます。 ```shell remote: Enumerating objects: 412, done. remote: Total 412 (delta 0), reused 0 (delta 0), pack-reused 412 (from 1) Receiving objects: 100% (412/412), 407.12 MiB | 9.07 MiB/s, done. Resolving deltas: 100% (39/39), done. Updating files: 100% (354/354), done. ``` これは後述しますが、シードデータのメディアファイルが合計334MBあり、これがリポジトリサイズの大半を占めていました。内訳は以下の通りで、GIFとJPEG形式のファイルが入っていて、まずここが最適化できそうということが分かります。 - 動画(GIF)179MB - 画像(JPEG)89MB - 音声(MP3)66MB ## 初期状態のアプリケーションをデプロイする スコア計測はアプリケーションをデプロイしないことには始まりません。 しかし、運営が用意したfly.ioの環境は競技開始直後に認証周りで問題があって、ちょっとの間デプロイすることができなかったのと、自前の環境のほうが問題は少なくなりそうだったので、早々にfly.ioアカウントを作ってそこにデプロイすることを決めました。 `fly apps create {アプリ名}` でアプリを作成し、`fly deploy --app {アプリ名}` でデプロイしました。特に詰まることなくデプロイできたので良かったです。 ## Lighthouse CIによる継続的パフォーマンス観測をする 最適化の効果を定量的に追跡するため、[Lighthouse CI](https://github.com/GoogleChrome/lighthouse-ci)を導入しました。 ローカルで実行するために `@lhci/cli` をdevDependencyに追加し、`pnpm lighthouse` でローカルサーバーに対してLighthouse CIを実行できるようにしました。 ```bash # application/ でサーバー起動後 pnpm lighthouse ``` 改善施策の実装前後でこれを実行して、ターゲットのメトリクスが実際に改善したか確認していました。 また、特定のページだけ計測したい場合に備えて `lighthouse:page` スクリプトを用意し、`--collect.url` でURLを渡して個別に計測できるようにしました。 ```bash # ホームページだけ計測(1回実行) pnpm lighthouse:page -- --collect.url="http://localhost:3000/" # 検索ページだけ計測 pnpm lighthouse:page -- --collect.url="http://localhost:3000/search" ``` ただ今回に関しては[公式のscoring-tool](https://github.com/CyberAgentHack/web-speed-hackathon-2026/tree/main/scoring-tool)が提供されていたため、これを実行すれば良かったです。ここはなぜか見落とした私のミスです。 ### パフォーマンス改善サイクルを自動で回す パフォーマンス改善の進め方として「仮説→計測→変更→計測」のサイクルを[Claude Codeのルール](https://github.com/kubosho/web-speed-hackathon-2026/blob/main/.claude/rules/perf-optimization.md)にして、このサイクルが自動で回せるようにしていました。 ルール化したことによって、Claude Code上で「この部分が遅くなってそう」とプロンプトを投げて、それに基づきClaude Codeが「何が遅いか、なぜ遅いか、どのメトリクスに反映されるか」といった仮説を立て、Lighthouse CIによる計測で裏付けてから変更を入れた後にもう一度計測をして、ターゲットのメトリクスが改善しなければrevertするフローが割と自動化できました。 --- ここまでやったところで、パフォーマンス改善をやっていきます。 ## バンドルサイズ分析 webpack-bundle-analyzerでバンドル構成を可視化して `main.js` が108MBという異常なサイズであることが分かりました。 サイズの大きい順に以下の通りです。 1. FFmpeg WASM (`ffmpeg-core.wasm?binary`):動画処理用 2. ImageMagick WASM (`magick.wasm?binary`):画像処理用 3. @mlc-ai/web-llm:AI推論エンジン 4. negaposi-analyzer-ja (`pn_ja.dic.json`): 感情分析辞書 5. highlight.js / refractor:シンタックスハイライト(全言語入り) 6. kuromoji / katex:重量級ライブラリ群 7. moment / lodash / jquery / core-js / bluebird:現代基準でいらないライブラリ ## Webpack→Rspackへ移行し、複数の問題をいっぺんに解消する Webpackの設定も問題が多かったです。一部を紹介すると以下の通りです。 - `mode: "none"`:minification無効 - `optimization.minimize: false`:圧縮無効 - `optimization.splitChunks: false`:コード分割無効 問題は他にもありますが、こういった問題を1つずつ直すより、Rspackに移行して設定をいい感じにするのが手っ取り早いと判断し、Rspackに移行したのち設定をいい感じにしました。 Rspack移行後に変えた設定は以下の通りです。 - `builtin:swc-loader` の使用 - `mode: "production"` への移行 - `splitChunks: { chunks: "all" }` でコード分割 - `devtool: false` でソースマップをバンドルから除外 - エントリーポイントから `core-js`, `regenerator-runtime` を除去 - CSSは `CssExtractRspackPlugin` + `css-loader` + `postcss-loader` - `HtmlRspackPlugin` で `inject: true` にしてJS/CSSを自動的に注入 これによる結果は以下の通りになりました。 - ビルド時間:15秒以上 → 0.9秒 - エントリーポイント合計:108MB → 12.3MB - FFmpegやImageMagickは別バンドルとして出力されるようになった ## 初回スコア計測 ここまでやった段階でスコア計測をして、以下の結果になりました。 | テスト項目 | CLS (25) | FCP (10) | LCP (25) | SI (10) | TBT (30) | 合計 (100) | | ---------------------------- | -------- | -------- | -------- | ------- | -------- | ---------- | | ホームを開く | 20.75 | 0.00 | 0.00 | 0.00 | 0.00 | 20.75 | | 投稿詳細ページを開く | 25.00 | 0.00 | 0.00 | 0.00 | 0.00 | 25.00 | | 写真つき投稿詳細ページを開く | 24.75 | 0.00 | 0.00 | 0.00 | 0.00 | 24.75 | | 動画つき投稿詳細ページを開く | 23.50 | 0.00 | 0.00 | 0.00 | 0.00 | 23.50 | | 音声つき投稿詳細ページを開く | 25.00 | 0.00 | 0.00 | 0.00 | 0.00 | 25.00 | | 検索ページを開く | 25.00 | 0.00 | 0.00 | 0.00 | 0.00 | 25.00 | | DM一覧ページを開く | 25.00 | 0.00 | 0.00 | 0.00 | 0.00 | 25.00 | | DM詳細ページを開く | 25.00 | 0.00 | 0.00 | 0.00 | 0.00 | 25.00 | | 利用規約ページを開く | 25.00 | 0.00 | 0.00 | 0.00 | 0.00 | 25.00 | 合わせて219点でした。ユーザーフローテストは通常テストのスコアが300点未満だと計測されない仕様で、まずは300点を超えるのが最初の目標という感じです。 ## Phase 1: 219→498点 ### サーバーのgzip圧縮 サーバー側でレスポンスの圧縮が一切入っていなかったので、Expressに `compression` ミドルウェアを追加してgzipによる圧縮を有効化しました。 ```diff +import compression from "compression"; import Express from "express"; export const app = Express(); app.set("trust proxy", true); +app.use(compression()); app.use(sessionMiddleware); ``` ついでにレスポンスヘッダーを確認したところ、全レスポンスに付与されていたヘッダーにも罠がありました。 ```diff -app.use((_req, res, next) => { - res.header({ - "Cache-Control": "max-age=0, no-transform", - Connection: "close", - }); - return next(); -}); ``` - `no-transform`: プロキシやCDNによる変換の無効化 - `max-age=0`: ブラウザーキャッシュの無効化 - `Connection: close`: HTTP Keep-Aliveの無効化 3つとも意図的な妨害コードで、レスポンスデータの容量を増やしたりリクエストを無駄に飛ばしたりする効果があるので丸ごと消しました。 ### 静的ファイルのcontenthashと長期キャッシュ アセット類をキャッシュしようとして、Rspackの出力ファイル名にハッシュが付いていないことに気づきました。内容が変わったときにキャッシュが無効化されるよう `[contenthash]` を追加して長期キャッシュを使えるようにしました。 ```js // rspack.config.js output: { filename: "scripts/[name]-[contenthash].js", chunkFilename: "scripts/chunk-[contenthash].js", }, // CssExtractRspackPlugin { filename: "styles/[name]-[contenthash].css" } ``` そしてルーター側で `/scripts`, `/styles`, `/assets` に対し `Cache-Control: public, max-age=31536000, immutable` を設定しました。 ```ts // routes/static.ts staticRouter.use('/scripts', (_req, res, next) => { res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); next(); }); ``` 認証が必要なAPIレスポンスと、ハッシュを付けなかった静的ファイル(index.htmlなど)にはキャッシュヘッダーを付けていません。 ### InfiniteScrollの不要な繰り返し判定の除去 `InfiniteScroll` コンポーネントで、スクロール位置が最下部に到達したかの判定を2 \*\* 18回繰り返していました。 「念の為」というコメントが付いていましたが、`window.innerHeight + Math.ceil(window.scrollY) >= document.body.offsetHeight` は同一イベントハンドラ内では毎回同じ値を返す純粋な比較式で繰り返す意味がありません。 この判定は `scroll`, `wheel`, `touchmove`, `resize` の4イベントすべてで発火するため、スクロールするたびに約26万回のDOM参照と配列生成が走り、TBTを悪化させていました。 ```diff - // 念の為 2の18乗 回、最下部かどうかを確認する - const hasReached = Array.from(Array(2 ** 18), () => { - return window.innerHeight + Math.ceil(window.scrollY) >= document.body.offsetHeight; - }).every(Boolean); + const hasReached = window.innerHeight + Math.ceil(window.scrollY) >= document.body.offsetHeight; ``` この実装は以前のWeb Speed Hackathonでもあったので、早めに「念の」でgrepしていました。こんなキーワードでgrepするのはWeb Speed Hackathonくらいな気がします。 ### Reactマウントのloadイベント待ち除去 `index.tsx` を見ると、`window.addEventListener("load", ...)` の中でReactをマウントしていました。 `load`イベントは全リソースの読み込み完了後に発火するため、バンドルファイルのダウンロード+パースが終わるまで描画が一切始まりません。 ```diff -window.addEventListener("load", () => { - createRoot(document.getElementById("app")!).render( - - - - - , - ); -}); +createRoot(document.getElementById("app")!).render( + + + + + , +); ``` ### Tailwind CSSのビルド時コンパイル `index.html` で `@tailwindcss/browser@4.2.1` をCDNから同期スクリプトとして読み込み、ブラウザー内でCSSをコンパイルしている構成でした。 外部スクリプトのダウンロード+パース+CSSコンパイルで描画をブロックするので、`@tailwindcss/postcss` を導入して、ビルド時にCSSを生成するように変更しました。 そしてここまで変更をおこなったところで、ようやくパフォーマンス計測を行いました。結果は以下の通りです。 ビルド時コンパイル化後の計測結果(ホーム画面): | 指標 | Before | After | | ------------------------- | ------ | ----- | | Performance | 0.17 | 0.15 | | FCP | 0.36 | 0.47 | | LCP | 0 | 0 | | CLS | 0.53 | 0.40 | | render-blocking-resources | 2件 | 1件 | | unused-javascript | 2件 | 1件 | FCPが0.36→0.47に改善しました。Performanceスコア自体は0.15に下がっていますが、個別のメトリクスが改善(FCP +0.11、CLS +0.13)していたので、改善を適用した状態にしました。 結果的にこの変更がレギュレーション違反の原因になるわけですが、当時の自分は気づいていませんでした。 ### ルートベースのコード分割 全ルートのコンテナコンポーネントが `AppContainer.tsx` に静的importされており、巨大な単一バンドル(なんと12MB!)になっていました。 ホーム画面の表示に不要な `/crok`(web-llm, katex, react-syntax-highlighter)やNewPostModal(含むFFmpegとImageMagick)まで全て初期ロードに含まれている状態だったので、`React.lazy` + `Suspense` で全ルートコンテナとモーダルを遅延読み込みに変更しました。 ```tsx const TimelineContainer = lazy(() => import('@web-speed-hackathon-2026/client/src/containers/TimelineContainer')); // ... 他のルートも同様 } path="/" /> {/* ... */} ; ``` コード分割後の計測結果(ホーム画面): | 指標 | Before | After | | ------------------------- | ------ | ------------------ | | Performance | 0.15 | 0.23 | | FCP | 0.47 | ≥0.9 (warning消失) | | render-blocking-resources | 1件 | 0件 (warning消失) | main.jsが12.3MiB→462KiB(96%削減)となり、FCPが大幅改善してwarningの閾値以下になりました。 ### CoveredImageをネイティブ``に置換 `CoveredImage` コンポーネントを調べると、画像表示のために毎回 `jQuery.ajax` でバイナリをフェッチし、`image-size`でサイズ計算、`piexifjs`でEXIF抽出、Blob URL生成という重い処理を踏んでいました。これらは `` + `object-fit: cover` に置き換えれば全部不要になります。 EXIFからのalt取得は「ALTを表示する」ボタン押下時のみ動的にインポートして実行するように変更し、`loading` 属性をpropsに追加した上で、ファーストビューに入る画像のみ `eager`、それ以外は `lazy` に設定しました。 ```tsx ``` 置換後の計測結果(ホーム画面): | 指標 | Before | After | | ---------------------- | ------ | ----- | | Performance | 0.23 | 0.23 | | offscreen-images | 28件 | 1件 | | uses-responsive-images | 47件 | 34件 | | lcp-lazy-loaded | - | 解消 | Performanceスコアは変わっていませんが、offscreen-imagesが28→1件に減りました。画像がJS経由ではなくブラウザーネイティブで読み込まれるようになった効果です。 ### jQuery → native fetchへの置換 `fetchers.ts` を見ると、全HTTPリクエストを `$.ajax({ async: false })` で実行していました。`async: false` は同期XHRなのでリクエスト中メインスレッドがブロックされます。これをネイティブの `fetch` APIに置き換えました。 置き換え後に気づいたこととして、`fetchers.ts` 内のFetch APIラッパーである `sendJSON` がリクエストボディをgzip圧縮した上で `Content-Encoding: gzip` ヘッダーを付けてリクエストしようとしたときに、Fetch APIを使うとDM送信と投稿が失敗していました。 [Constructing a Response with Content-Encoding? · Issue #589 · whatwg/fetch](https://github.com/whatwg/fetch/issues/589)で書かれていますが、ブラウザー(Chrome)側でレスポンスデータは自動解凍してくれるにも関わらず、リクエスト時には自動圧縮してくれないという一貫性のなさがあるようです。 とはいえRequest時に `Content-Encoding` を付けることは比較的まれという記述もissueにあって、実際に私もRspack設定内の `ProvidePlugin` から `$` と `window.jQuery` を削除することで問題がなくなりました。 Fetch API置換後の計測結果(ホーム画面): | 指標 | Before | After | | -------------------- | ------- | ------- | | エントリーポイント | 462 KiB | 377 KiB | | deprecations failure | あり | 解消 | | charset failure | あり | 解消 | エントリポイントが85KiB縮小し、jQuery由来の非推奨API warningも解消されました。 ### web-llmのdynamic import化 `TranslatableText` コンポーネントが `createTranslator` をstatic importしていた影響で、依存していた `@mlc-ai/web-llm`がタイムラインのチャンクに存在していました。翻訳機能はユーザーが「Show Translation」ボタンを押した時のみ使われるため、クリック時にdynamic importするよう変更しました。 dynamic import後の計測結果(ホーム画面): | 指標 | Before | After | | ----------- | ------ | ----- | | Performance | 0.20 | 0.24 | web-llmがタイムラインのチャンクから分離され、ホーム画面の初期ロードに含まれなくなったことでパフォーマンスの値が上がりました。 ### momentの除去 複数のコンポーネントで日付フォーマットに `moment`が使われていましたが、使い方を調べたところ3パターンしかありませんでした。 - `moment(date).locale("ja").format("LL")` →「2026年3月20日」 - `moment(date).locale("ja").fromNow()` →「3時間前」 - `moment(date).locale("ja").format("HH:mm")` →「17:30」 なので `Intl.DateTimeFormat` と `Intl.RelativeTimeFormat` で置き換えて、momentの依存をなくしました。 ただ相対的に他と比較してパッケージサイズが小さいため、パフォーマンス改善にはつながりませんでした。 ## Phase 2: 498→576点 ### ReDoS脆弱性の修正 クライアント側の正規表現を調べたところ、3箇所にReDoS(Regular Expression Denial of Service)パターンが仕込まれていました。いずれもネスト量指定子によって指数関数的にバックトラッキングが増えていました。 1. `auth/validation.ts`:`/^(?:[^\P{Letter}&&\P{Number}]*){16,}$/v` → `/^[\p{Letter}\p{Number}]*$/v`(パスワードの記号チェック) 2. `search/services.ts`:`/since:((\d|\d\d|\d\d\d\d-\d\d-\d\d)+)+$/` → `/since:(\d{4}-\d{2}-\d{2})$/`(日付抽出) 3. `search/services.ts`:`/^(\d+)+-(\d+)+-(\d+)+$/` → `/^\d+-\d+-\d+$/`(日付形式判定。変数名が `slowDateLike` だった) ReDoSはユーザー入力時(フォームバリデーション)で発火するため、ユーザーフローテストのINP/TBTで効果が出ました。 ## Phase 3: 576→750点 ### 動画のGIF→MP4変換 メディアファイルの最適化に着手しました。最初にリポジトリを取得したときにデータ容量が大きいことには気づいていたので、ここを改善することで各種指標が上がりそうという肌感がありました。 まずは動画です。動画は全てGIF形式で保存されていて、15ファイルで合計179MBとなっていました。 GIFは `PausableMovie` コンポーネントでfetchした後 `gifler` + `omggif` でフレーム単位にデコードしてcanvasへ描画する実装になっていて、メインスレッドでのデコード処理がTBTを悪化させていました。 そのため、まずはシードGIFをFFmpeg CLIでMP4 (H.264) に事前変換するスクリプトを作成して、動画をMP4形式にしました。 ```bash # `-movflags +faststart` オプションを追加して、moov atomをファイル先頭に配置し、ダウンロード完了前からストリーミング再生できるようにした ffmpeg -i "$gif" \ -c:v libx264 \ -pix_fmt yuv420p \ -movflags +faststart \ -an \ -loglevel warning \ "$mp4" ``` また `PausableMovie` コンポーネントをネイティブのvideo要素に置き換えて `gifler`, `omggif` の依存を削除しました。 ```tsx