データは0.3秒で届いていたのに、React が描画を5秒後回しにしていた
「スクロール後にプロフィールが5秒開かない」の犯人は、サーバーでも回線でもJSでもなく、React の後回しレーンに飼い殺しにされた重い初回描画だった。容疑者10人を消去法で潰した記録。
個人開発の SNS アプリ(iOS/Android・React Native + Expo + Convex)で、「タイムラインを深くスクロールした後に誰かのプロフィールを開くと、数秒間スケルトンのまま」という問題を追いかけた。
結論から書くと、データはずっと 0.3 秒で届いていた。犯人は React が新しい画面の初回描画を「後回しレーン」に入れ、重すぎて完走できない描画を約5秒の強制期限まで飼い殺しにしていたことだった。修正は「初回描画を2段に分割して軽くする」だけ。実測 5,200ms → 400ms になった。
たどり着くまでに容疑者を10人つぶした。この回り道が一番の学びだったので、順番に書く。
症状
- TLを深くスクロールするほど、その後に開くプロフィールが遅くなる(先頭なら400ms、9件目あたりで2秒、深いと5秒)
- 30秒放置しても直らない。でもアプリを開き直して先頭ならすぐ速い
- 軽い画面(設定など)は常に速い。投稿がたくさん載る画面だけ遅い
- Android のミドル帯で顕著、iOS も若干
先に白状すると、この症状に対して私は最初、サーバーからの取得件数を疑ってページング化(200件一括→30件+継ぎ足し)を実装し、次に画像を疑って CDN 変換(60〜92%軽量化)を導入した。どちらも通信量とサーバー負荷にはちゃんと効いたが、体感は1ミリも変わらなかった。ユーザーとしての自分の「何も変わってないよ」が正しかった。
容疑者を消していく
勘で直すのをやめて、アプリに自己診断させることにした。__DEV__ 限定で、アプリ自身が各種メトリクスを記録して5秒ごとに開発環境の DB へ送るモジュールを入れた。テスト協力者(=自分の実機)は「普通にアプリを使うだけ」でよく、あとから DB を読めば全部わかる。
これで潰した容疑者の一覧:
| 容疑者 | 検証 | 結果 |
|---|---|---|
| サーバーの実行が遅い | 実行ログ | ❌ 常に20〜65ms |
| 応答が大きい | サイズ実測 | ❌ 5〜25KB |
| JSスレッドの詰まり | 250ms間隔タイマーの遅延 | ❌ 遅い最中もゼロ |
| 回線の帯域飽和 | 毎秒の小さなHTTP往復 | ❌ 遅い最中も450msで正常 |
| WebSocketの配達詰まり | 同じWSを通る極小クエリ | ❌ 遅い最中も400msで往復 |
| クエリの再計算嵐 | サーバーの実行回数 | ❌ 1購読1回だけ |
| フォーカスイベントの遅延 | focus到着時刻 | ❌ 常に55〜70ms |
| UIスレッドの渋滞 | rAF間隔 | ❌ フレームは流れ続けている |
| データ到着が遅い | WS受信の全件記帳 | ❌ 注文→到着は常に約300ms |
全部シロ。**「データは手元にあり、JSも描画も回線も暇なのに、画面だけ出ない」**という不可解な状態になった。ここが一番苦しかった。
決定打は2つ
その1。同じ画面の左上に置いていたデバッグ用バッジ(1秒ごとに setState して描画される)は、遅い5秒の間もずっと更新され続けていた。 つまり React 全体が止まっているのではない。プロフィール画面の setState「だけ」が描画されない。
その2。遅いときの実測値が 5,034 / 5,116 / 5,166 / 5,214ms と、毎回「5秒強」に張り付いていた。 ランダムな遅さはこんなに揃わない。何かの「期限」の匂いがする。
React の concurrent rendering には、transition(後回しにしてよい更新)のレーンがあり、後回しにされ続けた作業を強制実行する期限がだいたい5秒にある。指紋が一致した。
メカニズム
- 画面遷移で、新しい画面の描画は後回しレーンに入る
- うちのプロフィール画面の初回描画は、写真グリッド30枚+αを一気に描くので、実機で約1秒級の重さがあった
- 後回しレーンの作業は、他の描画が割り込むたびに中断される。1秒級の作業はいつまでも完走できない
- 約5秒の期限が来て、ようやく強制実行される
スクロール量に比例していたのは、深くスクロールするほど割り込み源(購読の再配信など)が増えて、レーン内で完走できる確率が下がっていたからだった。「JSも描画も暇なのに遅い」ように見えたのは、忙しかったのではなく、何度も中断されてやり直していたからだった。
修正
初回描画を2段に分けた。
- 1段目: 名前・アイコン・フォローボタンだけ描く。軽いので中断される前に完走する
- 2段目: 写真グリッドなど重い部分を
requestAnimationFrameで次のフレームに回す
const [belowFoldReady, setBelowFoldReady] = useState(false);
useEffect(() => {
if (data === undefined || belowFoldReady) return;
const id = requestAnimationFrame(() => setBelowFoldReady(true));
return () => cancelAnimationFrame(id);
}, [data, belowFoldReady]);
// 重い部分は {belowFoldReady ? <HeavyGrid/> : null}
体感は「名前が出て、まばたきの間に写真が出る」で、分割されたことには気づかない。実測は 5,200ms → 364〜426ms。残った400msはほぼ回線の往復時間そのものなので、アプリ側の無駄はほぼゼロになった。
学び
- 画面遷移直後の初回描画は「1フレームで完走できる軽さ」に保つ。 重い一枚岩の初回描画は、後回しレーンの中で期限まで待たされうる
- 取得の最適化と描画の最適化は別の病気。 ページングも画像軽量化も「効かなかった」のではなく、別の病気を治していた。体感の犯人を特定するまで、体感は動かない
- 毎回同じ値に張り付く遅さは「期限」を疑う。 5.0〜5.2秒に4回揃った時点で、ランダムな渋滞説は全部捨ててよかった
- アプリに自己申告させるのが最強の計測だった。 プロファイラの遠隔接続やDevToolsの手動操作は、実機・協力者・タイミング合わせの三重苦で全部空振りした。「普通に使ってもらって、あとからDBを読む」方式にした瞬間に捜査が進み始めた