はじめに
Web Speed Hackathon 2026に参加し、最終順位は4位(813.25点) でした。最終レギュレーションチェック前は21位前後でしたが、ここで多くの方がレギュレーション違反となったため4位になったという形です。前回も参加しており、その時は最終レギュレーションチェックで失敗したので、その反省を活かせたのは嬉しいことでした。ただ、後述しますが、後に自身で確認したときに最終的な状態でも微妙に壊した機能があったため、少し申し訳ない気持ちもあります。
今回は自分の振り返りも兼ねて、コンテスト中にやったこととコンテスト後に試した改善を残しておきます。コンテスト後の改善の結果はローカル環境にて1122.10点、コンテストと同様のGitHub Actions上では1027.85点を記録できました。コードに関してはこちらのリポジトリに置いてあり、mainブランチはコンテスト終了時、after-contestブランチにはコンテスト終了後に色々な改善を積んだものがあります。1
コンテスト前
事前に改善の勘所を掴むために、去年のコンテストに参加された方の参加記を読んでいました。WebpackからRspackへの移行を今回行ったのですが、この参加記を読んでいたおかげだと思います。時間があれば、素振りをしておくなり、簡単な改善ポイントのチェックシート作っておくなりをしたかったのですが、今回はできていませんでした。
コンテスト中
ここからはコンテスト中の改善について書いていきます。まずレギュレーションと計測環境を整え、その後にアプリケーションの改善を進めていきました。各種アプリケーションの改善は、Bundle Analyzerによるバンドルサイズの分析やChromeのDevTools上で、Network・Performance・Lighthouseの機能を見て、ボトルネックを推定するという形で進めていきました。
1. レギュレーションの確認
まずはBundle Analyzerを入れてボトルネックを探したくなりますが、去年レギュレーション失格の反省を活かし、落ち着いてレギュレーションの確認から行いました。特に最終レギュレーションチェックであるtest_cases.mdを最初に手動で通したことは、サービスの把握と意図しないリグレッションの防止に役立ってくれました。また、VRTのスナップショットもここで作成しています。これ以降の改善の際にも、別のポート番号で初期アプリを立ち上げておき、都度目で比較していました。ここはもう少し効率化できると良いのですが、あまり良い方法が浮かびませんでした。
2. 計測環境の構築
計測環境の構築も兼ねて、Bundlerの整理を最初に行いました。去年はViteへの移行で大変苦労した記憶があったので、Webpackとの互換性が高いRspackへの移行を初手にしました。Bundlerの移行は、必須ではありませんが、どうせ行うことになると考えたためです。ただ、計測するためにも何かしらのBundle Analyzerの導入は必須だと考えています。今回はBundle Analyzerとして、Rspackと相性の良いRsdoctorを使用しました。Rsdoctorは初めて使ったため色々操作にもたつきましたが、なんとなくの操作でChunk Sizeの確認ができたため、今回のコンテストでは十分役立ちました。
3. JSバンドルサイズを縮小
毎年のコンテストでも問われているのですが、今年もやはり初回のmain.jsが非常に大きなサイズになっていました。1つずつ色々な方法でサイズを減らしていきました。
- negaposi-analyzer-ja
- サーバー側に新しくAPIを作成し、そこに処理を移した
- @mlc-ai/web-llm
- dynamic importにした
- 後の講評でTranslator APIがあることを知った
- moment
- dayjsとIntl.DateTimeFormatに変更した
- dayjsに関して、
moment().fromNow()を最初はIntl.RelativeTimeFormatを使用しようとしていたが、微妙に仕様の差分があったため仕方なく導入
- lodash
- 削除した
- encoding-japanese
- TextDecoderで代替した
- jQuery
- 削除した
- 微妙にステータスコードのハンドリングが異なるので、そこは修正が必要だった
- pako
- CompressionStreamで代替した
- 各種Polyfill(buffer, bluebird, standardized-audio-context)
- 最新のChromeのみがターゲットのため、不要なので削除した
- FFmpeg, ImageMagick
- サーバー側で必要な処理を行うように変更、詳細は後述
- redux-form
- react-final-formへ移行した
- formライブラリの変更はblurで動作しなくなるなどの微妙にバリデーションの挙動の差異が怖くて躊躇っていたが、Reduxなりlodashなりを引き連れていたので変更することを決意
- 今回は公式で後継と書いていたreact-final-formにした
4. Tailwind CSSを組み込む
Networkタブを見ると、Tailwind CSSがjsdelivr経由でダウンロードされていたため、自前で持つようにしました。ビルド時にCSSを生成するようにすれば、Tailwind CSS自体のスクリプトは配信しないで済むようになります。ただ、これによりランタイム時に動的にクラス名を組み立てている場合に正常に動作しなくなります。今回の場合は、ユーザープロフィール画面のヘッダーの背景色が該当していました。今回の場合VRTでの検知もそうですし、test_cases.mdにわざわざ記載されていたので、自分は気づくことができました。これによるレギュレーション失格の方もおられたようでした。
5. 最新のCSSで実現可能な代替
CSSで実現可能なものをJavaScriptで代替しているパターンもよく出題されており、今回もいくつか存在していました。DevToolsの計測ではなくスコアリングで悪かった画面のソースをざっと見て、AIに改善余地のありそうな箇所を洗い出してもらい、妥当そうなものから検証していきました。今回は以下のようなものがありました。
6. 各種静的リソースの最適化
極端に大きな画像やフォントファイルが送られてきている、というパターンもよく出題されています。それによってFCP, LCP, CLSなどのスコアが悪化しやすいです。今回の場合はリソースが大きいことに加えて、クライアント側でリソースのハンドリングを行うことで、不要なハンドリングのためのライブラリが増える、処理時間によるTBTの悪化などの問題も起きていました。
画像
画像のサイズが4000×6000など、非常に大きかったのでアスペクト比は保ちつつサイズを小さくすることに加え、圧縮効率に優れるavif形式へと変換しました。ファーストビューに含まれそうな画像にはfetchPriorityの付与もしました。また、クライアント側で行っていた画像の変換と、後ほどaltに使われるEXIF情報の埋め込みの処理をサーバー側に持っていきました。そもそもEXIFからaltを抽出する処理も無駄なので、変換処理と同時にデータベース上にaltを保存し、レスポンスにaltを入れる形にしました。運営の方も想定していたのか、初期のデータベースに使われていなかったaltのカラムがあったため、そこに入れました。余談ですが、レギュレーション失格理由にaltが表示されない、がいくつか見受けられました。推測ですがavif等への変換でEXIFが落ちた、初期データのalt移行が漏れていた、などが起きていたと考えています。
音声
音声に関してはmp3への変換処理とクライアント側での波形の表示を画像と同じようにサーバー側で行うようにしました。波形に関してクライアント側ではAudioContextで行っていたものをffmpegで行うようにしたのですが、最初にClaude Codeが生成したコードでは微妙に波形が異なっていました。このようなものもClaude Codeに理由を聞き、ステレオでの処理がモノラルになっていたためだとわかりました。ただ、変換処理をサーバー側に持っていったことで、scoring-toolのシナリオではWAVファイルをサーバーへ送るようになったのですが、これはタイムアウトするようになっていたことが後に判明しました。コンテスト後に行った改善については後述します。
動画
動画に関しては、最初そもそもクライアント側で何をやっているかよく分からなかったのですが、アニメーションgifを一時停止可能な状態で提供することだと分かりました。videoタグで置き換えできそうだったので、webmを使用した動画に置き換えました。投稿時の変換もサーバー側に持っていきました。ただ、この変更により、 canvas要素に描画されていた動画がvideo要素に変わったので、canvas要素を前提としていたVRTのシナリオが通らなくなりました。恐らく他の参加者も同様の事象に遭遇し、問い合わせがあったためか、2日目にVRTの修正が追加されていました。
フォント
フォントは利用規約ページで、コンテスト独自のものが使われていましたが、使われる文字は限られていたためサブセット化することで容量を減らしました。講評によるとレギュレーションに「Noto Serif JP」がインストールされている環境であることと、コンテスト独自のフォントが「Noto Serif JP」をY軸を縮小したフォントなので、スケールを変更して表示することでフォント配信を無くせたようですが、流石にそこまでは気づけませんでした。
アイコン
初期状態だと、各種アイコンの表示に何故か大きなファイルのネットワークアクセスが発生しており、見てみるとsvgのuse xlink:hrefで配信しているsvgを参照しており、そのsvgがアプリケーションで使っていないアイコンなども全部入りのものだったためでした。そもそもこんな機能があることを初めて知りましたが、普通に使う箇所のsvgだけを切り出して使うようにしました。
ここまでは主に配信サイズや静的リソースの観点で改善してきましたが、ここからはデータ取得や描画方式そのものの見直しについて書きます。
7. useEffect + fetchからTanstack Queryへ移行
これは思いつきでしたが、fetchの結果をキャッシュできないかとTanstack Queryを導入し置き換えました。これ自体はあまりスコアに貢献していなかったと記憶していますが、後続のServer Side Renderingに置き換える際には役立ちました。
8. Server Side Rendering
どうにもLCPのスコアが上がらず、Server Side Renderingの実装を試してみました。もし自分一人では大分時間が掛かったと思いましたが、終えられたのはClaude Code様様です。ただ、終わった後に見返すと大分カオスな構造になっており、よく動作していたな、と思います。効果自体はありましたが、思ったよりは寄与していなかった印象があります。
コンテスト終了
上記の修正に加えて、いくつか細かい修正を加え、4位(813.25点) という結果でコンテストを終えました。その状態はmainブランチに置いてあります。ただ、後にスコアを上げようと確認した際に、以下の箇所で初期アプリケーションと異なることが判明しました。
- ユーザープロフィールのヘッダーの背景色が、他の画面からの遷移では問題ないが、直アクセスだと反映されていなかった。imgのonloadで実装されていたが、SSRしたタイミングで発火しないようになっていた。
- DM詳細画面での画面表示時、メッセージ送信・受信時のスクロール位置が画面下部より少し上になっている。メッセージ入力フォーム分の高さがずれている。毎ミリ秒監視してスクロールする処理の最適化をしたときに、
window.scrollToからscrollIntoViewに書き換わっていたのだが、入力フォーム分の高さが考慮されないため。 - Crokのストリーミング中のスクロールボタンが表示されない。シンプルに修正時に巻き込んで壊してしまった。
3番目に関してはテストケースに記載が無かったため良いとしても、他の2項目は抵触していると思うので申し訳なさがあります。また、SSRも誤った実装になっていたため、hydration errorがconsoleに出ているような危うい状態でした。
コンテスト後の改善
ここからはコンテスト後に行った改善について書いていきます。こちらはClaude Codeではなく、Codexをメインに使ってみました。ローカル環境にて1122.10点、コンテストと同様のGitHub Actions上では1027.85点を最高点として記録できました。環境の詳細は以下です。
- ローカル環境
- MacBook Pro M5 32GB
- コンテスト再現環境
- Cloudflare + AWS EC2 c7a.medium + GitHub Actions
1. 静的リソース
とりあえず各種静的リソースは、できるだけサイズを小さくなるようにしました。どこまでやるとレギュレーション違反になるかは分からないのでコンテスト中は妥協していましたが、やはりこの静的リソースのサイズはスコアに大きく影響します。また、キャッシュ周りのヘッダーも全然活用できていなかったので設定を見直したり、Cloudflareを使っていたというのもありs-maxageなども設定しました。
2. video要素がLCPになることへの対処
画像に関しては fetchPriority=high でリソースの先読みをして、LCPを改善できるのですが、動画の場合は以下を行うと効果的でした。
- posterプロパティを使用して、先に画像を出す
- preloadは
noneにしておき、他のリソースを優先する - 1番目のposter画像をpreloadできるように
<link rel="preload" as="image" href="poster.jpg" fetchpriority="high">のようなコードをheadに入れる
ここに関してはこの記事が参考になりました。
3. 音声投稿テストのタイムアウトへの対処
先に述べたように、単純に圧縮処理をサーバーに持っていくだけだと「ユーザーフロー: 投稿」のシナリオでタイムアウトするようになっています。そのため、クライアント側で何かしらの圧縮処理が必須となります。今回はlamejsを使ってmp3へのエンコードをしましたが、初期のコードと同じく単純に投稿時に変換するとTBTが悪化します。そのため、Web Workerで行うようにすると、TBTに悪影響なくシナリオが満点で通るようになりました。
4. レスポンスサイズの削減
各種APIのレスポンスの無駄なフィールドの削減や、ページネーションを駆使して無限スクロールに実装を変えられそうなところは変えていきました。顕著なのは、DM一覧画面やDM詳細画面で効きました。また、効果があるか実感はできていませんが、APIレスポンスに対するBrotli圧縮も行いました。
5. CrokのTBT改善
講評でも触れられていましたが、react-syntax-highlighter のAuto DetectがTBT悪化の大きな要因だったので、それを外すだけでも大分改善しました。ただ、mermaidのシンタックスハイライトに関しては、微妙に初期と異なるものになるので初期のアプリケーションと全く同じものを目指す、という点ではもう一手間必要です。しかし、今回は見送りました。また、SSEで一度に送るメッセージのサイズを増やしたところ、ユーザー目線では快適になったのですが、TBTが悪化してしまったので今回は入れていません。
6. DM送信のINP改善
ある程度改善を進めると、DM送信でINPの悪化がありました。メッセージ送信後に会話内の全てのメッセージをfetchしなおし、画面にレンダリングするところが原因でした。楽観的更新を実装しネットワーク通信をせずとも画面の更新することで改善されました。
7. JSサイズの削減
ある程度までLCPは改善されるのですが、どうしてもJSのサイズがネックになって点が伸び悩みました。そして、この時点でJSのサイズを占めている原因はreact-domとなっていました。そのため、react/react-domをpreactを置き換えることを主軸にしつつ、JSを減らしていきました。
react/react-dom→preactreact-final-form: 壊れたので、自前実装に変更react-router:wouter→wouter/preactというプロセスを経て置き換えreact-helmet: 大したことはしていなかったので自前実装に変更@tanstack/react-query:@tanstack/preact-queryに簡単に置き換え
上記の移行後preactはDOMのイベントハンドリングが異なるためか、翻訳機能のクリックに関連する箇所が壊れましたが、それ以外に大きな問題はなさそうでした。
8. その他
CSSのインライン化を行うとローカル環境では数点点数が上がるものの、GitHub Actions環境上では大幅にTBTが悪化したことや、dirty hackで点数が上がったことなど、他に細かい改善点も色々あるのですがここでは割愛します。最終的な点数の内訳もここに記載しておきます。
ローカル環境
通常テスト
| テスト項目 | CLS (25点) | FCP (10点) | LCP (25点) | SI (10点) | TBT (30点) | 合計 (100点) |
|---|---|---|---|---|---|---|
| ホームを開く | 25.00 | 9.90 | 22.25 | 10.00 | 30.00 | 97.15 |
| 投稿詳細ページを開く | 25.00 | 9.90 | 23.00 | 10.00 | 30.00 | 97.90 |
| 写真つき投稿詳細ページを開く | 25.00 | 9.90 | 21.75 | 10.00 | 30.00 | 96.65 |
| 動画つき投稿詳細ページを開く | 25.00 | 9.80 | 22.25 | 10.00 | 30.00 | 97.05 |
| 音声つき投稿詳細ページを開く | 25.00 | 9.80 | 23.25 | 10.00 | 30.00 | 98.05 |
| 検索ページを開く | 25.00 | 9.90 | 21.25 | 10.00 | 30.00 | 96.15 |
| DM一覧ページを開く | 25.00 | 9.60 | 21.25 | 9.90 | 30.00 | 95.75 |
| DM詳細ページを開く | 25.00 | 9.60 | 21.00 | 9.90 | 30.00 | 95.50 |
| 利用規約ページを開く | 25.00 | 9.10 | 24.00 | 9.80 | 30.00 | 97.90 |
ユーザーフローテスト
| テスト項目 | INP (25点) | TBT (25点) | 合計 (50点) |
|---|---|---|---|
| ユーザーフロー: ユーザー登録 → サインアウト → サインイン | 25.00 | 25.00 | 50.00 |
| ユーザーフロー: DM送信 | 25.00 | 25.00 | 50.00 |
| ユーザーフロー: 検索 → 結果表示 | 25.00 | 25.00 | 50.00 |
| ユーザーフロー: Crok AIチャット | 25.00 | 25.00 | 50.00 |
| ユーザーフロー: 投稿 | 25.00 | 25.00 | 50.00 |
合計 1122.10 / 1150.00
コンテスト再現環境
通常テスト
| テスト項目 | CLS (25点) | FCP (10点) | LCP (25点) | SI (10点) | TBT (30点) | 合計 (100点) |
|---|---|---|---|---|---|---|
| ホームを開く | 25.00 | 8.80 | 23.25 | 6.00 | 19.20 | 82.25 |
| 投稿詳細ページを開く | 25.00 | 9.00 | 22.00 | 7.60 | 30.00 | 93.60 |
| 写真つき投稿詳細ページを開く | 25.00 | 9.00 | 19.75 | 7.30 | 30.00 | 91.05 |
| 動画つき投稿詳細ページを開く | 25.00 | 9.00 | 21.00 | 8.50 | 30.00 | 93.50 |
| 音声つき投稿詳細ページを開く | 25.00 | 9.00 | 22.25 | 8.80 | 30.00 | 95.05 |
| 検索ページを開く | 25.00 | 9.00 | 18.75 | 7.40 | 30.00 | 90.15 |
| DM一覧ページを開く | 25.00 | 8.00 | 16.75 | 8.90 | 29.70 | 88.35 |
| DM詳細ページを開く | 25.00 | 8.00 | 14.75 | 9.50 | 30.00 | 87.25 |
| 利用規約ページを開く | 25.00 | 4.80 | 19.00 | 4.60 | 30.00 | 83.40 |
ユーザーフローテスト
| テスト項目 | INP (25点) | TBT (25点) | 合計 (50点) |
|---|---|---|---|
| ユーザーフロー: ユーザー登録 → サインアウト → サインイン | 25.00 | 25.00 | 50.00 |
| ユーザーフロー: DM送信 | 25.00 | 23.25 | 48.25 |
| ユーザーフロー: 検索 → 結果表示 | 25.00 | 25.00 | 50.00 |
| ユーザーフロー: Crok AIチャット | 25.00 | 0.00 | 25.00 |
| ユーザーフロー: 投稿 | 25.00 | 25.00 | 50.00 |
合計 1027.85 / 1150.00
まだJSには減らす余地がありそう、API通信部分は改善の余地がありそう、GitHub Actions環境だとCrok AIのTBTは大いに改善の余地ありなど、改善できそうな点はありますが、今回はここまでとしておきました。
おわりに
Web Speed Hackathon 2026は自身のフロントエンド力の確認と研鑽、およびモダンなフロントエンドのキャッチアップのとても良い機会になりました。また、コンテストから話は逸れますが、Claude Code/Codexをここまで密度高く利用したのは初めてかもしれません。去年のコンテストではChatGPTに聞きながらも実装は自分でしていましたが、今年は自身の手ではほとんどコードを書いておらず、良し悪しはさておき時代の変化を大きく感じています。現時点では、全体の方針を考えるところや細部の最適化の箇所を見つけるところは自走から少し離れているように感じましたが、とはいえ数か月後あるいは来年には、また違う感想を持っているかもしれません。
今回も作問や進行など、開催に多大な労力を払っていただいた運営の皆様へ本当に感謝しています。このような質の高いコンテストが、自分の感覚では、まだ知名度が十分に高いとは言えないように感じており、この記事が知名度向上に少しでも寄与できれば幸いです。
Footnotes
-
コミット履歴や実装等はめちゃめちゃ汚いですし、after-contestブランチに関しては
database.sqliteをgitignoreしたためpnpm seed:insertの実行が必要な点にも注意してください。 ↩