Tips & Tricks (更新: 2026/6/7)

IndexedDBの使い方:idbライブラリで下書き保存とオフライン同期を作る

ブラウザ内DBのIndexedDBをidbライブラリで簡潔に。localStorageとの違い、オブジェクトストアとインデックス、トランザクション、容量と永続化を動くコードで。

IndexedDBの使い方:idbライブラリで下書き保存とオフライン同期を作る

電車に乗る直前、ブログの下書きを書いていました。トンネルに入った瞬間、通信が切れて、画面のオートセーブが「保存失敗」で赤く点滅。地上に出たら、書いていた本文が半分消えていたんです。

このとき僕が悪かったのは、下書きをサーバーにしか保存していなかったこと。通信が切れたら、それで終わりでした。

ブラウザの中にも、ちゃんとデータベースが入っています。それが IndexedDB です。localStorage の親戚みたいに思われがちですが、実体はもっと本格的で、オブジェクトのまま保存できて、検索用のインデックスも張れて、トランザクションで「全部成功か、全部なかったことに」もできます。オフラインの下書き、検索つきキャッシュ、再送キュー——このあたりを作るなら、最初に手が伸びるべきAPIです。

ただ、生のAPIは正直とっつきにくい。open して onupgradeneeded を待って、onsuccess をぶら下げて……とコールバックの森になります。そこで今日は、idb という軽いラッパーを使って、なるべく短いコードでIndexedDBを動かします。

この記事の要点

  • IndexedDBは「ブラウザの中の小さなDB」。localStorage が文字列の引き出しなら、こっちはテーブルとインデックスを持つ本物のデータベース。
  • 生APIはコールバックが深くなるので、idb ライブラリでPromise化すると一気に読みやすくなる。
  • 最初に決めるのは「何を保存するか」より「どのキーで取り出すか」。インデックスは後から足すとバージョン上げが必要。
  • トランザクションは「途中で失敗したら全部巻き戻す」ための安全装置。下書き保存とキュー追加は1つにまとめる。
  • 容量は無限じゃない。QuotaExceededError を握りつぶすと「保存したのにリロードで消える」事故になる。

IndexedDBとlocalStorageは何が違うのか

まず、混同しやすい localStorage との線引きから。

localStorage は、テーマ設定、最後に開いたタブ、ちょっとしたフラグみたいな「短い文字列」を置く引き出しです。setItem / getItem で同期的に読み書きできて、お手軽。でも弱点がはっきりしています。同期APIなのでメインスレッドを止める、容量が数MBと小さい、そして検索ができません。配列を JSON.stringify して突っ込み始めたら、それはもう設計を間違えているサインです。

IndexedDBは、ブラウザの中に置かれた小さなデータベースだと思ってください。用語を身近なものに言い換えると、こうなります。

  • オブジェクトストア(object store) = テーブル。データを入れる箱。
  • インデックス(index) = 検索用の通り道。「更新日順」「未同期だけ」を高速に引くための索引。
  • トランザクション(transaction) = まとめて成功・まとめて失敗の単位。途中で落ちたら巻き戻る。
  • バージョン(version) = スキーマの世代番号。箱やインデックスの形を変えるときに上げる。

どっちを使うか迷ったら、この表で当ててみてください。

保存したいものlocalStorageIndexedDB
テーマ・表示モード・短い設定向いている使えるが大げさ
下書き・フォーム復元・長いJSON容量と同期処理が不安向いている
検索したい商品・記事のキャッシュ全件JSONになりがちインデックスで引ける
画像のBlob・音声・添付メタ不向きBlobもメタも保存できる
オフラインの再送キュー順序や失敗管理が弱いトランザクションで扱える

ざっくり言うと、「1件ずつの短い値」なら localStorage、「たくさんのレコードを検索したい」なら IndexedDB。この一線を引けるだけで、保存まわりの設計はだいぶ楽になります。

こういう場面でIndexedDBが効く

抽象論だと頭に入らないので、僕が実際にIndexedDBを選んだ場面を4つ挙げます。

  1. ブログエディタの下書き保存。 冒頭のトンネル事故の答えがこれです。入力中の本文を updatedAtsyncStatus(同期状態)つきで保存しておけば、通信が切れても消えません。オンラインに戻ったら未同期だけ拾って送る。
  2. 検索結果のキャッシュ。 商品一覧やヘルプ記事を categoryupdatedAt のインデックスで引けるようにキャッシュしておくと、2回目以降の表示が一瞬になります。
  3. オフライン操作キュー(PWA)。 問い合わせ送信やコメント投稿を一旦キューに積み、オンライン復帰時にまとめて再送する。Service Workerと組み合わせる定番構成で、詳しくはService Worker入門:キャッシュ戦略とオフライン対応で扱っています。
  4. 重い計算結果の使い回し。 APIから毎回取り直すと遅い大きな解析結果を、期限つきでブラウザ側に置いておく。体験が安定します。

共通点は「件数が多くて、ある軸で検索したい」こと。ここに localStorage を使うと、全件JSONを読んでJavaScriptでフィルタする羽目になり、件数が増えた瞬間に重くなります。

設計はスキーマとインデックスから始める

ここが一番大事なので、コードの前に言葉で。

IndexedDBで最初に決めるべきは「何を保存するか」ではなく、**「どのキーで取り出すか」**です。id で1件取るだけなら何も考えなくていい。でも実アプリでは「未同期の下書きだけ」「古いキャッシュから」「失敗回数が少ないジョブから」みたいに、別の軸で引きたくなります。その軸が インデックス です。

そして厄介なのが、インデックスを後から足すにはDBのバージョンを上げて、アップグレード処理の中で追加しないといけないこと。だから最初にひと呼吸おいて「将来どう検索するか」を想像しておくと、後の移行が減ります。

僕がよくやらかした失敗を2つ先に共有します。

  • 初期版で notes 箱だけ作り、後で「未同期だけ」が必要になって全件 filter した。 データが50件なら気づきません。5000件になると、起動直後のUIが固まります。最初から by-sync-status インデックスを張っておけばよかった、という後悔です。
  • バージョンに 1.1 のような小数を渡した。 IndexedDBのバージョンは整数として扱われます(MDNも注意しています)。1, 2, 3 と整数で増やすのが正解です。

では、idbを入れてスキーマを書きます。

npm i idb

次のコードはViteやNext.jsのクライアント側モジュールにそのまま置けます。idb は生のIndexedDBをPromiseで包んでくれる軽量ラッパーで、async/await で書けるようになるのが一番のうまみです。

import { openDB, type DBSchema, type IDBPDatabase } from "idb";

// 同期状態。dirty=未同期、syncing=送信中、failed=失敗
type SyncStatus = "clean" | "dirty" | "syncing" | "failed";

export interface DraftNote {
  id: string;
  title: string;
  body: string;
  updatedAt: number;
  baseVersion: number; // 同期衝突の判定に使う土台バージョン
  syncStatus: SyncStatus;
}

export interface SyncJob {
  id: string;
  noteId: string;
  operation: "upsert-note" | "delete-note";
  payload: unknown;
  createdAt: number;
  attempts: number; // リトライ回数
  lastError?: string;
}

// DBの形(テーブル=オブジェクトストアと、その検索用インデックス)
interface AppDB extends DBSchema {
  notes: {
    key: string;
    value: DraftNote;
    indexes: {
      "by-updated-at": number; // 更新日順に引く
      "by-sync-status": SyncStatus; // 未同期だけ引く
    };
  };
  syncQueue: {
    key: string;
    value: SyncJob;
    indexes: {
      "by-created-at": number; // 古い順に送る
      "by-attempts": number; // 失敗回数で絞る
    };
  };
}

const DB_NAME = "indexeddb-demo";
const DB_VERSION = 2;

let dbPromise: Promise<IDBPDatabase<AppDB>> | undefined;

export function getDb() {
  // 接続は1回だけ作って使い回す
  dbPromise ??= openDB<AppDB>(DB_NAME, DB_VERSION, {
    // スキーマ変更は全部このupgradeの中に閉じ込める
    upgrade(db, oldVersion, _newVersion, tx) {
      if (oldVersion < 1) {
        const notes = db.createObjectStore("notes", { keyPath: "id" });
        notes.createIndex("by-updated-at", "updatedAt");

        const queue = db.createObjectStore("syncQueue", { keyPath: "id" });
        queue.createIndex("by-created-at", "createdAt");
        queue.createIndex("by-attempts", "attempts");
      }

      if (oldVersion < 2) {
        // 後から「未同期だけ」を引くためのインデックスを追加
        const notes = tx.objectStore("notes");
        if (!notes.indexNames.contains("by-sync-status")) {
          notes.createIndex("by-sync-status", "syncStatus");
        }
      }
    },
    blocked() {
      // 別タブが古い接続を握っていて新バージョンを開けないとき
      console.warn("別のタブを閉じるとDBの更新が完了します。");
    },
    blocking() {
      // 自分のタブが将来の移行を邪魔しないよう接続を閉じる
      dbPromise?.then((db) => db.close()).catch(() => {});
    },
  });

  return dbPromise;
}

ポイントは、箱の作成もインデックス追加も、ぜんぶ upgrade の中だけでやること。バージョン1で箱を作り、バージョン2でインデックスを足す——この「世代ごとの差分」を if (oldVersion < N) で積み上げるのが定石です。

blockedblocking は地味ですが大事です。別タブで古いDB接続が開きっぱなしだと、新バージョンのオープンが止まることがあります。blocked で「タブ閉じて」と案内し、blocking で自分の接続を閉じて道を譲る。これを書いておかないと、ユーザーが複数タブを開いた瞬間に更新が固まります。

トランザクションで「半分だけ保存」を防ぐ

下書きを保存する関数を書きます。ここで トランザクション の出番です。

何が嬉しいか。下書きの保存と、同期キューへの追加は、本来セットです。下書きだけ保存できて、キューへの追加だけ失敗すると、画面は「保存済み」に見えるのにサーバーには永遠に送られない、という気持ち悪い状態になります。そこで両方を1つの readwrite トランザクションに入れて、どちらか失敗したら両方なかったことにする

const createId = () =>
  globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2);

export async function saveDraft(input: {
  id?: string;
  title: string;
  body: string;
  baseVersion?: number;
}) {
  const db = await getDb();
  const now = Date.now();
  const noteId = input.id ?? createId();

  const note: DraftNote = {
    id: noteId,
    title: input.title,
    body: input.body,
    updatedAt: now,
    baseVersion: input.baseVersion ?? 0,
    syncStatus: "dirty", // まだ送ってないので未同期
  };

  const job: SyncJob = {
    id: createId(),
    noteId,
    operation: "upsert-note",
    payload: note,
    createdAt: now,
    attempts: 0,
  };

  // notesとsyncQueueを1つのトランザクションでまとめて書く
  const tx = db.transaction(["notes", "syncQueue"], "readwrite");
  await tx.objectStore("notes").put(note);
  await tx.objectStore("syncQueue").put(job);
  await tx.done; // ここで全体の成否が確定する

  return note;
}

// 「未同期だけ」をインデックス経由で一発取得(全件filterしない)
export async function getDirtyNotes() {
  const db = await getDb();
  return db.getAllFromIndex("notes", "by-sync-status", "dirty");
}

ここに大きな落とし穴がひとつ。トランザクションの途中で fetchawait してはいけません。 IndexedDBのトランザクションは、やることがなくなると自動的に閉じます。間にネットワーク通信を挟むと、待っている間にトランザクションが閉じてしまい、後続の書き込みが「トランザクションが終わってる」と怒られます。サーバー送信はトランザクションの外でやって、成功したら別トランザクションで状態を更新する。この順番を守ってください。

getDirtyNotes も注目してほしいところ。getAllFromIndex で「未同期だけ」をインデックス経由で直接取っています。全件取ってから filter するのとは、件数が増えたときの速さが段違いです。

容量と永続化:保存したのに消える事故

IndexedDBは無限の保管庫ではありません。端末の空き容量、プライベートブラウズ、ストレージ圧迫、ユーザー操作——いろんな理由で、書き込みが失敗したり、ブラウザがデータを勝手に消したりします。

初心者が必ず一度は踏むのが、await saveDraft() を呼ぶだけで catch しないこと。容量超過(QuotaExceededError)が起きても、握りつぶしていると画面は保存できたように見えて、リロードしたら消えています。冒頭の僕の事故も、根っこはこれと同じ「失敗に気づけない保存」でした。

容量の見積もり、永続化のお願い、エラー判定をまとめて用意します。

// あとどれくらい使えるかの目安を取る
export async function estimateStorage() {
  if (!navigator.storage?.estimate) {
    return { usage: undefined, quota: undefined };
  }
  const { usage, quota } = await navigator.storage.estimate();
  return { usage, quota };
}

// 「勝手に消さないで」とブラウザにお願いする(保証ではない)
export async function requestPersistentStorage() {
  if (!navigator.storage?.persist) return false;
  return navigator.storage.persist();
}

// 容量超過エラーかどうかを判定
export function isQuotaError(error: unknown) {
  return error instanceof DOMException && error.name === "QuotaExceededError";
}

// 保存をラップして、容量超過は読めるメッセージに変える
export async function saveDraftSafely(input: Parameters<typeof saveDraft>[0]) {
  try {
    return await saveDraft(input);
  } catch (error) {
    if (isQuotaError(error)) {
      throw new Error("ブラウザの保存領域が不足しています。不要な下書きを削除してください。");
    }
    throw error;
  }
}

persist() は「絶対に消えない」を約束する魔法ではありません。ブラウザやユーザーの判断が入る、あくまで「お願い」です。だから現実には、容量の見積もり・古いキャッシュの削除・サーバー同期・エラー表示を組み合わせて守ります。そして大原則として、お金や契約に関わるデータをブラウザだけの原本にしないこと。オフラインは便利機能であって、バックアップではありません。

オフラインキューと同期衝突をどう捌くか

最後に、溜めたキューを送る部分です。オンラインに戻ったら順に送り、失敗したらリトライ回数を増やして残す——という素直な実装にします。

type SendJob = (job: SyncJob) => Promise<void>;

export async function flushSyncQueue(sendJob: SendJob) {
  if (!navigator.onLine) return; // オフラインなら何もしない

  const db = await getDb();
  // 古い順に取り出して、その順で送る
  const jobs = await db.getAllFromIndex("syncQueue", "by-created-at");

  for (const job of jobs) {
    try {
      await sendJob(job); // ← 送信はトランザクションの外
      await db.delete("syncQueue", job.id); // 成功したらキューから消す
    } catch (error) {
      const message = error instanceof Error ? error.message : "Unknown error";
      // 失敗したらリトライ回数を増やして残す
      await db.put("syncQueue", {
        ...job,
        attempts: job.attempts + 1,
        lastError: message,
      });
    }
  }
}

// オンライン復帰をトリガーに自動で再送
window.addEventListener("online", () => {
  void flushSyncQueue(async (job) => {
    const response = await fetch("/api/sync", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(job),
    });
    if (!response.ok) throw new Error(`Sync failed: ${response.status}`);
  });
});

送信を try で囲み、成功したらキューから消し、失敗したら attempts を増やして残す。これだけで「ネットワークがちょっと不安定でも、いつかは送り切る」キューになります。

そして避けて通れないのが 同期衝突。あなたがオフラインで下書きを直している間に、別の端末で同じノートを更新していたら? 何も考えずに上書きすると、後から復帰したブラウザが古い内容でサーバーを塗りつぶします。土台のバージョン(baseVersion)とサーバーの現在バージョンを比べて、ズレていたら衝突とみなします。

export function detectConflict(input: { local: DraftNote; remoteVersion: number }) {
  const { local, remoteVersion } = input;
  // 手元が未同期で、かつサーバーが土台より進んでいたら衝突
  return local.syncStatus === "dirty" && remoteVersion > local.baseVersion;
}

衝突の解決策はアプリの性格で変わります。ただのメモなら「サーバー版とローカル版を並べてユーザーに選ばせる」で十分なことが多い。でも請求・予約・在庫のような重いデータは、クライアントだけで決めず、サーバー側でバージョン番号やETagを見て拒否するのが安全です。ブラウザの中だけで完結させない、という線引きをここでも引きます。

なお、移行処理や保存関数のテストには fake-indexeddb を使うとNode上で回せます。ただし容量超過や複数タブのブロックは実ブラウザでしか再現できないので、自動テストと手動確認は分けて考えてください。テストの優先順位の付け方はClaude Codeで決めるテスト戦略が参考になります。

よくある質問

Q. IndexedDBとlocalStorage、結局どっちを使えばいい? A. 1件ずつの短い値(テーマ設定、フラグ)なら localStorage。たくさんのレコードをある軸で検索したい(下書き一覧、キャッシュ、キュー)なら IndexedDB です。配列を JSON.stringify し始めたら IndexedDB に移すサインです。

Q. idbライブラリは必須? 生APIじゃダメ? A. 必須ではありませんが、強くおすすめします。生APIはコールバックが深くなりがちで、idbは async/await で書けてTypeScriptの型も効きます。生APIを一度学ぶ価値はありますが、実務のコードはidb経由のほうが読みやすく、レビューもしやすいです。

Q. 保存容量はどれくらい? あふれたら? A. ブラウザと端末の空き容量に依存し、固定値ではありません。navigator.storage.estimate() で目安を取れます。あふれると QuotaExceededError が出るので、必ず catch してユーザーに伝え、古いデータを削除する導線を用意してください。

Q. データが勝手に消えることはある? A. あります。容量が逼迫すると、ブラウザがbest-effortのデータを削除します。navigator.storage.persist() で永続化を「お願い」できますが保証はされません。大事なデータは必ずサーバーにも同期しておきます。

Q. SSR(Next.jsなど)で使うときの注意は? A. IndexedDBはブラウザのAPIなので、サーバー側には存在しません。getDb() のような呼び出しは useEffect の中やイベントハンドラなど、クライアントで実行される場所に置いてください。トップレベルで呼ぶとSSR時に落ちます。

実際に試した結果

冒頭のトンネル事故のあと、僕は小さなメモアプリでこの構成を組み直しました。一番効いたのは、コードを書く前に「syncStatusupdatedAt のインデックスを先に決めた」ことです。最初の版では未同期を全件 filter で探していて、テストデータを数千件に増やしたら起動直後がもたつきました。インデックスに切り替えた瞬間、その重さは消えました。

下書き保存とキュー追加を1つのトランザクションにまとめてからは、「保存したのに送られてない」という気持ち悪い状態がなくなり、失敗時に何が起きたか説明できるようになりました。一方で正直に書くと、fake-indexeddb だけでは容量超過も別タブのブロックも再現できません。最後はChromeのDevToolsでストレージを手動で消し、ネットワークをOfflineにして、保存→リロード→オンライン復帰→再送までを手で一周しました。ブラウザの中のDBは「お手軽な保存先」ではなく、ちゃんと設計するデータ層です。そう腹をくくると、扱いやすくなります。

このあたりの設計や依頼文を標準化したい人は実践テンプレートをどうぞ。導入を相談したい場合は研修・導入相談から声をかけてください。

公式の確認先も置いておきます。基本はMDNのIndexedDBガイド、容量と削除基準はMDNのストレージクォータ、ラッパーはidbのREADMEが一次情報です。

#IndexedDB #idb #localStorage #オフライン #TypeScript
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。