メインスレッド税:IndexedDB vs. LCP(パート1)

6 min read

メインスレッド税:IndexedDB vs. LCP(パート1)

パート1(全2回):クライアントサイドの永続化が、アプリを救う前に壊してしまった話。

Largest Contentful Paint(LCP)が7秒もかかるのは、どんなプロダクトにとっても致命的です。クライアントサイドキャッシュは定番の解決策で、APIデータをIndexedDBに永続化し、リロード時に復元してネットワークのラウンドトリップを省略します。あらゆるパフォーマンスガイドが推奨するアプローチです。

私たちはこれを実装しました — そしてインタラクションのレイテンシを破壊してしまいました。クリックが反応しない。テキスト入力が遅延する。ユーザーから「アプリが壊れている」と言われ、実際その通りでした。

根本原因は明白ではありませんでした。本番環境で数百のキャッシュされたクエリがある状態では、キャッシュの書き込みを伴うインタラクションでINPが3秒まで跳ね上がりました — 最新のマシンでもです。外的要因のせいにはできません。問題は完全に自ら招いたものでした。メインスレッド上でのstructured cloneシリアライゼーションが、ブラウザがIndexedDB用のデータを準備している間、ユーザー入力をブロックしていたのです。

LCPについて簡単に説明します。Largest Contentful Paintは、ページ上で最も大きな可視要素 — 通常はヒーロー画像、データテーブル、主要コンテンツブロック — がレンダリングされるまでの時間を測定します。これはCore Web Vitalsの一つで、2.5秒を超えると「良好」とは見なされません。私たちの場合、LCP要素は通常データ駆動型でした。つまり、APIデータが到着するまで表示できないダッシュボードウィジェットやカンバセーションリストです。

この全2回のシリーズでは、何がうまくいかなかったのか、なぜ明白な解決策が機能しなかったのか、そしてLCPのP75を7秒から2.5秒未満に改善したアーキテクチャについてお伝えします — SSRなし、INPを悪化させることなく、メモリを肥大化させることもなく。

このパート1では、問題について解説します。実際に何が遅かったのか、なぜ最初のキャッシュの試みがかえって状況を悪化させたのか、そしてなぜReact Queryの組み込み永続化オプションが適合しなかったのか。パート2では、最終的に機能したアーキテクチャとその成果について詳しく説明します。

アプリケーション:大規模な認証済みデータ

この問題がなぜ難しかったのかを理解するには、私たちが扱っていたアプリケーションの種類を知る必要があります。シンプルなアプリで機能するパフォーマンス戦略は、この規模ではまったく異なる振る舞いをします。

DevRevはマーケティングサイトやシンプルなCRUDアプリではありません。CRMワークフロー、課題管理、ダッシュボード、アナリティクス、リアルタイムカンバセーションを統合した大規模なプロダクトです。ほぼすべての画面がデータ駆動型であり、さらに重要なのは、表示される内容がユーザーの属性、所属組織、権限に依存することです。

任意の時点で、React Queryのキャッシュには数百のアクティブなクエリが保持されています。リストAPIが50件のアイテムを返すと、レスポンスを各アイテムの個別オブジェクトレベルのクエリに正規化します。つまり、1回のネットワークリクエストで数十のキャッシュエントリが生成されるのです。これはビュー間の整合性には有用ですが、クエリグラフが大規模かつ深く相互接続されることを意味します。

ユーザーは頻繁にページを新しいタブで開きます。つまり、クライアントサイドのルート遷移ではなくフルページリロードが発生します。新しいタブを開くたびに、完全な起動シーケンス(ダウンロード、パース、実行、ブートストラップ、データフェッチ、レンダリング)が走ります。SPA内セッションで機能するパフォーマンス戦略 — プリフェッチ、ルートレベルのコード分割、stale-while-revalidate — は、アプリをリロードしたり新しいタブで開いたりする場合には効果がありません。

LCPの分解:真のボトルネックを見つける

何かを最適化する前に、クライアントレンダリングのSPAにおいてLCPに何が実際に寄与しているのかを分解する時間を取りました。

c5d574334e020b07223d867b509cba14fe23211a-1472x188.png

JavaScriptのダウンロードは通常高速でした — アセットはブラウザにキャッシュされていたためです。JavaScriptの実行とアプリのブートストラップも重要でしたが、最大のボトルネックではありませんでした。

非常に明確だったのはデータフェッチでした。ほとんどのページで最大のコンテンツ要素は静的マークアップではなく、複数のAPIクエリが完了するまでレンダリングできないデータ駆動型コンポーネントでした。レンダリングが瞬時であっても、データが到着するまでLCP要素を描画できなかったのです。

これは私たちにとってマインドセットの転換でした。レンダリングの問題ではなく、データの可用性の問題だったのです。

SSRが答えではなかった理由:パーソナライズされた権限付きデータ

「SSRを使えばいい」と思われるかもしれません — わかります。SSRはレンダリング結果をキャッシュできる場合に強力なLCPの改善手段です。マーケティングページや商品一覧なら、SSRとCDNの組み合わせでプリレンダリングされたHTMLをミリ秒単位で配信できます。

私たちの状況は異なっていました。ほぼすべてのデータが認証され、権限で制御されています。表示される内容はユーザーのID、組織のメンバーシップ、アクセスレベルに依存します。データは頻繁に変更されます — 通知フィードやアクティビティタイムラインを想像してください。異なるユーザーに同じプリレンダリングページを提供することはできず、サーバーレンダリングされた結果を意味のある期間キャッシュするにはデータの変更が速すぎます。

より根本的に言えば、私たちのLCP要素はマークアップではなくデータに依存していました。サーバー上でHTMLを早期にレンダリングしても、コンポーネントが必要とするデータがハイドレーション後にユーザーごとのAPIコールを必要とするなら意味がありません。

念のため言っておくと、これはSSR否定の記事ではありません。私たちの特定の制約 — 認証済み、権限付き、パーソナライズされた、頻繁に変更されるデータ — においては、クライアント上のデータの可用性が支配的なボトルネックであり、マークアップの生成ではなかったということです。状況によって結果は異なるでしょう。

ナイーブな永続化:structured cloneがINPを壊した理由

データの可用性がボトルネックだと特定した後、次のステップは明白に思えました。データフェッチがLCPを遅延させるなら、クライアントにデータをキャッシュすればいい。リロード時に復元して、バックグラウンドで新しいデータをロードしている間にページを即座に描画できるようにする。

私たちはアプリケーション全体でReact Queryを使用していたため、標準的なアプローチから始めました。クエリキャッシュをデハイドレートし、IndexedDBに書き込み、ページロード時に復元してReact Queryにハイドレートする。ページはキャッシュされたデータで即座にレンダリングされ、それをstaleとマークし、バックグラウンドで新しいデータをリフェッチします。

期待した結果:リロードの高速化、ノンブロッキングなデータフェッチ、LCPの改善。

実際に起こったこと:メインスレッドのジャンク、復元時のロングタスク、深刻なINPの悪化。

少数のクエリでの開発中はうまく機能していました。数百のキャッシュされたクエリと深くネストされたデータ構造を持つ本番規模でロールアウトした途端に壊れました。

キャッシュデータの復元は目に見えるジャンクを引き起こしました。クリックが登録されない。テキスト入力が遅延する。ネットワークからデータをロードするのを待つよりも悪い形でアプリが壊れて感じられました。

なぜこうなったのかを理解するために、内部で実際に何が起きていたのかを調べる必要がありました。

0f7d03a940ef1967548652bfcd3ad1816b3b0f31-1952x374.png

IndexedDBの操作は非同期です。しかし「非同期」というのはI/O — 実際のディスクの読み書き — についてのみ当てはまります。データがIndexedDBに到達する前に、ブラウザはstructured cloneアルゴリズムを使ってシリアライズする必要があります。そして復元されたデータが使用可能なJavaScriptオブジェクトになる前に、ブラウザはデシリアライズする必要があります。これらの操作は両方ともメインスレッドで実行されます。

小さなオブジェクトでは無視できるレベルです。しかし、深くネストされた正規化データ構造を持つ数百のクエリを含むキャッシュでは、structured cloneがロングタスクの支配的な原因になります。私たちの規模では、フルクエリキャッシュのシリアライズとデシリアライズは、DevToolsのPerformanceパネルで測定して1〜3秒のロングタスクを生成しました — メインスレッドが明確にブロックされており、これはまさにINPが測定する対象です。画面上のローダーアニメーションは、structured cloneの実行中にメインスレッドがrequestAnimationFrameコールバックを実行できないため、フレームの途中でフリーズしていました。

問題は永続化というアイデア自体が悪いということではありませんでした。コストモデルの根本的な何かがスケールしなかったのです。キャッシュ全体を一度に永続化し、すべてのシリアライゼーション作業をメインスレッドで行う — このアプローチのコストはキャッシュサイズに比例して線形に増大します。そして大規模なアプリケーションでは、キャッシュサイズは制御できるものではありません。

既製品の評価:React Queryが不十分だった点

独自のソリューションを構築する前に、React Queryの公式永続化オプションを評価しました。

persistQueryClient(標準プラグイン)はキャッシュ全体を単一のシリアライズされたblobにデハイドレートし、オール・オア・ナッシングの操作として復元します。これは先ほど遭遇したstructured cloneのコスト問題と同じです。さらに、gcTimeと永続化期間が結合されています — ドキュメントにはgcTimemaxAge「と同じかそれ以上の値に設定する」と明記されています。クエリをディスク上で24時間保持したい場合、メモリ上でも24時間保持する必要があります。数百のクエリを持つアプリでは、これは大きなメモリコストになります。

experimental_createPersister(より新しいクエリごとのアプローチ)は私たちのニーズに近いものでした。各クエリを個別に保存し、queryFnをラップしてネットワークリクエスト前にストレージをチェックします。しかし、useQueryのライフサイクルとの根本的な結合が私たちには適合しませんでした。

persisterはqueryFnをラップするため、コンポーネントがuseQueryでマウントされたときにのみ実行されます。queryClient.setQueryData()を使って命令的にキャッシュを更新する場合 — リストレスポンスを個別オブジェクトクエリに正規化するために私たちが頻繁に行っていること — persisterはそれらのエントリを認識しません。保存されないのです。

逆方向でも同じ制限があります。queryClient.getQueryData()でデータを読み取る場合、復元は行われません。永続化されたqueryFnラッパーが実行されなかったためです。プリフェッチ、派生計算、イベントハンドラ内での命令的読み取りなど、私たちのパターンの多くはuseQueryフックを完全にバイパスしています。

復元順序の問題もあります。ページロード時に200以上のクエリが同時にマウントされると、それぞれが独立してストレージから読み取ります。200以上の個別IndexedDB読み取りが発生し、それぞれがメインスレッド上で独自のstructured cloneをトリガーします。バッチ処理もなく、どのクエリを優先的にロードするかの制御もありません。パート2で説明しますが、これらの個別のメインスレッドへの割り込みは蓄積されます — 小さな読み取りでもクリティカルパスを断片化します。

私たちが必要としていたのは、キャッシュ内のすべて(setQueryDataエントリを含む)をキャプチャし、フルblobではなくインクリメンタルに永続化し、オール・オア・ナッシングではなく選択的に復元し、メモリ上の生存期間とディスク上の生存期間を完全に分離するものでした。メモリ上は5分のgcTimeでヒープ使用量を低く保ちつつ、ディスク上は48時間の永続化でリロードに耐える。

アーキテクチャの概要:Web Workerと選択的復元

既存のオプションはいずれも私たちの制約に適合しませんでした。そこで、React Queryを中心に3つのコア設計原則を持つカスタム永続化レイヤーを構築しました:

  1. すべての重い処理をメインスレッドから分離。マージ、シャーディング、ランキング、IndexedDBへの書き込みはすべてWeb Worker内で行われます。
  2. インクリメンタルに永続化。変更されたクエリのみがWorkerに送信されます。コストはキャッシュサイズではなくユーザーのアクティビティに比例します。
  3. 選択的に復元。リロード時には、キャッシュ全体ではなく現在のページが必要とするもののみをロードします。

アーキテクチャの全体像は以下の通りです:

ef6341b696199f64e2162f5eba3aa0e09cb622d1-1200x800.png

React Queryのキャッシュはそのまま元の場所にあります — ランタイム時のメモリ上の信頼できるソースであることに変わりありません。React Queryを置き換えたのではなく、その周りに永続化レイヤーを構築したのです。

キャッシュ全体をメインスレッドでシリアライズする代わりに、前回の永続化以降に変更されたクエリのみをデハイドレートし、postMessageでWeb Workerに送信し、Workerにすべての重い処理を任せます。既存のシャードデータとのマージ、シャードへの分配、使用頻度によるランキング、古いエントリのフィルタリング、IndexedDBへの書き込みです。

リロード時には、すべてを復元するのではありません。IndexedDBから事前計算された単一のファーストリードキャッシュを読み取り、オプションでルート固有のシャードをロードし、クエリをReact Queryにハイドレートし、すべてを無効としてマークしてバックグラウンドでリフェッチさせます。

永続化時のメインスレッドの仕事は軽量です:デハイドレートと差分計算。復元時のメインスレッドの仕事も軽量です:1回のIndexedDB読み取りと1回のハイドレート呼び出し。すべてのコストの高い処理はWorkerで行われます。

パート2では、このアーキテクチャの各コンポーネントを詳しく解説します。インクリメンタルな差分計算、シャーディング戦略、階層型復元メカニズム、ファーストリードキャッシュの最適化 — そして本番環境でのガードレールと成果について。

DEVREV

See Computer work for you

Your AI teammate that finds answers, takes action, and gets work done across every tool.