by MintJams

低遅延なUI表示とクラウド同期を両立する:IndexedDB×サーバーAPIのハイブリッド設計

オフライン時でも高速に動作し、かつマルチデバイス間で設定やデータを共有できるIndexedDB+サーバーファイルストレージのハイブリッドデータ同期設計について解説します。

ユーザーの「お気に入り局」や「再生履歴」、「音量設定」などの永続化データを管理する際、ローカルストレージ(IndexedDB)のみに依存すると他端末との同期が取れず、逆に毎回サーバーAPIへアクセスすると、アプリの起動遅延やレスポンス低下を招きます。

本記事では、MintJams CMSのラジオアプリで採用した、ローカル(IndexedDB)とリモート(サーバー上のJSONファイル)の双方向にタイムスタンプを持ち、遅延のない操作感とデータ共有を両立するハイブリッド同期(LibraryStore)の実装手法について解説します。

この記事でわかること

  • ローカル(IndexedDB)とリモート(サーバーJSON)を併用したデータ永続化パターン
  • 連続するユーザー操作(音量スライダー等)のバッチ処理(デバウンス)によるサーバー負荷軽減
  • タイムスタンプ比較によるコンフリクト解消の設計

データ構造と同期の基本設計

ライブラリ(Library)オブジェクトには、常に更新日時を示す updatedAt(ISO 8601形式のタイムスタンプ)を保持します。

// library.ts より型定義の抜粋
export interface Library {
    version: 1;
    updatedAt: string; // "2026-09-28T14:30:00.000Z"
    favorites: Station[];
    recents: RecentEntry[];
    custom: Station[];
    settings: PlayerSettings;
}

アプリ起動時の読み込み(高速化と最新状態の担保)

アプリ起動時は、ローカルのIndexedDBとサーバー上のファイルストレージの双方からデータを並行して取得します。双方のレスポンスが揃った段階で updatedAt を比較し、より新しいタイムスタンプを持つ側のデータで画面を復元します。

async load(): Promise<Library> {
    // ローカル(IndexedDB)とリモート(Server JSON)を並行取得
    const [local, remote] = await Promise.all([this.#loadLocal(), this.#loadRemote()]);
    const candidates = [local, remote].filter((l): l is Library => !!l);
    
    if (!candidates.length) {
        return emptyLibrary();
    }
    // updatedAtが最も新しいものを採用する
    candidates.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
    return normalize(candidates[0]);
}

書き込み処理のデバウンス(Coalescing)とウィンドウ終了時処理

お気に入り登録の連続クリックや、音量スライダーのドラッグ操作のたびにサーバーへHTTPリクエストを送信すると、通信帯域とサーバーのディスクI/Oを圧迫します。

これを回避するため、変更要求が発生した時点ではタイマー(800ms)を設定するに留め、一定時間操作が途切れたタイミングで1回のみ書き込み(ローカル保存+サーバーアップロード)を実行します。

// 保存処理の集約(デバウンス)ロジック
save(library: Library): Promise<void> {
    library.updatedAt = new Date().toISOString();
    this.#pending = JSON.parse(JSON.stringify(library)); // スナップショット作成
    
    if (this.#saveTimer) {
        clearTimeout(this.#saveTimer);
    }
    
    return new Promise<void>((resolve, reject) => {
        this.#waiters.push({ resolve, reject });
        this.#saveTimer = setTimeout(() => {
            this.#saveTimer = null;
            this.#flush(); // 800ms後にまとめて書き込み実行
        }, 800);
    });
}

ウィンドウが閉じられる際の確実な書き込み(flush)

タイマー待機中にユーザーがアプリのウィンドウを閉じた場合、メモリ上の変更が消失するリスクがあります。そのため、アプリの終了イベント(beforeClose)に割り込み、未送信の変更(#pending)があればタイマーをキャンセルし、即座に非同期処理を完了させる flush() メソッドを呼び出します。

// app.ts におけるクローズ時のコールバック設定
instance.setBeforeCloseCallback(async () => {
    player?.destroy();
    try {
        // 待機中の保存タスクがあれば直ちに実行・完了させる
        await store?.flush();
    } catch (e) {
        console.warn('[Radio] Library could not be saved on close:', e);
    }
    return true;
});

ネットワーク切断時のエラーハンドリング

オフライン状態でリモートへのアップロード(#saveRemote)が失敗した場合、ローカルのIndexedDBへの書き込みのみを成功させ、呼び出し側に失敗ステータスを返します。

// UI側でのエラーハンドリング(app.ts)
saveLibrary(quiet = false) {
    store.save(this.library).then(() => {
        if (!quiet) this.showStatus('ライブラリを保存しました');
    }).catch(() => {
        // サーバー保存に失敗した場合でもローカルには残っているため、状況を提示する
        this.showStatus('サーバーへの保存に失敗しました(オフライン保存済み)');
    });
}

これにより、次回オンライン状態でアプリを再起動した際に、ローカルの新しい updatedAt を最新データと判定し、自動的にサーバー側へ同期します。


チェックリスト

  • [ ] データ構造内にISO 8601形式の updatedAt タイムスタンプを保持しているか
  • [ ] 短時間に頻繁に発生する保存リクエスト(スライダー操作等)をデバウンスできているか
  • [ ] アプリ終了時(beforeClose 等)に保留中の書き込みを強制的(flush)に完了させているか
  • [ ] サーバー保存失敗時でもローカル(IndexedDB)への書き込みを担保できているか

まとめ

クライアントローカルのIndexedDBとリモートストレージをハイブリッドで運用するパターンは、Webアプリケーションの体感速度とデータ共有の柔軟性を高める強力なアプローチです。

updatedAt によるシンプルなタイムスタンプ比較と、タイマーによる保存処理の集約(デバウンス)を組み合わせることで、複雑な同期ライブラリを導入せずとも安定したデータ管理が可能になります。

全3回にわたるラジオアプリの技術解説連載をご覧いただき、ありがとうございました。 この記事が皆様のWeb開発のヒントとなれば幸いです。


参考資料