ストリーミングSSR

ストリーミングSSRは、重要なコンテンツを即座に表示しながら、時間のかかるデータを後から段階的に送信する高度な技術です。これにより、初回表示の高速化とユーザー体験の向上を両立できます。

ストリーミングSSRとは

ストリーミングSSRは、従来のSSRが全てのデータが揃うまで待機するのに対し、段階的にコンテンツを配信する技術です。

従来のSSRとの違い

従来のSSR:すべてのデータを待つ必要がある

// ❌ Traditional SSR: Wait for all data
export const load: PageServerLoad = async () => {
  // Screen is blank until everything is ready
  const criticalData = await getCriticalData();  // 100ms
  const slowData = await getSlowData();          // 3000ms
  return { criticalData, slowData };
  // Total: 3100ms until page displays
};
typescript

ストリーミングSSR:段階的にコンテンツを表示

// ✅ Streaming SSR: Progressive display
export const load: PageServerLoad = async () => {
  const criticalData = await getCriticalData();  // 100ms

  return {
    critical: criticalData,  // Render immediately
    streamed: {
      slow: getSlowData()  // Return Promise (no await)
    }
  };
  // Basic content shows after 100ms,
  // Full content shows after 3000ms
};
typescript

効果: 初期表示が100msで開始(3100ms → 100ms)、体感速度が大幅に向上

ストリーミングSSRの仕組み

ダイアグラムを読み込み中...

SvelteKitでのストリーミングSSR動作

ダイアグラムを読み込み中...

アーキテクチャの概要

ダイアグラムを読み込み中...

基本的な実装

サーバーサイドの実装

Load関数からPromiseを直接返すことで、ストリーミングSSRを実現します。

データの分類と返却方法

  • クリティカルデータ: awaitで待機して即座に表示(タイトル、価格、在庫状況など)
  • ストリーミングデータ: Promiseのまま返して後から表示(レビュー、関連商品など)
// +page.server.ts
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async ({ fetch }) => {
  // クリティカルデータ:awaitで待機して即座に表示
  const criticalData = await fetch('/api/critical').then(r => r.json());

  return {
    // 初期HTMLに含めて即座にレンダリング
    critical: criticalData,

    // ストリーミングデータ:Promiseのまま返す
    streamed: {
      slow: fetch('/api/slow').then(r => r.json()),        // レビュー、詳細情報
      optional: fetch('/api/optional').then(r => r.json()) // 関連商品
    }
  };
};
typescript

コンポーネントでの表示

Svelteの{#await}ブロックを使用して、ストリーミングデータを段階的に表示します。

表示の3つの状態

  1. 即座表示: クリティカルデータ(既に解決済み)
  2. ローディング中: Promiseが解決されるまで
  3. 完全表示: すべてのデータが揃った状態
<!-- +page.svelte -->
<script lang="ts">
  import type { PageData } from './$types';

  // Load関数からデータを受け取る
  let { data }: { data: PageData } = $props();
</script>

<!-- クリティカルデータ:即座に表示 -->
<header>
  <h1>{data.critical.title}</h1>
  <p class="subtitle">{data.critical.subtitle}</p>
</header>

<!-- ストリーミングデータ:準備でき次第表示 -->
<main>
  {#await data.streamed.slow}
    <!-- ローディング状態:スケルトンスクリーン表示 -->
    <div class="loading">
      <div class="skeleton">
        <div class="skeleton-line"></div>
        <div class="skeleton-line"></div>
        <div class="skeleton-line short"></div>
      </div>
    </div>
  {:then slowData}
    <!-- データ読み込み完了:コンテンツ表示 -->
    <article>
      <p>{slowData.content}</p>
      <ul>
        {#each slowData.items as item}
          <li>{item.name}: {item.value}</li>
        {/each}
      </ul>
    </article>
  {:catch error}
    <!-- エラー時のフォールバック:エラーメッセージ表示 -->
    <div class="error">
      <p>コンテンツの読み込みに失敗しました</p>
      <button onclick={() => location.reload()}>再試行</button>
    </div>
  {/await}
</main>

<!-- サイドバー:オプショナルデータ -->
<aside>
  <h2>関連情報</h2>
  {#await data.streamed.optional}
    <!-- シンプルなローディングテキスト(非クリティカル) -->
    <p class="loading-text">読み込み中...</p>
  {:then optionalData}
    <!-- オプショナルコンテンツを表示 -->
    <div class="related">
      {#each optionalData.items as item}
        <a href={item.url}>{item.title}</a>
      {/each}
    </div>
  {:catch error}
    <!-- 優雅なエラー処理(非クリティカル) -->
    <p class="muted">追加情報は現在利用できません</p>
  {/await}
</aside>

<style>
  .skeleton {
    background: #f0f0f0;
    border-radius: 4px;
    padding: 1rem;
  }

  .skeleton-line {
    height: 1em;
    background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
    background-size: 200% 100%;
    animation: loading 1.5s infinite;
    margin-bottom: 0.5rem;
    border-radius: 2px;
  }

  .skeleton-line.short {
    width: 60%;
  }

  @keyframes loading {
    0% { background-position: 200% 0; }
    100% { background-position: -200% 0; }
  }

  .error {
    background: #fee;
    border: 1px solid #fcc;
    padding: 1rem;
    border-radius: 4px;
  }
</style>
svelte

実践的な使用例

ECサイトの商品ページ

商品の基本情報は即座に表示し、レビューや関連商品は後から読み込みます。

// +page.server.ts
export const load: PageServerLoad = async ({ params, fetch }) => {
  // 商品の基本情報(必須・高速)
  const productInfo = await fetch(`/api/products/${params.id}`)
    .then(r => r.json());

  // 在庫情報(購入判断に重要)
  const stockStatus = await fetch(`/api/products/${params.id}/inventory`)
    .then(r => r.json());

  return {
    // 即座に表示(SEOのためSSR)
    product: productInfo,
    inventory: stockStatus,

    // ストリーミングデータ(並列取得)
    streamed: {
      reviews: fetch(`/api/products/${params.id}/reviews`).then(r => r.json()),     // ユーザーレビュー
      related: fetch(`/api/products/${params.id}/related`).then(r => r.json()),     // 関連商品
      analytics: fetch(`/api/products/${params.id}/analytics`).then(r => r.json())  // 閲覧履歴
    }
  };
};
typescript
<!-- +page.svelte -->
<script lang="ts">
  import type { PageData } from './$types';
  let { data }: { data: PageData } = $props();
</script>

<!-- 商品情報:即座に表示 -->
<section class="product-info">
  <h1>{data.product.name}</h1>
  <p class="price">¥{data.product.price.toLocaleString()}</p>

  {#if data.inventory.inStock}
    <!-- 在庫あり:購入ボタンを有効化 -->
    <button class="buy-button">カートに追加</button>
  {:else}
    <!-- 在庫切れ:ボタンを無効化 -->
    <button disabled>在庫切れ</button>
  {/if}

  <div class="description">
    {data.product.description}
  </div>
</section>

<!-- レビュー:ストリーミング表示 -->
<section class="reviews">
  <h2>カスタマーレビュー</h2>
  {#await data.streamed.reviews}
    <!-- ローディングスケルトン(レイアウトシフト防止) -->
    <div class="review-skeleton">
      {#each Array(3) as _}
        <div class="skeleton-review">
          <div class="skeleton-rating"></div>
          <div class="skeleton-text"></div>
        </div>
      {/each}
    </div>
  {:then reviewData}
    <!-- レビューデータ表示 -->
    {#each reviewData.items as review}
      <article class="review">
        <div class="rating">{review.rating}/5</div>
        <h3>{review.title}</h3>
        <p>{review.comment}</p>
        <small>by {review.author}</small>
      </article>
    {/each}
  {:catch}
    <!-- 非クリティカルなエラーフォールバック -->
    <p>レビューを読み込めませんでした</p>
  {/await}
</section>

<!-- 関連商品:段階的表示 -->
<section class="related-products">
  <h2>関連商品</h2>
  {#await data.streamed.related}
    <!-- シンプルなローディング(低優先度) -->
    <div class="products-loading">関連商品を読み込み中...</div>
  {:then relatedItems}
    <!-- 商品グリッド -->
    <div class="product-grid">
      {#each relatedItems.items as item}
        <a href="/products/{item.id}" class="product-card">
          <img src={item.image} alt={item.name} />
          <h3>{item.name}</h3>
          <p>¥{item.price.toLocaleString()}</p>
        </a>
      {/each}
    </div>
  {/await}
  <!-- catchブロックなし(オプショナルコンテンツ) -->
</section>
svelte

ダッシュボード画面

ダッシュボードでは、重要なKPI(売上、ユーザー数など)を即座に表示し、詳細なグラフやデータは段階的に読み込みます。これにより、ユーザーは最も重要な情報をすぐに確認でき、詳細データが読み込まれるのを待つ必要がありません。

// +page.server.ts
export const load: PageServerLoad = async ({ locals, fetch }) => {
  // 認証チェック
  if (!locals.user) {
    throw redirect(302, '/login');
  }

  // 重要なKPI指標(高速・数値データ)
  const keyMetrics = await fetch('/api/dashboard/kpi').then(r => r.json());

  return {
    // 即座に表示
    kpi: keyMetrics,
    user: locals.user,

    // 段階的に読み込み
    streamed: {
      charts: fetch('/api/dashboard/charts').then(r => r.json()),           // グラフデータ(重い)
      recentActivity: fetch('/api/dashboard/activity').then(r => r.json()), // 活動履歴
      notifications: fetch('/api/dashboard/notifications').then(r => r.json()) // 通知一覧
    }
  };
};
typescript

高度な実装パターン

複数の段階的読み込み

データの重要度と処理時間に応じて、3段階の読み込みパターンを実装できます。第1段階は100ms以内の最重要データ、第2段階は500ms以内の重要データ、第3段階は時間がかかってもよい補足データという構成です。

// +page.server.ts
export const load: PageServerLoad = async () => {
  // 第1段階:クリティカルデータ(100ms以内)
  const criticalContent = await getCriticalData();

  // 第2段階:重要データ(500ms以内)
  const importantDataPromise = new Promise(async (resolve) => {
    const importantInfo = await getImportantData();
    resolve(importantInfo);
  });

  // 第3段階:補足データ(時間制限なし)
  const supplementaryDataPromise = getSupplementaryData();

  return {
    critical: criticalContent,
    streamed: {
      important: importantDataPromise,
      supplementary: supplementaryDataPromise
    }
  };
};
typescript

エラーハンドリングの強化

ストリーミングデータでエラーが発生しても、ページ全体の動作に影響しないようにするためのパターンです。エラー時にはフォールバックデータを提供し、UIが壊れないようにします。

// +page.server.ts
export const load: PageServerLoad = async () => {
  // 必須データ
  const criticalData = await getCriticalData();

  return {
    critical: criticalData,
    streamed: {
      // Promiseチェーンでエラーハンドリング
      slowData: fetch('/api/slow')
        .then(r => r.json())
        .catch(error => {
          // エラー時のフォールバックデータ
          console.error('Slow data failed:', error);
          return {
            error: true,        // エラーフラグ
            fallback: true,     // フォールバック指標
            message: 'データの取得に失敗しました',
            items: []          // 空配列(UIのクラッシュ防止)
          };
        })
    }
  };
};
typescript
<!-- エラーハンドリングの表示 -->
{#await data.streamed.slowData}
  <div class="loading">読み込み中...</div>
{:then resultData}
  {#if resultData.error}
    <!-- エラー時のフォールバックUI -->
    <div class="error-fallback">
      <p>{resultData.message}</p>
      <button onclick={() => location.reload()}>再試行</button>
    </div>
  {:else}
    <!-- 正常データの表示 -->
    <div>{resultData.content}</div>
  {/if}
{/await}
svelte

条件付きストリーミング

ユーザーの認証状態や権限レベルに応じて、動的にストリーミングするデータを決定するパターンです。プレミアムユーザーには追加のデータを提供するなど、柔軟な対応が可能です。

// +page.server.ts
export const load: PageServerLoad = async ({ locals }) => {
  // すべてのユーザー向け基本データ
  const baseData = await getBasicData();

  // 動的なストリーミングデータ
  const streamedContent: any = {
    publicData: getPublicData()  // 公開データ(全員)
  };

  // 認証済みユーザーには追加データ
  if (locals.user) {
    // パーソナライズされたコンテンツ
    streamedContent.personalData = getPersonalData(locals.user.id);

    // プレミアムユーザーにはプレミアム機能
    if (locals.user.isPremium) {
      // 追加のプレミアムデータ
      streamedContent.premiumData = getPremiumData(locals.user.id);
    }
  }

  return {
    basic: basicData,
    streamed  // ユーザータイプに応じたデータが含まれる
  };
};
typescript

データフローの詳細

ダイアグラムを読み込み中...

パフォーマンス最適化

適切なデータ分割

ユーザーが最初に見る画面上部のコンテンツ(Above the Fold)を優先的に読み込み、スクロールが必要な部分は後から読み込む戦略です。

// ✅ 良い例:適切なデータ分割
export const load: PageServerLoad = async () => {
  return {
    // Above the foldコンテンツ(可視領域)
    critical: await getFoldData(),

    // Below the foldコンテンツ(遅延読み込み)
    streamed: {
      belowFold: getBelowFoldData(),       // スクロール後のコンテンツ
      analytics: getAnalyticsData(),       // バックグラウンド分析
      recommendations: getRecommendations() // 低優先度
    }
  };
};

// ❌ 悪い例:優先度を考慮していない
export const load: PageServerLoad = async () => {
  return {
    critical: await getRandomData1(),  // 重要度が不明
    streamed: {
      random: getRandomData2()  // 優先度が不明
    }
  };
};
typescript

キャッシュとの組み合わせ

メモリキャッシュを活用して、2回目以降のアクセスを高速化する実装パターンです。

// lib/cache.ts
const memoryCache = new Map();
const CACHE_DURATION = 5 * 60 * 1000; // 5分間

export async function getCachedData<T>(
  cacheKey: string,
  dataFetcher: () => Promise<T>
): Promise<T> {
  const cachedEntry = memoryCache.get(cacheKey);
  const currentTime = Date.now();

  // 有効なキャッシュを返す
  if (cachedEntry && currentTime - cachedEntry.timestamp < CACHE_DURATION) {
    return cachedEntry.data;  // メモリから即座にアクセス
  }

  // キャッシュミス時は新データを取得
  const freshData = await dataFetcher();
  memoryCache.set(cacheKey, { data: freshData, timestamp: currentTime });
  return freshData;
}

// +page.server.ts
import { getCachedData } from '$lib/cache';

export const load: PageServerLoad = async () => {
  return {
    // 高速なキャッシュされたクリティカルデータ
    critical: await getCachedData('critical', getCriticalData),

    streamed: {
      // キャッシュチェック付きストリーミング
      slow: getCachedData('slow', getSlowData)
    }
  };
};
typescript

ストリーミングSSRのメリット

1. 初期表示の高速化

  • TTFB改善: 重要なコンテンツが即座に送信される
  • FCP向上: First Contentful Paintが大幅に改善
  • CLS削減: レイアウトシフトを最小限に抑制

2. ユーザー体験の向上

  • 段階的表示: ユーザーは待ち時間を感じにくい
  • プログレッシブエンハンスメント: 基本機能から徐々に充実
  • エラー耐性: 一部の失敗でもページは表示される

3. SEO対応

  • クリティカルコンテンツ: 検索エンジンが重要な情報を即座に認識
  • 構造化データ: 基本的なメタデータはSSRで配信
  • パフォーマンス指標: Core Web Vitalsが改善

注意点と制限事項

1. 実装の複雑さ

<!-- エラー処理を含む完全な実装例 -->
{#await data.streamed.slow}
  <!-- ローディング状態:アクセシビリティも考慮 -->
  <div class="loading" aria-label="読み込み中">
    <div class="spinner"></div>
    <p>データを読み込んでいます...</p>
  </div>
{:then loadedData}
  <!-- データ取得後:空データのチェックも必要 -->
  {#if loadedData?.items?.length > 0}
    <!-- データがある場合の表示 -->
    <ul>
      {#each loadedData.items as item}
        <li>{item.name}</li>
      {/each}
    </ul>
  {:else}
    <!-- 空データの場合のフォールバック -->
    <p>データがありません</p>
  {/if}
{:catch error}
  <!-- エラー時:role="alert"でスクリーンリーダーに通知 -->
  <div class="error" role="alert">
    <h3>エラーが発生しました</h3>
    <p>{error.message}</p>
    <button onclick={() => window.location.reload()}>
      ページを再読み込み
    </button>
  </div>
{/await}
svelte

2. デバッグの難しさ

// デバッグ用のログ出力
export const load: PageServerLoad = async () => {
  console.log('Load function started');

  // クリティカルデータの取得とログ
  const critical = await getCriticalData();
  console.log('Critical data loaded:', critical);

  // ストリーミングデータの非同期処理
  const slowPromise = getSlowData()
    .then(data => {
      // 成功時のログ
      console.log('Slow data loaded:', data);
      return data;
    })
    .catch(error => {
      // エラー時のログ(エラーを再スロー)
      console.error('Slow data failed:', error);
      throw error;
    });

  return {
    critical,
    streamed: { slow: slowPromise }
  };
};
typescript

3. キャッシング戦略

Server-Timingヘッダーを使用してパフォーマンスを監視し、Chrome DevToolsで最適化の効果を確認できます。

// Performance monitoring with Server-Timing
export const load: PageServerLoad = async ({ setHeaders }) => {
  const startTime = performance.now();

  // Measure critical data fetch
  const criticalData = await getCriticalData();
  const duration = performance.now() - startTime;

  // Send timing info to browser DevTools
  setHeaders({
    'Server-Timing': `critical;dur=${duration}`
  });

  return {
    critical: criticalData,
    streamed: {
      slow: getSlowData()  // Not measured (streaming)
    }
  };
};
typescript

ベストプラクティス

1. 適切なデータ分割

  • Above the fold: ユーザーが最初に見る部分は即座に表示
  • Below the fold: スクロールしないと見えない部分はストリーミング
  • Interactive elements: ボタンやフォームは重要度に応じて判断

2. ローディング状態の設計

<!-- Good loading state example -->
{#await data.streamed.articles}
  <div class="articles-loading">
    <!-- Skeleton loading -->
    {#each Array(3) as _}
      <article class="article-skeleton">
        <div class="skeleton-title"></div>
        <div class="skeleton-content"></div>
        <div class="skeleton-meta"></div>
      </article>
    {/each}
  </div>
{:then articles}
  <!-- Actual content here -->
{/await}
svelte

3. エラー処理の戦略

// Streaming with fallback
export const load: PageServerLoad = async () => {
  return {
    critical: await getCriticalData(),
    streamed: {
      optional: getOptionalData().catch(() => ({
        fallback: true,
        message: 'オプショナルデータは現在利用できません'
      }))
    }
  };
};
typescript

トラブルシューティング

よくある問題と解決法

  1. Promiseが解決されない

    // ❌ Problem: Missing .then()
    const slowData = fetch('/api/slow'); // Returns Response, not data
    
    // ✅ Fixed: Parse JSON
    const slowData = fetch('/api/slow').then(r => r.json());
    typescript
  2. メモリリーク

    <script lang="ts">
      import { onMount } from 'svelte';
    
      let abortController: AbortController;
    
      onMount(() => {
        abortController = new AbortController();
        return () => abortController.abort();
      });
    </script>
    svelte
  3. 型エラー

    // 型定義を明確に
    type StreamedData = {
      slow: Promise<{ items: Item[] }>;
      optional: Promise<OptionalData>;
    };
    
    export const load: PageServerLoad = async (): Promise<{
      critical: CriticalData;
      streamed: StreamedData;
    }> => {
      // 実装
    };
    typescript

まとめ

ストリーミングSSRは、以下の場面で特に効果的です。

  • 大量データの表示: 記事一覧、商品カタログ、検索結果
  • 外部API依存: サードパーティAPIからのデータ取得
  • 複雑な計算処理: 重いデータ処理や分析結果の表示
  • 段階的な情報提示: ユーザーの関心に応じた情報の出し分け

適切に実装することで、ユーザー体験を大幅に改善し、パフォーマンス指標を向上させることができます。

次のステップ

Last update at: 2025/09/16 03:33:57