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

ユーザーの「お気に入り局」や「再生履歴」、「音量設定」などの永続化データを管理する際、ローカルストレージ(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開発のヒントとなれば幸いです。







