Microsoft Edge で突然 localStorage の保存に失敗し、ユーザー設定や編集中データが消える――フロントエンドで最も避けたい事故のひとつです。本稿では、エラーの正体(QuotaExceededError)と原因、現場で即実践できる応急処置から恒久対策(圧縮・データ設計・IndexedDB への移行・PWA の永続化・File System Access など)まで、手順とコード例をまとめて解説します。ブラウザ依存の落とし穴や InPrivate での厳格な制限も併せて押さえましょう。
Edge の localStorage で発生するエラーの概要
Microsoft Edge(Chromium 系)で localStorage.setItem() を実行した際、保存容量の上限に達すると次の例外が投げられます。
Failed to execute 'setItem' on 'Storage': Setting the value of 'userData' exceeded the quota.
ブラウザが確保している「オリジンごとの割り当て」を超過すると、この QuotaExceededError が発生し、以後の書き込みは失敗します。症状としては次のようなものが典型です。
- アプリの設定(テーマ、レイアウト、フィルター条件など)が保存できない/保存直後に消える
- 編集中の下書きが消える、フォームのドラフトが復元できない
- ユーザーから「保存ボタンを押したのに反映されない」と報告が来る
原因の理解:localStorage の容量は「およそ 5 MB」
Edge を含む主要ブラウザは、同一オリジン(プロトコル+ホスト+ポート)ごとに、およそ 5 MB 前後の localStorage 上限を設けています。厳密な値やカウント方法は実装に依存しますが、一般にはキーと値の合計サイズが基準です。
- 実容量は文字数ではなく「バイト」換算です。UTF-16/UTF-8 の扱い、メタデータなど実装差があるため、同じ文字数でも実バイトは前後します。
- InPrivate(シークレット)モードでは、割当てがさらに厳しくなったり、セッション終了時にストレージが消去されるため、同じコードでも急にエラーが出やすくなります。
- OS のディスク空き容量が乏しい場合や、企業ポリシーでストレージが制限されている環境では、利用可能容量が実質的に縮むことがあります。
質問の状況(要約)
アプリが Microsoft Edge で localStorage.setItem() を実行したところ、Failed to execute 'setItem' on 'Storage': Setting the value of 'userData' exceeded the quota. という例外が発生し、ユーザー設定や編集中データが保存できなくなった。
最初に試す「応急処置」手順(実作業の流れ)
| 手順 | 内容 |
|---|---|
| 原因の理解 | Edge を含む主要ブラウザでは、同一オリジンにつき約 5 MB 程度の localStorage 容量制限がある。これを超えると QuotaExceededError が発生する。 |
| 不要データの削除 | 1. F12 キーで DevTools を開く → Application タブ → Local Storage 2. 容量の大きいキーや不要キーを削除し、再実行する。 |
| 保存データの最適化 | ・文字列を圧縮(例:LZ-string)、キー名や JSON プロパティ名を短くする。 ・画像・大きなバイナリは Base64 で保存せず、IndexedDB や外部ストレージへ移行する。 |
| ストレージ種別の切替 | 5 MB を超える継続的データは IndexedDB、一時データは sessionStorage など、用途に応じてストレージを分ける。 |
| ブラウザモード確認 | シークレット(InPrivate)モードはさらに厳しい容量制限があるため、通常ウィンドウでの動作確認を推奨。 |
| 端末環境の確認 | ローカルディスクの空き容量不足や、組織ポリシーによるストレージ制限がないか確認する。 |
容量を超えているか素早く見極める:計測スニペット
localStorage の「実」使用量をおおまかに把握するには、各キーのサイズを合算するユーティリティが役立ちます。
// localStorage の概算バイト数を計算(キーと値を合算)
function localStorageSizeBytes() {
let total = 0;
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
const value = localStorage.getItem(key);
// Blob によるバイト推定(実装差による誤差はあり)
total += new Blob([key]).size + new Blob([value]).size;
}
return total;
}
// 目安表示(MB)
console.log((localStorageSizeBytes() / (1024 * 1024)).toFixed(2) + ' MB');
// Storage API でサイト全体の quota/usage を把握(IndexedDB なども含む推定)
if (navigator.storage && navigator.storage.estimate) {
navigator.storage.estimate().then(e => {
const used = (e.usage || 0) / (1024 * 1024);
const quota = (e.quota || 0) / (1024 * 1024);
console.log(`Site usage: ${used.toFixed(2)}MB / quota ${quota.toFixed(2)}MB`);
});
}
注意:navigator.storage.estimate() はサイト全体の見積りであり、localStorage 単独の上限値とは一致しませんが、「もう限界に近いか?」 の判断材料として有用です。
エラーを再現して原因を確信する(デバッグ用)
再現コードをコンソールで実行し、エラーが QuotaExceededError であることを確認します。
// 100KB の擬似データを作り、繰り返し書き込む
function fillLocalStorage() {
const payload = 'x'.repeat(100 * 1024);
let i = 0;
try {
for (;; i++) {
localStorage.setItem('test:' + i, payload);
}
} catch (err) {
console.error('Stop at i=', i, err.name, err.message);
}
}
fillLocalStorage();
Edge で上記を実行し、一定回数で QuotaExceededError が出れば、容量超過が主因と判断できます。
堅牢化:安全な保存ラッパー(try/catch とフォールバック)
実運用では、保存時に例外が起きてもアプリが壊れないように、安全な setItem ラッパーを導入しましょう。
/**
* localStorage に安全に保存する(失敗時は圧縮や退避を試みる)
*/
async function safeSetItem(key, value, {compress = false, onFallback} = {}) {
const text = typeof value === 'string' ? value : JSON.stringify(value);
const toSave = compress && window.LZString ? LZString.compressToUTF16(text) : text;
try {
localStorage.setItem(key, toSave);
return {ok: true, savedAs: 'localStorage', compressed: !!compress};
} catch (e) {
if (e && (e.name === 'QuotaExceededError' || e.code === 22)) {
// 退避先へフォールバック(IndexedDB など)
if (typeof onFallback === 'function') {
await onFallback(key, text);
return {ok: true, savedAs: 'fallback', compressed: false};
}
}
console.error('safeSetItem failed', e);
return {ok: false, error: e};
}
}
ポイントは次のとおりです。
- 例外を握りつぶさない(ログを残し、UI に「保存方法を切り替えました」などの通知を出す)
- 必要に応じて圧縮(LZ-string などを利用。導入時はパフォーマンス計測を推奨)
- フォールバックとして IndexedDB などに退避し、ユーザーの作業継続を最優先する
保存データをスリム化するための具体策
- JSON を最短化:プロパティ名の省略、無駄な空白や改行を入れない(
JSON.stringify(obj)の第 3 引数にスペースは渡さない)。 - 差分保存:履歴やログを丸ごと保存せず「最新のみ」「直近 N 件のみ」に制限。
- Base64 を避ける:画像やバイナリを Base64 化するとおよそ 33% 程度サイズが増えます。IndexedDB の Blob や File System Access API で扱う方が効率的です。
- 圧縮:LZ-string(
compressToUTF16/decompressFromUTF16)などの軽量圧縮を検討。ただし CPU コストとトレードオフのため、対象データを絞る。 - TTL と自動削除:保存時に
expiresAtを持たせ、起動時に古いキーを掃除(エビクション)する。
用途別:どのストレージを使うべきか
| 用途 | 推奨ストレージ | 理由 | 容量の目安 |
|---|---|---|---|
| ユーザー設定・トグル | localStorage | 即時同期・読み出しが軽い | 〜数十 KB |
| フォームのドラフト(短期) | sessionStorage | タブ単位。閉じると消えるため漏洩リスクを抑制 | 〜数百 KB |
| 一覧キャッシュ・検索結果 | IndexedDB | 非同期で大容量、Blob も扱える | MB〜GB(環境に依存) |
| 画像・添付ファイル | IndexedDB / File System Access | Base64 を避けて効率保存 | サイズ依存 |
| オフライン編集の元データ | IndexedDB + 永続化 | PWA と相性が良い。エビクション耐性向上 | 数十 MB 以上 |
IndexedDB への移行:ミニマル実装例
外部ライブラリなしで JSON データを保存・取得する基本形です。
// DB を開く(初回はオブジェクトストアを作成)
function openDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open('appDB', 1);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains('kv')) db.createObjectStore('kv');
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function idbSet(key, value) {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction('kv', 'readwrite');
tx.objectStore('kv').put(value, key);
tx.oncomplete = () => resolve(true);
tx.onerror = () => reject(tx.error);
});
}
async function idbGet(key) {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction('kv', 'readonly');
const req = tx.objectStore('kv').get(key);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
// localStorage が溢れたら IndexedDB へ退避
safeSetItem('userData', bigJson, {
compress: false,
onFallback: (key, raw) => idbSet(key, raw)
});
画像・大きなバイナリの扱い:Base64 ではなく Blob/ファイルへ
大きな画像を Base64 で localStorage に入れると、容量を一気に食い、しかも 33% ほどサイズ増になります。IndexedDB に Blob として保存するか、File System Access API でユーザーの許可のもとにファイルへ保存しましょう。
// IndexedDB に Blob を保存
async function saveImageBlobToIdb(blob, key) {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction('kv', 'readwrite');
tx.objectStore('kv').put(blob, key);
tx.oncomplete = () => resolve(true);
tx.onerror = () => reject(tx.error);
});
}
// 表示するときは Object URL を使う
async function loadImageUrlFromIdb(key) {
const blob = await idbGet(key);
return URL.createObjectURL(blob); // img.src にセット
}
InPrivate(シークレット)モードの落とし穴
- 割当てが通常より厳格で、短時間で上限に達しやすい。
- セッション終了時にデータが消えるため、永続ストレージとしては不適。
- 検証時は通常ウィンドウと InPrivate の双方で挙動を確認し、エラー再現の有無を切り分ける。
企業環境・組織ポリシーの影響
企業端末では、ブラウザのストレージ関連ポリシーや「終了時に閲覧データを削除」設定が有効化されていることがあります。症状としては「保存できたりできなかったりする」「再起動すると消える」などの断続的なトラブルです。IT 管理者向けに、次の観点を共有しましょう。
- ブラウザ終了時の自動消去ポリシーが有効か
- プロファイルのローミング/再プロビジョニングにより、ストレージがリセットされていないか
- ディスク残量が閾値を下回っていないか(仮想環境・VHDX・プロファイルコンテナ等)
永続化(PWA 向け):エビクションを避ける工夫
オフラインファーストな PWA では、アセットやデータを大量に保存します。OS の空き容量低下やブラウザのハウスキーピングでキャッシュが消えるのを抑えるため、永続化のリクエストを検討します。
// 可能ならストレージ永続化を要求(ユーザーの許可が必要)
async function ensurePersistence() {
if (navigator.storage && navigator.storage.persist) {
const isPersisted = await navigator.storage.persisted();
if (!isPersisted) {
const granted = await navigator.storage.persist();
console.log('Persistence', granted ? 'granted' : 'denied');
}
}
}
また、ユーザーがローカルにファイルとして保存・読み込みできるようにすると、ストレージ容量の制限を根本的に回避できます(File System Access API)。
// JSON をローカルファイルへ保存(ユーザー操作が必要)
async function exportJsonToFile(filename, obj) {
const handle = await window.showSaveFilePicker({
suggestedName: filename,
types: [{ description: 'JSON', accept: { 'application/json': ['.json'] } }]
});
const writable = await handle.createWritable();
await writable.write(new Blob([JSON.stringify(obj)], { type: 'application/json' }));
await writable.close();
}
衝突回避:マルチタブの同時書き込み
localStorage は同期 I/Oで、書き込みはブロック的に実行されます。複数タブが同一キーに書き込むと、最後に書いた方が勝つ「ロストアップデート」が起きがちです。最低限、storage イベントでの協調を入れておきましょう。
// 他タブの変更を受けて UI を更新
window.addEventListener('storage', (e) => {
if (e.key === 'userData') {
// e.newValue を UI に反映
}
});
// 争いを減らす:キーをシャーディングし、局所的な上書きを避ける
// 例: userData:profile, userData:filters, userData:layout
自動掃除(エビクション)と TTL の設計例
const MAX_ITEMS = 200; // 例:保持する履歴上限
function putWithMeta(key, value, ttlMs) {
const item = { value, ts: Date.now(), exp: ttlMs ? Date.now() + ttlMs : null };
localStorage.setItem(key, JSON.stringify(item));
}
function getWithMeta(key) {
const raw = localStorage.getItem(key);
if (!raw) return null;
const item = JSON.parse(raw);
if (item.exp && Date.now() > item.exp) {
localStorage.removeItem(key);
return null;
}
return item.value;
}
function vacuum(prefix) {
const entries = [];
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k.startsWith(prefix)) {
const it = JSON.parse(localStorage.getItem(k));
entries.push({k, ts: it.ts || 0});
}
}
entries.sort((a, b) => b.ts - a.ts);
for (let i = MAX_ITEMS; i < entries.length; i++) {
localStorage.removeItem(entries[i].k);
}
}
LZ-string を使った圧縮・解凍(導入例)
文字列データに偏りがあり、重複が多いときは圧縮が効きます。導入時は CPU/メモリコストを計測しましょう。
// 圧縮保存
function setCompressed(key, obj) {
const raw = typeof obj === 'string' ? obj : JSON.stringify(obj);
const comp = LZString.compressToUTF16(raw);
localStorage.setItem(key, comp);
}
// 解凍取得
function getCompressed(key) {
const comp = localStorage.getItem(key);
if (comp == null) return null;
const raw = LZString.decompressFromUTF16(comp);
try { return JSON.parse(raw); } catch { return raw; }
}
「安全に落ちる」UI 設計:ユーザー体験を壊さない
- 保存が失敗したらトースト通知で理由と対処(「容量が上限に達しました。履歴の削除またはファイル保存をご利用ください」)を伝える。
- エクスポート/インポートボタンを常設して、退避ルートを確保する。
- 同じキーへ断続的に書き込まない(デバウンス/スロットリング)。
- 書き込み前にサイズ見積もりを行い、限界の手前でユーザーに選択肢を提示する。
ブラウザ別の傾向(目安)
| ブラウザ | localStorage 上限の目安 | InPrivate/シークレット | 所感 |
|---|---|---|---|
| Microsoft Edge (Chromium) | 約 5 MB | より厳格/短命 | Chromium 系と同等。企業ポリシーの影響に注意 |
| Google Chrome | 約 5 MB | より厳格/短命 | 実運用でも 5 MB 目安を越えない設計が安全 |
| Firefox | 約 5 MB(実装差あり) | より厳格 | 計測ロジックを入れて挙動差を吸収 |
| Safari | 約 5 MB(バージョン差あり) | より厳格 | iOS Safari はシステム都合で消えることがある |
前提として、各ブラウザは「サイト全体のストレージ(IndexedDB などを含む)」に対する動的な割り当てやガベージ回収を持ち、細部は変わり得ます。「5 MB はあくまで安全ライン」と捉え、超える可能性があるデータは最初から IndexedDB に逃がすのが実務的です。
実務フロー:現場での切り分けから恒久対策まで
- 再現:該当ユーザー環境で DevTools を開き、コンソールに再現コードを投入。例外名・メッセージ・堆積状況を確認。
- 掃除:Local Storage の不必要なキー(古いバージョンのキャッシュ、ログ、巨大な Base64 画像など)を削除。
- 監視:localStorageSizeBytes() と Storage API でおおまかな使用量をダッシュボード表示。
- 縮小:キー名の短縮・JSON の正規化・圧縮の A/B 計測。
- 設計変更:用途別ストレージ に切り分け、大きいデータは IndexedDB へ移行。
- PWA 最適化:永続化の要求、アセットのキャッシュ戦略(Stale-While-Revalidate など)を調整。
- エクスポート経路:ファイル保存によるバックアップ導線を UI へ常設。
QA:よくある質問
Q. localStorage の 5 MB を超えて保存する方法は?
A. ありません。実装依存で多少の上下はあっても、設計としては超えない前提にし、IndexedDB もしくは 外部ファイル/クラウドに逃がします。
Q. どのデータから移行すべき?
A. 体感劣化を生みやすい「大きく頻繁に更新されるデータ」から。例えば検索結果キャッシュ、エディタのドラフト、画像プレビューなどは IndexedDB 向きです。
Q. 圧縮は常に有効?
A. いいえ。短い JSON や乱数っぽいデータは圧縮効率が悪く、CPU コストの方が上回ることがあります。対象を選ぶのがコツです。
Q. InPrivate でだけ落ちるのはバグ?
A. 仕様上の制限によるものが多いです。InPrivate をサポート対象外とするか、保存量を最小限にして「落ちない設計」にします。
チェックリスト(コピー&ペーストでチーム共有)
- [ ] localStorage に画像・巨大文字列を保存していないか
- [ ] JSON プロパティ名・キー名が冗長でないか
- [ ] 書込前に概算サイズを評価しているか
- [ ] 失敗時のフォールバック(IndexedDB 等)が実装されているか
- [ ] TTL と自動掃除の仕組みがあるか
- [ ] InPrivate での挙動をテストしているか
- [ ] 企業端末ポリシーの影響を確認したか
- [ ] エクスポート/インポート経路を UI に用意したか
- [ ] PWA の永続化(persist)を試しているか
テスト戦略:CI で「うっかり肥大化」を防ぐ
ユニットテストや E2E テストに「サイズが閾値を超えたら失敗する」アサーションを組み込みます。
// 疑似 localStorage を使ったサイズガード(例)
function assertWithinLocalStorageLimit(obj, limitBytes = 4.5 * 1024 * 1024) {
const serialized = JSON.stringify(obj);
const bytes = new Blob([serialized]).size;
if (bytes > limitBytes) {
throw new Error(`localStorage budget exceeded: ${bytes} bytes`);
}
}
「機能追加で設定オブジェクトが太る」などの回帰を、早期に検知できます。
まとめ:5 MB の「壁」を設計で越えない
- localStorage はおよそ 5 MB。上限を前提に薄く短く使う。
- 巨大データはIndexedDBかファイルへ。Base64 は避ける。
- 例外を握らず safeSetItem でフォールバック。TTL と掃除で肥大化を防止。
- PWA では永続化要求とキャッシュ戦略でエビクション耐性を高める。
- InPrivate/企業ポリシーの差異をテストに組み込み、ユーザー環境での再現性を担保する。
作業時間の目安(今回のケース)
不要キーの削除と動作テストで5〜10 分程度。恒久対策(圧縮・設計見直し・IndexedDB への移行)は、データ量と影響範囲に応じて段階的に進めると安全です。
補足:パフォーマンスと PWA の回避策
- localStorage は同期 I/Oのため、大容量データを頻繁に書き込むとメインスレッドをブロックし、描画パフォーマンスに影響することがあります。
- PWA であれば File System Access API でローカルファイルに保存する、あるいはクラウド同期ストレージを併用することでクォータ問題を回避可能です。
実装テンプレート集(すぐ使える断片)
保存前のヘルスチェック
function canSaveToLocalStorage(nextBytes, threshold = 4.5 * 1024 * 1024) {
try {
const current = localStorageSizeBytes();
return (current + nextBytes) < threshold;
} catch {
return false;
}
}
フォーム下書き:セーフティ付き保存
async function saveDraft(formState) {
const raw = JSON.stringify(formState);
const bytes = new Blob([raw]).size;
if (!canSaveToLocalStorage(bytes)) {
// 閾値超過なら IndexedDB へ
await idbSet('draft', raw);
return 'idb';
}
try {
localStorage.setItem('draft', raw);
return 'ls';
} catch (e) {
await idbSet('draft', raw);
return 'idb';
}
}
巨大ログの分割保存(シャーディング)
function saveLogSharded(prefix, text, chunkSize = 64 * 1024) {
// 既存チャンクを掃除
for (let i = 0;; i++) {
const k = `${prefix}:${i}`;
if (!localStorage.getItem(k)) break;
localStorage.removeItem(k);
}
// チャンク化して保存
for (let i = 0; i < text.length; i += chunkSize) {
const chunk = text.slice(i, i + chunkSize);
localStorage.setItem(`${prefix}:${i / chunkSize}`, chunk);
}
localStorage.setItem(`${prefix}:chunks`, String(Math.ceil(text.length / chunkSize)));
}
Base64 からの脱却:ファイル保存のガイド
async function saveDataAsFile(name, data) {
const handle = await window.showSaveFilePicker({ suggestedName: name });
const writable = await handle.createWritable();
await writable.write(new Blob([data]));
await writable.close();
}
運用ノート:ログ収集とユーザー支援
- 匿名の容量メトリクスを収集(平均・P90・P99 の使用量)し、リリース前に安全域を可視化。
- 復旧ガイドをアプリ内に設ける(「設定 → ストレージのクリーンアップ」「バックアップのエクスポート」)。
- サポートチーム向けに「DevTools の開き方」と「Local Storage の削除手順」を手順書化。
最後に:設計原則のひとこと
localStorage は「メモ付きスイッチ」くらいに留める。――これだけで多くの事故は避けられます。大きなデータは最初から大きな器(IndexedDB / ファイル)へ。限界を設計に組み込めば、QuotaExceededError は怖くありません。

コメント