Featured image of post Service Workers:高度なオフライン戦略でWebアプリを強化

Service Workers:高度なオフライン戦略でWebアプリを強化

キャッシュファースト/ネットワークファースト戦略の比較、Workbox統合、Background Sync、Periodic Sync、Stale-While-Revalidate、オフラインファーストアーキテクチャを詳しく解説。

Service Workerはオフライン対応のプログレッシブWebアプリの基盤です。Chrome、Firefox、Safari、Edge全てのブラウザでサポートが universal になった今、オフライン対応はプログレッシブエンハンスメントではなく、本番要件です。本ガイドでは、キャッシング戦略、同期、オフラインファーストアーキテクチャの実践的なパターンを解説します。

Service Workerのライフサイクルと基礎

Service Workerは3つの主要イベントを通じて動作します。install(インストール)、activate(有効化)、fetch(フェッチ)です。インストール時に静的アセットを事前キャッシュし、有効化時に古いキャッシュをクリーンアップします。全てのフェッチリクエストがワーカーを通過するため、レスポンスを完全に制御できます。

const CACHE_NAME = "my-app-v2";

self.addEventListener("install", (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) =>
      cache.addAll(["/", "/styles/main.css", "/scripts/app.js"])
    )
  );
});

self.addEventListener("activate", (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
    )
  );
});

self.addEventListener("fetch", (event) => {
  event.respondWith(handleRequest(event.request));
});

self.skipWaiting()をインストール時に、clients.claim()を有効化時に使用することで、更新後に全ての開いているページを即座に制御下に置けます。


キャッシュ戦略の詳細比較

適切なキャッシュ戦略の選択は、リソースタイプ、更新頻度、接続要件に依存します。

戦略最適な用途トレードオフ
Cache-first静的アセット、画像次のSW更新まで古いコンテンツ
Network-firstAPIレスポンス、HTMLオンライン時の速度低下
Stale-while-revalidate混合コンテンツ即時読み込み+バックグラウンド更新
Cache-only不変のハッシュ付きアセットキャッシュ欠落時にフォールバックなし
Network-onlyリアルタイムデータ、更新系オフライン非対応

Stale-while-revalidateは多くのコンテンツに推奨されるデフォルトの戦略です。キャッシュを即座に返し、バックグラウンドで最新コピーを取得します:

async function staleWhileRevalidate(request) {
  const cache = await caches.open("dynamic-v1");
  const cached = await cache.match(request);

  const fetchPromise = fetch(request).then((response) => {
    cache.put(request, response.clone());
    return response;
  });

  return cached || fetchPromise;
}

同時ページロード時の競合状態を防ぐには、保留中のリクエストマップを使用して重複ネットワークフェッチを回避します。


Background SyncとPeriodic Sync

Background Sync APIを使用すると、ユーザーが安定した接続を得られるまでアクションを延期できます。オフラインフォーム送信やキューイングされたAPIミューテーションに最適です。

async function queueSubmission(data) {
  const db = await openDB("offline-queue", 1);
  await db.add("submissions", data);
  await navigator.serviceWorker.ready.then((sw) =>
      sw.sync.register("submit-data")
  );
}

self.addEventListener("sync", (event) => {
  if (event.tag === "submit-data") {
    event.waitUntil(processQueue());
  }
});

Periodic Syncはさらに進んで、ブラウザ定義の間隔でバックグラウンド更新を可能にします。ニュース記事の事前取得、天気データの更新、ソーシャルメディアフィードのリフレッシュに活用できます。ユーザーの許可が必要で、Safariでは現在サポートが限定されています。


オフラインファーストアーキテクチャ

オフラインファーストとは、オフラインをデフォルトとし、接続を拡張機能として扱うアプリケーション設計です。IndexedDBを信頼できる情報源とし、バックグラウンドでリモート同期を行います。

interface SyncQueue {
  id: string;
  action: "create" | "update" | "delete";
  endpoint: string;
  body: unknown;
  createdAt: Date;
}

class OfflineStore {
  private db: IDBDatabase;

  async enqueue(entry: SyncQueue) {
    const tx = this.db.transaction("sync-queue", "readwrite");
    await tx.store.add(entry);
    await navigator.serviceWorker.ready.then((sw) =>
        sw.sync.register("sync-queue")
    );
  }

  async processQueue() {
    const tx = this.db.transaction("sync-queue", "readonly");
    const entries = await tx.store.getAll();
    for (const entry of entries) {
      try {
        await fetch(entry.endpoint, {
          method: "POST",
          body: JSON.stringify(entry.body),
        });
        const deleteTx = this.db.transaction("sync-queue", "readwrite");
        await deleteTx.store.delete(entry.id);
      } catch {
        break;
      }
    }
  }
}

競合解決戦略も重要です。last-write-winsはシンプルですが情報損失のリスクがあります。CRDTやoperational transformsは協調シナリオでユーザーの意図を保持します。RxDBやPouchDBなどのフレームワークは、自動レプリケーションを備えたオフラインファースト機能を提供します。


キャッシュ管理とテスト

キャッシュのバージョン管理は、デプロイ後に古いアセットが配信されるのを防ぎます。キャッシュ名にバージョン文字列を含め、有効化時に古いキャッシュを削除します。navigator.storage.estimate()でストレージクォータを確認し、永続ストレージをリクエストして削除を防止します:

async function requestPersistentStorage() {
  if (navigator.storage && navigator.storage.persist) {
    const granted = await navigator.storage.persist();
    console.log(`Persistent storage: ${granted ? "granted" : "denied"}`);
  }
}

テストにはChrome DevToolsのオフラインシミュレーションが便利です。Playwrightを使った自動テストでは、オフライン状態での動作を検証できます:

import { test, expect } from "@playwright/test";

test("app works offline", async ({ page, context }) => {
  await page.goto("https://my-app.com");
  await context.setOffline(true);
  await page.reload();
  await expect(page.locator("h1")).toHaveText("My App");
});

Navigation PreloadはService Workerの起動パフォーマンスを改善します。インストールハンドラで有効化し、プリロードレスポンスをネットワークファーストのフォールバックとして利用します。

Service Workerは現在、Webアプリケーションの信頼性を高める本番標準のツールです。まずはstale-while-revalidateから始め、Background Syncでミューテーションを追加し、アーキテクチャの成熟に伴ってオフラインファーストへと進化させていきましょう。結果として、ユーザーの接続状態に関わらず尊重するアプリケーションが実現します。