はじめに

Webアプリケーション開発では、ユーザーデータやアプリケーション状態をブラウザ側に保存する場面が頻繁に発生します。ログイン状態の維持、フォーム入力の一時保存、オフライン時のデータキャッシュなど、用途に応じて最適なクライアントサイドストレージを選択する必要があります。

本記事では、Cookie、localStorage、sessionStorage、IndexedDBという4つの主要なクライアントサイドストレージについて、それぞれの特性、容量制限、セキュリティ上の考慮点を詳しく解説します。さらに、PWA(Progressive Web App)開発で重要となるオフライン対応アプリでのIndexedDB活用例も紹介します。

対象読者

  • フロントエンドでデータ管理を行う開発者
  • PWA開発に興味があるエンジニア
  • クライアントサイドストレージの使い分けを理解したい方

実行環境

  • モダンブラウザ(Chrome 80以上、Firefox 78以上、Safari 14以上、Edge 80以上)
  • Node.js環境での実行は対象外(ブラウザAPI)

クライアントサイドストレージの種類と特性

ブラウザには複数のデータ保存方法が用意されています。それぞれの特性を理解することで、適切なストレージを選択できます。

ストレージ比較表

特性 Cookie localStorage sessionStorage IndexedDB
容量制限 約4KB 約5MB 約5MB 数百MB以上
データ形式 文字列 文字列 文字列 オブジェクト、Blob等
有効期限 設定可能 永続 セッション終了まで 永続
サーバー送信 自動送信 送信なし 送信なし 送信なし
API 同期 同期 同期 非同期
Web Worker対応 不可 不可 不可 可能

各ストレージのアーキテクチャ

graph TB
    subgraph "ブラウザストレージ"
        subgraph "小容量・シンプル"
            A[Cookie<br/>4KB]
            B[localStorage<br/>5MB]
            C[sessionStorage<br/>5MB]
        end
        subgraph "大容量・構造化"
            D[IndexedDB<br/>数百MB以上]
        end
    end
    
    E[Webアプリケーション] --> A
    E --> B
    E --> C
    E --> D
    
    A -->|自動送信| F[サーバー]
    
    style A fill:#ffcc00,color:#000
    style B fill:#4CAF50,color:#fff
    style C fill:#2196F3,color:#fff
    style D fill:#9C27B0,color:#fff

Cookieの特性と適切な使用場面

Cookieは最も古くから存在するクライアントサイドストレージです。HTTPリクエストごとにサーバーへ自動送信される特性を持ちます。

Cookieの基本操作

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// Cookieの設定
document.cookie = "username=tanaka; max-age=86400; path=/; secure; samesite=strict";

// Cookieの読み取り
function getCookie(name) {
  const cookies = document.cookie.split("; ");
  for (const cookie of cookies) {
    const [key, value] = cookie.split("=");
    if (key === name) {
      return decodeURIComponent(value);
    }
  }
  return null;
}

// 使用例
const username = getCookie("username");
console.log(username); // "tanaka"

// Cookieの削除(有効期限を過去に設定)
document.cookie = "username=; max-age=0; path=/";

Cookieの適切な使用場面

  • セッションID管理(HttpOnly属性と併用)
  • サーバーサイドで参照が必要な設定値
  • CSRF対策トークン

Cookieの制限事項

  • 容量が約4KBと非常に小さい
  • 毎回のHTTPリクエストに含まれるためパフォーマンスに影響
  • JavaScriptからの操作APIが直感的でない

Web Storage(localStorage / sessionStorage)の仕組み

Web Storage APIは、Cookieの制限を解消するために設計されたシンプルなキーバリューストアです。localStorageとsessionStorageの2種類があり、データの永続性に違いがあります。

localStorageとsessionStorageの違い

sequenceDiagram
    participant User as ユーザー
    participant Tab1 as タブ1
    participant Tab2 as タブ2
    participant LS as localStorage
    participant SS as sessionStorage

    User->>Tab1: データ保存
    Tab1->>LS: setItem("key", "value")
    Tab1->>SS: setItem("key", "value")
    
    Note over LS: 同一オリジンで共有
    Note over SS: タブごとに独立
    
    User->>Tab2: 新しいタブを開く
    Tab2->>LS: getItem("key")
    LS-->>Tab2: "value" を返す
    Tab2->>SS: getItem("key")
    SS-->>Tab2: null を返す
    
    User->>Tab1: タブを閉じる
    Note over SS: データ消去
    Note over LS: データ維持

Web Storageの基本操作

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// localStorageへの保存
localStorage.setItem("theme", "dark");
localStorage.setItem("language", "ja");

// localStorageからの取得
const theme = localStorage.getItem("theme");
console.log(theme); // "dark"

// 特定のキーを削除
localStorage.removeItem("theme");

// すべてのデータを削除
localStorage.clear();

// 保存されているキーの数を取得
console.log(localStorage.length);

// キー名を取得(インデックス指定)
const keyName = localStorage.key(0);

オブジェクトや配列の保存

Web Storageは文字列のみを保存できるため、オブジェクトや配列はJSON形式に変換する必要があります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// オブジェクトの保存
const userSettings = {
  theme: "dark",
  fontSize: 16,
  notifications: true
};

localStorage.setItem("userSettings", JSON.stringify(userSettings));

// オブジェクトの取得
const savedSettings = JSON.parse(localStorage.getItem("userSettings"));
console.log(savedSettings.theme); // "dark"

// 配列の保存
const recentSearches = ["JavaScript", "TypeScript", "React"];
localStorage.setItem("recentSearches", JSON.stringify(recentSearches));

// 配列の取得
const searches = JSON.parse(localStorage.getItem("recentSearches"));
console.log(searches); // ["JavaScript", "TypeScript", "React"]

ストレージイベントによるタブ間同期

localStorageはstorageイベントを通じて、同一オリジンの別タブ間でデータ変更を検知できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// 別タブでのlocalStorage変更を検知
window.addEventListener("storage", (event) => {
  console.log("ストレージが変更されました");
  console.log("キー:", event.key);
  console.log("古い値:", event.oldValue);
  console.log("新しい値:", event.newValue);
  console.log("URL:", event.url);
  
  // テーマ変更の同期例
  if (event.key === "theme") {
    applyTheme(event.newValue);
  }
});

function applyTheme(themeName) {
  document.documentElement.setAttribute("data-theme", themeName);
}

Web Storageの適切な使用場面

ストレージ 適切な使用場面 具体例
localStorage 永続的なユーザー設定 テーマ設定、言語設定、表示オプション
localStorage キャッシュデータ API応答のキャッシュ、計算結果の保存
sessionStorage 一時的なフォームデータ マルチステップフォームの途中データ
sessionStorage ページ間の状態引き継ぎ 検索条件、フィルタ設定

IndexedDBの仕組みと基本操作

IndexedDBは、ブラウザ内で動作する低レベルのNoSQLデータベースです。Web Storageでは対応できない大容量データや複雑なクエリが必要な場合に使用します。

IndexedDBの主要概念

graph TB
    subgraph "IndexedDB構造"
        A[Database] --> B[Object Store 1]
        A --> C[Object Store 2]
        B --> D[Index 1]
        B --> E[Index 2]
        B --> F[レコード群]
        C --> G[Index 3]
        C --> H[レコード群]
    end
    
    I[トランザクション] --> B
    I --> C
    
    style A fill:#9C27B0,color:#fff
    style B fill:#4CAF50,color:#fff
    style C fill:#4CAF50,color:#fff
    style I fill:#FF9800,color:#fff
概念 説明
Database データベース全体。アプリケーションごとに1つ以上作成可能
Object Store RDBMSのテーブルに相当。オブジェクトを保存する領域
Index 高速検索のためのインデックス。特定のプロパティで検索可能
Transaction データ操作の単位。読み取り専用または読み書き可能
Cursor レコードを順番に処理するためのポインタ

IndexedDBの基本操作

IndexedDBはすべての操作が非同期で行われます。以下に基本的なCRUD操作を示します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
// データベースを開く
function openDatabase() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open("MyAppDB", 1);
    
    // データベース作成またはバージョンアップ時
    request.onupgradeneeded = (event) => {
      const db = event.target.result;
      
      // Object Storeが存在しない場合は作成
      if (!db.objectStoreNames.contains("users")) {
        const store = db.createObjectStore("users", { keyPath: "id", autoIncrement: true });
        store.createIndex("email", "email", { unique: true });
        store.createIndex("name", "name", { unique: false });
      }
    };
    
    request.onsuccess = (event) => {
      resolve(event.target.result);
    };
    
    request.onerror = (event) => {
      reject(event.target.error);
    };
  });
}

// データの追加
async function addUser(user) {
  const db = await openDatabase();
  return new Promise((resolve, reject) => {
    const transaction = db.transaction(["users"], "readwrite");
    const store = transaction.objectStore("users");
    const request = store.add(user);
    
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

// データの取得
async function getUser(id) {
  const db = await openDatabase();
  return new Promise((resolve, reject) => {
    const transaction = db.transaction(["users"], "readonly");
    const store = transaction.objectStore("users");
    const request = store.get(id);
    
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

// データの更新
async function updateUser(user) {
  const db = await openDatabase();
  return new Promise((resolve, reject) => {
    const transaction = db.transaction(["users"], "readwrite");
    const store = transaction.objectStore("users");
    const request = store.put(user);
    
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

// データの削除
async function deleteUser(id) {
  const db = await openDatabase();
  return new Promise((resolve, reject) => {
    const transaction = db.transaction(["users"], "readwrite");
    const store = transaction.objectStore("users");
    const request = store.delete(id);
    
    request.onsuccess = () => resolve();
    request.onerror = () => reject(request.error);
  });
}

使用例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 実行例
async function main() {
  try {
    // ユーザーを追加
    const userId = await addUser({
      name: "田中太郎",
      email: "tanaka@example.com",
      createdAt: new Date()
    });
    console.log("追加されたユーザーID:", userId);
    
    // ユーザーを取得
    const user = await getUser(userId);
    console.log("取得したユーザー:", user);
    
    // ユーザーを更新
    user.name = "田中次郎";
    await updateUser(user);
    console.log("ユーザーを更新しました");
    
    // ユーザーを削除
    await deleteUser(userId);
    console.log("ユーザーを削除しました");
  } catch (error) {
    console.error("エラーが発生しました:", error);
  }
}

main();

インデックスを使用した検索

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// メールアドレスでユーザーを検索
async function getUserByEmail(email) {
  const db = await openDatabase();
  return new Promise((resolve, reject) => {
    const transaction = db.transaction(["users"], "readonly");
    const store = transaction.objectStore("users");
    const index = store.index("email");
    const request = index.get(email);
    
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

// 全ユーザーを取得
async function getAllUsers() {
  const db = await openDatabase();
  return new Promise((resolve, reject) => {
    const transaction = db.transaction(["users"], "readonly");
    const store = transaction.objectStore("users");
    const request = store.getAll();
    
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

// カーソルを使用した範囲検索
async function getUsersInRange(minId, maxId) {
  const db = await openDatabase();
  return new Promise((resolve, reject) => {
    const transaction = db.transaction(["users"], "readonly");
    const store = transaction.objectStore("users");
    const range = IDBKeyRange.bound(minId, maxId);
    const request = store.openCursor(range);
    const results = [];
    
    request.onsuccess = (event) => {
      const cursor = event.target.result;
      if (cursor) {
        results.push(cursor.value);
        cursor.continue();
      } else {
        resolve(results);
      }
    };
    
    request.onerror = () => reject(request.error);
  });
}

各ストレージの容量制限と削除基準

ブラウザのストレージ容量はブラウザやデバイスによって異なります。容量制限を超えた場合の挙動も理解しておく必要があります。

ブラウザ別の容量制限目安

ブラウザ localStorage IndexedDB
Chrome 約5MB ディスク容量の最大80%
Firefox 約5MB ディスク容量の最大50%
Safari 約5MB 約1GB(ユーザー許可で拡張可能)
Edge 約5MB ディスク容量の最大80%

容量確認と見積もり

StorageManager APIを使用して、利用可能な容量と使用量を確認できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// ストレージ使用量の確認
async function checkStorageQuota() {
  if ("storage" in navigator && "estimate" in navigator.storage) {
    const estimate = await navigator.storage.estimate();
    
    const usedMB = (estimate.usage / (1024 * 1024)).toFixed(2);
    const quotaMB = (estimate.quota / (1024 * 1024)).toFixed(2);
    const percentUsed = ((estimate.usage / estimate.quota) * 100).toFixed(2);
    
    console.log(`使用量: ${usedMB} MB`);
    console.log(`上限: ${quotaMB} MB`);
    console.log(`使用率: ${percentUsed}%`);
    
    return {
      usage: estimate.usage,
      quota: estimate.quota,
      percentUsed: parseFloat(percentUsed)
    };
  }
  
  console.warn("StorageManager APIがサポートされていません");
  return null;
}

// 永続ストレージのリクエスト
async function requestPersistentStorage() {
  if ("storage" in navigator && "persist" in navigator.storage) {
    const isPersisted = await navigator.storage.persisted();
    
    if (!isPersisted) {
      const granted = await navigator.storage.persist();
      if (granted) {
        console.log("永続ストレージが許可されました");
      } else {
        console.log("永続ストレージは許可されませんでした");
      }
      return granted;
    }
    
    console.log("すでに永続ストレージが有効です");
    return true;
  }
  
  console.warn("永続ストレージAPIがサポートされていません");
  return false;
}

容量超過時の対処

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// localStorageへの安全な保存
function safeSetItem(key, value) {
  try {
    localStorage.setItem(key, value);
    return true;
  } catch (error) {
    if (error.name === "QuotaExceededError") {
      console.error("ストレージ容量が不足しています");
      // 古いデータを削除するなどの対処
      cleanupOldData();
      return false;
    }
    throw error;
  }
}

// 古いデータのクリーンアップ
function cleanupOldData() {
  const keysToRemove = [];
  
  for (let i = 0; i < localStorage.length; i++) {
    const key = localStorage.key(i);
    if (key.startsWith("cache_")) {
      keysToRemove.push(key);
    }
  }
  
  // 古いキャッシュを削除
  keysToRemove.forEach(key => localStorage.removeItem(key));
  console.log(`${keysToRemove.length}件のキャッシュを削除しました`);
}

セキュリティ上の考慮点

クライアントサイドストレージはユーザーのブラウザに直接保存されるため、適切なセキュリティ対策が必要です。

XSS攻撃への対策

クライアントサイドストレージに保存されたデータは、XSS(クロスサイトスクリプティング)攻撃によって窃取される可能性があります。

1
2
3
4
5
6
7
8
// 危険なパターン: 機密情報をlocalStorageに保存
// 絶対に避けるべき
localStorage.setItem("accessToken", "eyJhbGciOiJIUzI1NiIs...");

// XSS攻撃によるトークン窃取の例
// 悪意のあるスクリプトが実行された場合
const stolenToken = localStorage.getItem("accessToken");
// attackerServer.send(stolenToken);

推奨されるセキュリティ対策

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// 1. 機密性の高いトークンはHttpOnly Cookieで管理
// サーバー側で設定
// Set-Cookie: session_id=xxx; HttpOnly; Secure; SameSite=Strict

// 2. 保存前にデータを検証・サニタイズ
function sanitizeInput(input) {
  if (typeof input !== "string") {
    return JSON.stringify(input);
  }
  // HTMLエンティティをエスケープ
  return input
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#x27;");
}

// 3. 機密データの暗号化(クライアント側の暗号化は補助的な対策)
async function encryptData(data, key) {
  const encoder = new TextEncoder();
  const dataBuffer = encoder.encode(JSON.stringify(data));
  
  const cryptoKey = await crypto.subtle.importKey(
    "raw",
    key,
    { name: "AES-GCM" },
    false,
    ["encrypt"]
  );
  
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const encrypted = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv },
    cryptoKey,
    dataBuffer
  );
  
  return {
    iv: Array.from(iv),
    data: Array.from(new Uint8Array(encrypted))
  };
}

// 4. 有効期限付きデータの保存
function setWithExpiry(key, value, ttlMs) {
  const item = {
    value: value,
    expiry: Date.now() + ttlMs
  };
  localStorage.setItem(key, JSON.stringify(item));
}

function getWithExpiry(key) {
  const itemStr = localStorage.getItem(key);
  if (!itemStr) return null;
  
  const item = JSON.parse(itemStr);
  if (Date.now() > item.expiry) {
    localStorage.removeItem(key);
    return null;
  }
  
  return item.value;
}

同一オリジンポリシーによる保護

クライアントサイドストレージは同一オリジンポリシーにより保護されています。異なるオリジン(プロトコル、ホスト、ポートの組み合わせ)からはデータにアクセスできません。

graph TB
    subgraph "https://example.com"
        A[localStorage] --> B[データA]
    end
    
    subgraph "https://other.com"
        C[localStorage] --> D[データB]
    end
    
    subgraph "http://example.com"
        E[localStorage] --> F[データC]
    end
    
    A -.->|アクセス不可| D
    A -.->|アクセス不可| F
    
    style A fill:#4CAF50,color:#fff
    style C fill:#2196F3,color:#fff
    style E fill:#FF9800,color:#fff

オフライン対応アプリでのIndexedDB活用

PWA(Progressive Web App)では、オフライン時でもアプリケーションが動作することが求められます。IndexedDBを活用したオフラインファースト設計の実装例を紹介します。

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

sequenceDiagram
    participant App as アプリケーション
    participant IDB as IndexedDB
    participant API as サーバーAPI
    
    App->>IDB: 1. ローカルデータ取得
    IDB-->>App: キャッシュデータを返却
    App->>App: 2. UIを即座に表示
    
    alt オンライン
        App->>API: 3. 最新データを取得
        API-->>App: 最新データを返却
        App->>IDB: 4. ローカルデータを更新
        App->>App: 5. UIを更新
    else オフライン
        App->>App: キャッシュデータで継続動作
        App->>IDB: 操作をキューに保存
    end
    
    Note over App,IDB: オンライン復帰時にキューを処理

実践的なIndexedDBラッパークラス

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
// IndexedDBを使いやすくラップするクラス
class OfflineDB {
  constructor(dbName, version = 1) {
    this.dbName = dbName;
    this.version = version;
    this.db = null;
  }
  
  async init(stores) {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.dbName, this.version);
      
      request.onupgradeneeded = (event) => {
        const db = event.target.result;
        
        stores.forEach(store => {
          if (!db.objectStoreNames.contains(store.name)) {
            const objectStore = db.createObjectStore(store.name, {
              keyPath: store.keyPath,
              autoIncrement: store.autoIncrement || false
            });
            
            if (store.indexes) {
              store.indexes.forEach(index => {
                objectStore.createIndex(index.name, index.keyPath, {
                  unique: index.unique || false
                });
              });
            }
          }
        });
      };
      
      request.onsuccess = (event) => {
        this.db = event.target.result;
        resolve(this.db);
      };
      
      request.onerror = (event) => {
        reject(event.target.error);
      };
    });
  }
  
  async add(storeName, data) {
    return this._transaction(storeName, "readwrite", store => store.add(data));
  }
  
  async put(storeName, data) {
    return this._transaction(storeName, "readwrite", store => store.put(data));
  }
  
  async get(storeName, key) {
    return this._transaction(storeName, "readonly", store => store.get(key));
  }
  
  async getAll(storeName) {
    return this._transaction(storeName, "readonly", store => store.getAll());
  }
  
  async delete(storeName, key) {
    return this._transaction(storeName, "readwrite", store => store.delete(key));
  }
  
  async clear(storeName) {
    return this._transaction(storeName, "readwrite", store => store.clear());
  }
  
  async _transaction(storeName, mode, operation) {
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction([storeName], mode);
      const store = transaction.objectStore(storeName);
      const request = operation(store);
      
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }
}

オフライン同期キューの実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
// オフライン時の操作をキューに保存し、オンライン復帰時に同期
class SyncQueue {
  constructor(db) {
    this.db = db;
    this.isOnline = navigator.onLine;
    this.setupEventListeners();
  }
  
  setupEventListeners() {
    window.addEventListener("online", () => {
      this.isOnline = true;
      this.processQueue();
    });
    
    window.addEventListener("offline", () => {
      this.isOnline = false;
    });
  }
  
  async enqueue(operation) {
    const queueItem = {
      id: Date.now().toString(),
      operation: operation.type,
      data: operation.data,
      endpoint: operation.endpoint,
      method: operation.method,
      timestamp: new Date().toISOString(),
      retryCount: 0
    };
    
    await this.db.add("syncQueue", queueItem);
    
    if (this.isOnline) {
      this.processQueue();
    }
    
    return queueItem.id;
  }
  
  async processQueue() {
    const queueItems = await this.db.getAll("syncQueue");
    
    for (const item of queueItems) {
      try {
        await this.executeOperation(item);
        await this.db.delete("syncQueue", item.id);
        console.log(`同期完了: ${item.id}`);
      } catch (error) {
        console.error(`同期失敗: ${item.id}`, error);
        
        if (item.retryCount < 3) {
          item.retryCount++;
          await this.db.put("syncQueue", item);
        } else {
          // 最大リトライ回数を超えた場合は失敗キューへ移動
          await this.db.add("failedQueue", item);
          await this.db.delete("syncQueue", item.id);
        }
      }
    }
  }
  
  async executeOperation(item) {
    const response = await fetch(item.endpoint, {
      method: item.method,
      headers: {
        "Content-Type": "application/json"
      },
      body: JSON.stringify(item.data)
    });
    
    if (!response.ok) {
      throw new Error(`HTTP error: ${response.status}`);
    }
    
    return response.json();
  }
}

完全なオフライン対応Todoアプリの実装例

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
// オフライン対応Todoアプリケーション
class OfflineTodoApp {
  constructor() {
    this.db = new OfflineDB("TodoAppDB", 1);
    this.syncQueue = null;
  }
  
  async init() {
    await this.db.init([
      {
        name: "todos",
        keyPath: "id",
        indexes: [
          { name: "completed", keyPath: "completed" },
          { name: "createdAt", keyPath: "createdAt" }
        ]
      },
      {
        name: "syncQueue",
        keyPath: "id"
      },
      {
        name: "failedQueue",
        keyPath: "id"
      }
    ]);
    
    this.syncQueue = new SyncQueue(this.db);
    console.log("Todoアプリの初期化が完了しました");
  }
  
  async addTodo(title) {
    const todo = {
      id: crypto.randomUUID(),
      title: title,
      completed: false,
      createdAt: new Date().toISOString(),
      synced: false
    };
    
    // まずローカルに保存
    await this.db.add("todos", todo);
    
    // 同期キューに追加
    await this.syncQueue.enqueue({
      type: "CREATE",
      endpoint: "/api/todos",
      method: "POST",
      data: todo
    });
    
    return todo;
  }
  
  async toggleTodo(id) {
    const todo = await this.db.get("todos", id);
    if (!todo) return null;
    
    todo.completed = !todo.completed;
    todo.updatedAt = new Date().toISOString();
    todo.synced = false;
    
    await this.db.put("todos", todo);
    
    await this.syncQueue.enqueue({
      type: "UPDATE",
      endpoint: `/api/todos/${id}`,
      method: "PUT",
      data: todo
    });
    
    return todo;
  }
  
  async deleteTodo(id) {
    await this.db.delete("todos", id);
    
    await this.syncQueue.enqueue({
      type: "DELETE",
      endpoint: `/api/todos/${id}`,
      method: "DELETE",
      data: { id }
    });
  }
  
  async getAllTodos() {
    return this.db.getAll("todos");
  }
  
  async getCompletedTodos() {
    const todos = await this.getAllTodos();
    return todos.filter(todo => todo.completed);
  }
  
  async getPendingTodos() {
    const todos = await this.getAllTodos();
    return todos.filter(todo => !todo.completed);
  }
}

// 使用例
async function runTodoApp() {
  const app = new OfflineTodoApp();
  await app.init();
  
  // Todoを追加
  await app.addTodo("IndexedDBについて学ぶ");
  await app.addTodo("オフライン対応を実装する");
  
  // すべてのTodoを取得
  const todos = await app.getAllTodos();
  console.log("Todoリスト:", todos);
  
  // 最初のTodoを完了にする
  if (todos.length > 0) {
    await app.toggleTodo(todos[0].id);
  }
  
  // 完了したTodoを取得
  const completed = await app.getCompletedTodos();
  console.log("完了したTodo:", completed);
}

runTodoApp();

期待される動作結果

上記のコードを実行すると、以下の動作が確認できます。

  1. IndexedDBに「TodoAppDB」データベースが作成される
  2. 「todos」「syncQueue」「failedQueue」のObject Storeが作成される
  3. オフライン状態でもTodoの追加・更新・削除が可能
  4. オンライン復帰時に自動的にサーバーと同期

各ストレージの使い分け判断フロー

以下のフローチャートを参考に、要件に応じた最適なストレージを選択してください。

flowchart TD
    A[データを保存したい] --> B{サーバーへ自動送信<br/>が必要?}
    B -->|はい| C[Cookie]
    B -->|いいえ| D{データ容量は<br/>5MB以上?}
    D -->|はい| E[IndexedDB]
    D -->|いいえ| F{複雑なクエリ<br/>が必要?}
    F -->|はい| E
    F -->|いいえ| G{永続化が<br/>必要?}
    G -->|はい| H[localStorage]
    G -->|いいえ| I[sessionStorage]
    
    C --> J{機密データ?}
    J -->|はい| K[HttpOnly属性を付与]
    J -->|いいえ| L[通常のCookie]
    
    style C fill:#ffcc00,color:#000
    style E fill:#9C27B0,color:#fff
    style H fill:#4CAF50,color:#fff
    style I fill:#2196F3,color:#fff

ユースケース別の推奨ストレージ

ユースケース 推奨ストレージ 理由
セッションID管理 Cookie(HttpOnly) サーバー送信が必要、XSS対策
ユーザー設定(テーマ等) localStorage 永続化が必要、容量小
フォームの一時保存 sessionStorage タブ単位で独立、一時的
オフラインデータキャッシュ IndexedDB 大容量、構造化データ
画像・ファイルキャッシュ IndexedDB Blobの保存が可能
アクセストークン メモリ or HttpOnly Cookie セキュリティ最優先

まとめ

本記事では、クライアントサイドストレージの4種類(Cookie、localStorage、sessionStorage、IndexedDB)について、それぞれの特性と使い分けを解説しました。

主要なポイントを整理します。

  • Cookie: サーバーへの自動送信が必要な場合に使用。容量は約4KBと小さい
  • localStorage: 永続的なユーザー設定の保存に最適。約5MBまで保存可能
  • sessionStorage: タブを閉じるまでの一時データに使用。タブごとに独立
  • IndexedDB: 大容量データや複雑なクエリが必要な場合に使用。オフライン対応に最適

セキュリティ面では、機密情報はHttpOnly Cookieで管理し、クライアントサイドストレージにはセンシティブなデータを保存しないことが重要です。

PWA開発においては、IndexedDBを活用したオフラインファースト設計により、ネットワーク状況に関係なく快適なユーザー体験を提供できます。同期キューパターンを実装することで、オフライン時の操作をオンライン復帰後に確実にサーバーへ反映できます。

参考リンク