メインスレッド税:SSRなしでLCPを修正する - パート2
6 min read
—

Adit Shah
Member of Technical Staff
Category
2部構成のパート2:インクリメンタル永続化、シャードキャッシュ、段階的リストア、そして成果。
パート1では、問題を取り上げました。大規模な認証付きSPAにおける7秒のLCP。原因は遅いレンダリングではなく、データの可用性でした。ナイーブなIndexedDB永続化がなぜ事態を悪化させたか——メインスレッドでのstructured cloneシリアライゼーションによりINPが3秒にスパイクした理由——そしてReact Queryの組み込み永続化オプションがなぜ我々の制約に合わなかったかを示しました。
最後にアーキテクチャのプレビューを示しました:すべての重い処理を担当するWeb Worker、シャード化されたIndexedDBストレージ、リロード時の選択的リストア。本稿では各コンポーネントの詳細、信頼性を確保するためのプロダクションガードレール、そして測定結果を深く掘り下げます。
永続化を低コストに:インクリメンタル差分
最初の設計制約は明確でした:永続化コストはキャッシュサイズに比例して増加してはならない。実際に変更された量に比例してスケールすべきです。
ナイーブなアプローチは、永続化サイクルのたびにキャッシュ全体をシリアライズします。私たちのアプリでは数百のクエリがあり、そのほとんどは前回の永続化から変更されていません。変更されていないデータをstructured cloneに通し、postMessage境界を越え、マージ操作に投入するのは純粋な無駄です。
代わりに、以前永続化したすべてのクエリのdataUpdatedAtタイムスタンプを追跡します:
最初の永続化後、後続のサイクルは通常、クエリ全体のごく一部——前回以降に新しいデータを受信したものだけ——を送信します。差分自体はクエリごとのMapルックアップに過ぎず、定数時間で無視できるコストです。
永続化パスには2層のスケジューリング保護もあります。クエリキャッシュの変更イベントはデバウンスされ(2秒)、複数のAPIレスポンスからの高速な更新が個別の永続化サイクルをトリガーしないようにします。そして実際の永続化作業はrequestIdleCallbackでラップされ(5秒タイムアウト付き)、ブラウザのアイドル時間に作業を遅延させつつ、合理的なウィンドウ内に実行されることを保証します。
微妙な詳細が1つあります:postMessageでクエリをワーカーに送信する前に、クエリメタデータからシリアライズ不可能なプロパティを除去します。React Queryはクエリに任意のmetaオブジェクトをアタッチでき、我々のものには関数を含むものがありました。structured cloneアルゴリズムは関数をクローンできないため、metaをサニタイズしてシリアライズ可能な値のみを保持します。これがないと、メタデータに関数を含む単一のクエリが永続化サイクル全体をクラッシュさせます。
シャーディング:ストレージではなくリストアのため
すべてのクエリを単一のIndexedDBキーに永続化した場合、リストアはオール・オア・ナッシングになります。ブロブ全体を読み取り、デシリアライズし、すべてをハイドレートする。インクリメンタルな永続化があっても、リストアパスでは完全なstructured cloneに逆戻りです——そしてそれがパフォーマンスクリティカルなパスです。
シャーディングはクエリを約20の独立したIndexedDBキーに分散させることでこれを解決します。各シャードは独立して読み取りおよびリストアできる自己完結型のユニットです。
クエリはクエリキーの最初の要素に基づいてシャードにルーティングされます。2段階のルーティング戦略を使用しています:
予約シャード:高トラフィックルート用。シャード0〜2はそれぞれダッシュボード、通知、worksクエリに永続的に割り当てられます。特定のルートのすべてのクエリが同じシャードに格納されるため、1つのシャードを読み込めばルートが必要とするすべてが得られます。これは決定論的であり、ランタイムのランキングは不要です。
ハッシュ分散シャード:それ以外すべて用。クエリキーの最初の要素がFNV-1aでハッシュされ、残りのシャードの1つにマッピングされます。これにより、関連するクエリ——accountsやconversationsのようなキープレフィックスを共有するもの——が明示的な設定なしに自然に同じシャードに配置されます。
各シャードには上限もあります。シャードあたり最大400クエリで、上限を超えると最も古く更新されたエントリがエビクションされます。クエリごとに48時間のTTLがあり、永続化時に適用されます——ワーカーが書き込み前に古いクエリをフィルタリングします。これらのガードレールは読み取り時ではなく書き込み時に適用されるため、リストアは高速なままです。
結果:リストアコストはディスクに保存されたデータの総量ではなく、読み込むシャードの数に比例します。
段階的リストア:必要なものだけを読み込む
シャーディングにより選択的に読み込む能力を得ました。段階的リストア戦略が何を読み込むかを決定します。

Tier 1はクリティカルシャードです。ルートに関係なくすべてのページが必要とするクエリを含みます:認証状態、ユーザー権限、環境設定、フィーチャーフラグ。これらがなければどのページも正しくレンダリングできません。このシャードは意図的に小さく保たれます——普遍的に必要でないものはここに属しません。すべてをクリティカルシャードに入れると、単一ブロブを再発明したことになります。
Tier 2はトップMFU(Most Frequently Used)シャードです。永続化中にワーカーは各シャードが新しいクエリを受信する頻度を追跡し、累積カウントを維持します。リストア時にはランキング上位3シャードを読み込みます。これはヒューリスティックであり保証ではありませんが、使用パターンは大規模アプリケーションでは驚くほど安定しています。ユーザーがどのページに遷移しても、同じデータセットがロード直後にアクセスされる傾向があります。MFUカウントは意図的にルート非依存です:ナビゲーションを予測しようとせず、アプリケーションが実際に最も使用するデータを観察するだけです。
Tier 3はルート対応リストアです。URLパスセグメントからクエリキーへの軽量マッピングを維持しています:
アプリが/org/worksでロードされると、パスセグメントを抽出し、マッピングされたクエリキーを検索し、それらを含むシャード(この場合はシャード2)を計算して読み込みます。ルートのすべてのクエリは同じ予約シャードに共存するため、単一のIndexedDB読み取りでページが必要とするすべてが得られます。これは正確性レイヤーです——MFUヒューリスティックが正しいシャードを見逃した場合でも、LCP要素がデータを取得することを保証します。
リストアされたすべてのクエリはisInvalidated = trueとマークされます。これによりコンポーネントはキャッシュデータを使用して即座にレンダリングし、React Queryがバックグラウンドで静かに最新データを再フェッチします。ユーザーは即座にコンテンツを見て、データは透過的に更新されます。これはアプリケーションレベルのstale-while-revalidateです。
これら3つのティアに含まれないものはすべてディスク上に残ります。ユーザーがプリロードされていないデータのページに遷移した場合、通常のSPAと同様にネットワークからフェッチします。ペナルティも複雑さもなく、標準的なパスです。
ファーストリードキャッシュ:N+1回の読み取りを1回に集約
段階的リストアを導入しても、起動のホットパスで複数のIndexedDB読み取りを行っていました:クリティカルシャードに加え、トップ3のMFUシャード。各読み取りは非同期オペレーションのスケジューリング、structured cloneの実行、ヒープへのアクセスを意味します。個々の読み取りは大きくありませんが、合わせるとクリティカルパスが複数のメインスレッド割り込みに断片化されます。
解決策は、永続化中にマージ済みのファーストリードキャッシュをプリコンピュートすることでした。ワーカーはどのシャードがクリティカルでどれがトップランクかを既に知っています。個々のシャードを書き込んだ後、トップMFUシャードを読み戻し、クリティカルシャードと結合し、マージ結果を単一のIndexedDBキーとして書き込みます。
リロード時のリストアは:1回のIndexedDB読み取り、1回のstructured clone、1回のハイドレートになります。ルート対応シャードはファーストリード後に解決され、必要に応じて追加的に読み込まれます。
バイト数の削減ではありません——データ総量は同じです。最も重要な瞬間におけるメインスレッドの割り込み回数の削減です。
永続化中のわずかに多い作業——Web Worker内で、メインスレッド外で、クリティカルでないタイミングに行われる——と引き換えに、リストア中の劇的に少ない作業を実現しました。リストアはメインスレッド上で、アプリケーションライフサイクルの最もパフォーマンスに敏感な瞬間に発生します。
プロダクションハードニング
上述のアーキテクチャは定常状態の設計です。そこに至るまでには、初期ロールアウト後に数週間のプロダクションハードニングが必要でした。いくつかのパターンが重要でした。
ワーカーでの逐次処理。ワーカーはPromiseキューを通じて永続化メッセージを1つずつ処理します。これがないと、並行する永続化サイクルがそれぞれシャードデータのコピーを同時にメモリに保持し、メモリスパイクを引き起こします。各シャード書き込みの間にワーカーはイベントループにyieldし(setTimeout(0))、ガベージコレクターに前のシャードのデータからメモリを回収する機会を与えます。
クロージャ保持の防止。パーシスターがクリーンアップされる際——例えばログアウトやorg切り替え時——onmessageとonerrorハンドラをterminate()呼び出し前に明示的にnullに設定します。これがないと、パーシスターコンテキストをキャプチャしたクロージャがワーカー参照によって保持され、コンテキストオブジェクトグラフ全体のガベージコレクションが妨げられます。
キャッシュバージョニング。キャッシュバスター文字列がすべての永続化シャードに埋め込まれます。クエリの形状や正規化ロジックに互換性のない変更を加えた場合、バージョンをインクリメントします。リストア時にバスターが一致しなければ、キャッシュ全体が破棄されアプリはフレッシュな状態で起動します。
フィーチャーフラグ制御。すべてのチューナブルパラメータ——シャード数、シャードあたりのクエリ数、最大保持期間、ブロックするMFUシャード数——はフィーチャーフラグで制御されます。これにより段階的ロールアウト、org単位のチューニング、デプロイなしの即時ロールバックが可能になりました。
数値
これらが現在プロダクションで運用しているガードレールです:

これらの数値はマジックではありません。ガードレールです。重要なのは正確な値ではなく、リストアコストが有界で、予測可能で、強制されていること——アプリケーションが時間とともに蓄積したデータ量の関数ではないことです。
成果
プロダクション全体にロールアウト後、モダンマシンで測定、4週間以上安定:
LCP P75がフルページリロード時に7秒から2.5秒未満に低下。改善が安定したのは、リストアコストがキャッシュされたデータ量ではなく設計によって有界だからです。
INP P75が150ms未満に回復——キャッシュ導入前のベースラインに戻りました。最適化前は、キャッシュ書き込みをトリガーするインタラクション(ナビゲーション、ソケットイベントの受信)がstructured cloneシリアライゼーション中にメインスレッドを1〜3秒ブロックしていました。すべての重い処理をWeb Workerに移した後、永続化やリストア中に50msを超えるlong taskはなくなりました。ローダーアニメーションがフリーズしなくなりました。
リストアのメモリフットプリントが約6分の1に削減。選択的リストアがなければ、アプリはリロード時に8,000〜10,000のクエリをメモリにハイドレート——永続化キャッシュ全体を。段階的リストアでは、最悪のケースでも最大1,000〜1,500クエリしか読み込みません:クリティカルシャード、トップ3 MFUシャード、ルート固有のデータ。これは永続化クエリ全体の約6分の1であり、すべてのページでLCPを達成するには十分です。インメモリキャッシュの寿命は5分(React QueryのデフォルトgcTime)、ディスク上のキャッシュの寿命は48時間——独立したライフサイクルと独立したコストです。
これは週末プロジェクトではありません。このアーキテクチャには2人のエンジニアでおよそ3ヶ月かかりました——設計、構築、プロダクションハードニングを含めてエンドツーエンドで。シャーディングロジック、ワーカーメッセージキュー、MFUランキング、ファーストリードキャッシュのプリコンピュテーション——各要素は個別には簡明ですが、システム全体としてはクエリの形状や使用パターンの進化に合わせた継続的なメンテナンスが必要です。このトレードオフは我々のスケールでは価値がありました。小規模なアプリにはReact Queryの組み込み永続化でほぼ確実に十分です。
学んだこと
3ヶ月にわたるこのシステムの構築、破壊、修正の後、心に残ったことを共有します:
最適化する前にLCPを分解せよ。ボトルネックは常に想定した場所にあるとは限りません。我々の場合、レンダリングは高速でした。遅かったのはデータの可用性です。間違ったステージを最適化しても、メトリクスは動かず労力が無駄になります。
非同期I/OはCPUフリーを意味しない。IndexedDBは非同期ですが、structured cloneシリアライゼーションはメインスレッドで実行されます。大きなオブジェクトグラフをpostMessage、IndexedDB、またはstructured cloneを使用する任意のAPIを介して移動するたびに、オブジェクトの複雑さに比例した同期的なCPUコストを支払っています——I/O自体はノンブロッキングであっても。
実装よりもコストモデルが重要。永続化をO(キャッシュサイズ)からO(変更されたクエリ)にシフトしたことがスケールを可能にしました。リストアをO(保存データ総量)からO(このページに必要なデータ)にシフトしたことが高速化を実現しました。具体的な技術——IndexedDB、Web Worker——は交換可能です。コストモデルこそが設計です。
Web Workerは重い計算だけのものではない。我々のワーカーでは計算的にエキゾチックなことは何も行っていません。オブジェクトをマージし、IndexedDBに書き込み、配列をソートするだけです。重要なのは、これらの作業がメインスレッド上で、ユーザーがアプリケーションを操作しようとしているタイミングに発生しないことです。
ストレージ最適化ではなくリストア時の制御のために構築せよ。選択的リストアのないシャーディングは単なる複雑性の追加です。シャーディングの価値は、必要なものだけを読み込み、それ以外をディスクに残す能力にあります。
クライアントはシステムである。システムとして扱え。SSRが実行不可能な場合、クライアントサイドのデータレイヤーのアーキテクチャがパフォーマンスの上限を決定します。キャッシュ、永続化、リストアは後付けではなく、インフラストラクチャです。
DEVREV
See Computer work for you
Your AI teammate that finds answers, takes action, and gets work done across every tool.
Computer+ Apps
Our customers
Resources
Initiatives
