SPA (Single Page Application)
単一ページアプリケーション (SPA, Single-page application) はウェブアプリの実装方法の一種で、単一のウェブ文書のみを読み込み、別な内容を表する際には XMLHttpRequest やフェッチなどの JavaScript API を通じて単一文書の本文の内容を更新するものです。
MDN Web Docs より
単一ページアプリケーション (SPA, Single-page application) はウェブアプリの実装方法の一種で、単一のウェブ文書のみを読み込み、別な内容を表する際には XMLHttpRequest やフェッチなどの JavaScript API を通じて単一文書の本文の内容を更新するものです。
これにより、ユーザーはサーバーから新しいページ全体を読み込まずにウェブサイトを使うことができ、性能の向上やより動的な利用方法が得られるのと引き換えに、 SEO などで不利になったり、状態の保守、操作の改善、意味のある性能の監視のためにより手間がかかったりします。
先に押さえる指標: FCP / LCP / TTI
この後のスイムレーン図には FCP / LCP / TTI のマイルストーンが登場する。それぞれが「ユーザー体験のどの瞬間」を測っているのかを先に整理しておくと、図の中で『なぜここが重要なのか』が腹落ちしやすい。
- FCP First Contentful Paint — 最初の視覚コンテンツ描画
- テキスト・画像・SVG・非空の Canvas のような「視覚的なコンテンツ」がブラウザ画面に最初に描画された時点。背景色のみの画面は含まれない。Core Web Vitals の補助指標で、1.8 秒以内が良好とされる。
- LCP Largest Contentful Paint — 最大コンテンツ描画
- ページ内で最大のコンテンツ要素 (ヒーロー画像・主要見出し等) が描画された時点。「意味のある初回描画」の代理指標で、Core Web Vitals の主指標。2.5 秒以内が良好、4 秒以上は要改善。
- TTI Time to Interactive — 操作可能になった時刻
- ページが完全にインタラクティブ (クリックや入力に反応できる) になった時点。全てのイベントリスナーが付与され、main thread が 5 秒間 Long Task を出さなくなるタイミング。2024 年に Core Web Vitals 主指標から外れ INP に置き換わったが、実ユーザーの「使えるようになる時刻」として依然重要。
レンダリングライフサイクル
SPA が URL 入力から操作可能になるまでの 22 フェーズを 8 レーンに分解。JS Engine で React が VDOM を commit した後、Browser (Style/Layout) → Compositor (Raster) → GPU (フレームバッファ) → Display (vsync でピクセル表示) という実レンダリングパイプラインが流れる。FCP と LCP と TTI の物理的な違いがここで見える。
-
01 URL 入力 / リンククリック
ユーザーが URL をアドレスバーに入力する、もしくは既存ページのリンクをクリック。ここから全てのフェーズが始まる。
-
02 HTTP リクエスト発出
ブラウザがナビゲーションを検知し、宛先への HTTP リクエストを準備。Cookie / Cache / Service Worker などの介在があればここで判定される。
-
03 DNS / TCP / TLS
ネットワーク層で DNS 解決 → TCP 接続 → TLS ハンドシェイクが順次走る。近年の Web パフォーマンスで無視できない時間帯で、HTTP/3 / 0-RTT で短縮可能。
-
04 空 HTML シェルを返却
サーバー (通常は静的ホスティング) がほぼ空の HTML を返す。中身は
<div id="app"></div>のようなルート要素と、JavaScript バンドルへの<script>参照だけ。 -
05 HTML パース → DOM (ルートのみ)
ブラウザが HTML をパースして DOM を構築。ただしアプリのルート要素しか無いので、DOM 自体はほぼ空。
-
06 CSS パース → CSSOM
リンクされた CSS をパースして CSSOM を構築。DOM と合成するとレンダーツリーができる — ただしこの時点で実コンテンツは無い。
-
07 Style / Layout / Paint-record
メインスレッドでスタイル計算 → レイアウト → Paint-record (描画コマンドのリスト生成)。描くものが空のルート要素だけなので、生成される描画コマンドもほぼ空。この時点ではまだピクセル化されていない。
-
08 Raster + レイヤー合成 (空タイル)
コンポジタースレッド (Chrome の Compositor thread は Main thread と独立) が Paint-record をタイルに分割してラスタライズ。レイヤー情報に基づいて合成コマンドを組み立てる。
-
09 GPU コマンド実行 → フレームバッファ
GPU プロセス (Chrome では別プロセス) が合成コマンドを実行し、フレームバッファに書き込む。ハードウェアアクセラレーションによって、何万タイルでもミリ秒オーダーで処理される。
-
10 最初のフレームを画面に表示
モニタのリフレッシュ (60 Hz なら 16.67 ms 毎の vsync) に合わせてフレームバッファが画面に出力される。ここでようやく「ピクセルがユーザーの目に届く」。SPA ではここに空画面しかない。Core Web Vitals の FCP は厳密にはこのタイミング (paint-record 完了時点ではない)。
- FCP 到達 — ただし画面は空
-
11 JS バンドル要求
HTML パース中に検出した
<script>要素に従って、JS バンドルを並行取得開始。取得するのはまだ「ファイル」で、コードはまだ動いていない。 -
12 JS バンドル転送
ネットワーク経由でバンドル (React / Vue / Angular 等 + アプリコード) を転送。サイズが大きいほど所要時間が伸び、モバイル 4G では 500 KB 超で数秒のスタールが起きやすい。
-
13 JS パース / コンパイル
V8 / SpiderMonkey などの JS エンジンがソースをパースしてバイトコードへコンパイル。大きいバンドルほどメインスレッドが止まる時間 (Long Task) が長引き、ミッドレンジスマホなら 1 MB ≒ 200 ms のパース時間が目安。
-
14 フレームワーク起動 → VDOM 生成
React の createRoot / render() などが呼ばれ、コンポーネント関数を実行して仮想 DOM を構築する。この段階ではまだ実 DOM は変わっていない。
-
15 DOM commit (VDOM → 実 DOM)
フレームワークが仮想 DOM を実 DOM に反映。createElement / appendChild 等の DOM API を JS Engine からブラウザに対して呼び出す。この瞬間、次のレンダリングサイクルの引き金を引く。
-
16 Style 再計算 + Layout (コンテンツ込み)
DOM が変わったのでブラウザが再度スタイル計算 → レイアウト → Paint-record を実行。今回は実コンテンツが入っているので、生成される描画コマンドも豊富。
-
17 再ラスタ + レイヤー再合成
コンポジターがタイルをラスタライズし直し、新しいレイヤーツリーを組み立てる。Chrome RenderingNG では変更があったタイルだけ再ラスタする最適化がかかる。
-
18 フレームバッファ更新
GPU がコンポジターのコマンドを実行し、新しいフレームを生成。
transform/opacityだけの変更なら Layout / Paint-record をスキップして GPU の合成だけで済むため高速。 -
19 コンテンツが画面に出る
vsync に同期してフレームが出力される。ここでユーザーは初めて実コンテンツを見る。web.dev の Web Vitals 定義によれば LCP (Largest Contentful Paint) は「ビューポート内に表示される最大の画像・テキスト・動画のレンダー時刻」を測る指標。SPA は最初にほぼ空のシェル HTML を返し、JS バンドル実行後に実コンテンツを描画する構造のため、FCP (最初の描画) と LCP (実コンテンツの描画) の間に時間差が生じる。
- LCP 付近 — 実コンテンツが表示される
-
20 イベントリスナー付与 → TTI
フレームワークが各コンポーネントにクリック / 入力のリスナーを付与し、useState / ストアを初期化。Main thread がアイドルになりクリックに反応可能に。Time to Interactive (TTI) はこの時点。
- TTI 到達 — ここからページが「使える」
-
21 以降: クライアント内ナビゲーション
リンククリックや操作は History API (pushState) で URL を書き換え、JavaScript が該当コンポーネントをレンダリング。以降のネットワーク往復は API データの取得 (JSON) だけ。これが SPA の「速い」印象の核心。
-
22 VDOM diff → DOM patch (繰り返し)
状態が変わるたびにフレームワークが新旧の VDOM を比較し、変更ノードだけを実 DOM にパッチ。その後 Browser → Compositor → GPU → Display のパイプラインが再び走るが、ネットワーク往復は一切無い。これが SPA 最大の強み。
よくある疑問
初めて SPA を学ぶ人が実際に詰まるポイントと、その根本原因。
Q. なぜ「CSR-only な SPA は SEO に弱い」と言われる?
Q. 「FCP は速いのに LCP が遅い」のはなぜ?
Q. URL を直接叩いたり、リロードすると 404 になるのはなぜ?
index.html しか用意されていないから。SPA のルーティングは JavaScript が担当しているため、初回リクエストを必ず index.html に返す「SPA フォールバック」設定が必要。Vercel / Netlify は自動対応、nginx は try_files $uri $uri/ /index.html;、Apache は FallbackResource /index.html で設定する。Q. 戻るボタンが効かなくなる / スクロール位置が狂うのはなぜ?
pushState / replaceState / popstate) のうち popstate を拾っていないパターンが多い。popstate は戻る・進むで発火するイベントで、これを購読してルーティング処理を再実行しないと画面が更新されない。スクロール位置は history.scrollRestoration = 'manual' にしてルーティングライブラリで明示管理するのが定石。React Router / Vue Router / SvelteKit はこれを内部で実装している。Q. 「ハイドレーション」って SPA にも関係ある?
export して SSG として配信している場合、実際にはハイドレーションが走っているのでログを見ると hydrateRoot が出てくる。Q. アクセシビリティが壊れやすいと聞いたけど?
<h1> か <main> にフォーカスを移動、(b) document.title を更新、(c) aria-live="polite" 領域でスクリーンリーダーに変更を通知。React Router v6+ や Remix はこの辺りのヘルパーを提供している。設計判断のサイン
どういう症状が出たら SPA 以外を検討すべきか、逆にどういうプロダクトなら SPA で押し通して良いか。
SEO 流入が主要な導線になってきた
マーケティングからの要求でランディングページやブログセクションを増やし始めたら、そこだけ SSR or SSG にする「ハイブリッド構成」を検討。Next.js / Nuxt はページ単位でレンダリングモードを変えられる。
初回ロードの FCP / LCP が常に 2.5 秒超
Core Web Vitals の LCP が Yellow / Red に落ちる。バンドル最適化で改善余地が無ければ、クリティカルコンテンツだけサーバー側で返す SSR / SSG にフォールバック。モバイル回線がターゲットの場合は特に重要。
ログイン後のダッシュボード / 管理画面が主戦場
SEO 不要、クローラー対策不要、複雑な状態管理と豊富な相互作用が中心。こうしたユースケースはむしろ SPA の独擅場なので、SSR に逃げるメリットは薄い。
バックエンドを既存の API サーバーと共有する設計
モバイルアプリや他のクライアントと同じ API を使う前提なら、フロントエンドも純粋な API 消費者にする方がシンプル。SSR を入れると「フロントエンドサーバー + API サーバー」で二層構造になり、デプロイと監視が複雑になる。
よくある落とし穴
バンドルサイズの暴走
ライブラリを気軽に追加していくと 1 MB・2 MB と肥大化し、モバイル回線で数秒スタールする。対策: コード分割 (React.lazy / dynamic import)、Bundle Analyzer で依存を可視化、ツリーシェイキング、moment → dayjs のような軽量代替への置き換え。
メモリリークの蓄積
長時間同じセッションで使われる SPA では、イベントリスナーやタイマー、WebSocket のクリーンアップ漏れが積み重なってメモリが 500 MB・1 GB と膨らむ。対策: useEffect のクリーンアップ関数で必ず解除、AbortController で Fetch を中止、Chrome DevTools の Memory タブで定期的にプロファイル。
OGP / SNS シェアプレビューが壊れる
<meta property="og:title"> を JavaScript で後から書き換えても、SNS のクローラーは JS を実行しないので初期 HTML の meta しか読まない。動的メタタグが必要なら SSR / プリレンダリングで HTML に埋め込むか、各ページ専用の静的 HTML を用意する。
認証トークンの保存場所を間違える
SPA では認証情報をブラウザに保持するため、localStorage に JWT を入れる実装をよく見るが、XSS で一発で盗まれる。対策: HTTP-only cookie + SameSite=Strict を使い、JavaScript から読めなくする。SPA でもこの形は取れる (ログイン API 側でセットすれば OK)。