Browser storage failures are awkward because they look like app bugs until they do not. A write can fail because the code is wrong, because the browser hit a quota, because private browsing changes persistence behavior, or because the test runner is reusing a profile that is already full. If you need to test browser storage quota errors, the main job is not just forcing a failure, it is classifying the failure correctly.

The short version is this: localStorage, sessionStorage, and IndexedDB fail differently, they fail at different thresholds, and they are not interchangeable. A good test suite checks both the failure path and the fallback path. A good bug report also says whether the problem is in application logic, browser policy, or the test environment.

First, separate the three storage models

Before you script anything, define which storage layer you are testing.

  • localStorage is synchronous key-value storage, persistent per origin. It is small, simple, and easy to break with oversized payloads. MDN documents that it throws a QuotaExceededError when storage limits are exceeded. See Window.localStorage.
  • sessionStorage is also synchronous key-value storage, but scoped to the tab session. It is usually smaller in practical use and is cleared when the page session ends. See Window.sessionStorage.
  • IndexedDB is asynchronous and designed for structured client-side data. It is the right place to test offline caches, queueing, and large datasets. See Using IndexedDB and the Indexed Database API 3.0 specification.

If your app writes user drafts, cached API responses, or offline queue items, IndexedDB is usually the first place to test fallback logic. If it stores flags, tokens, or small preferences, localStorage and sessionStorage are the likely failure points.

What counts as a real storage-limit bug?

Not every QuotaExceededError is a product defect. The bug depends on where the code fails and what the app promised to do.

App bug

This is your bug when the application:

  • crashes on write instead of catching the exception,
  • loses user data even though a fallback storage path exists,
  • leaves the UI in a half-saved state after a failed write,
  • assumes storage writes always succeed.

Platform limit

This is browser or device behavior when:

  • the quota is genuinely exhausted,
  • the browser blocks a storage type in a special mode,
  • private browsing or strict privacy settings reduce persistence,
  • the storage API behaves as documented, but your app has no recovery path.

Test environment bug

This is the test setup when:

  • the same browser profile is reused across cases and accumulates stale data,
  • parallel tests share the same origin and collide,
  • a seed fixture fills storage before the test starts,
  • CI runs with a profile size or disk pressure that local dev does not have.

A useful triage question is simple: does the app fail only after the storage area is intentionally filled, or does it fail on an ordinary write with plenty of room left? The first is likely a quota handling issue. The second is often a logic or environment issue.

How to make storage fail on purpose

The safest approach is to force the storage layer into a known bad state, then observe how the app responds.

Method 1, fill localStorage or sessionStorage with large values

This is the simplest reproduction for synchronous key-value storage. Keep adding large strings until a write throws.

function fillStorage(storage: Storage, keyPrefix = 'stress_', chunkSize = 1024 * 1024) {
  const chunk = 'x'.repeat(chunkSize);
  let i = 0;

try { while (true) { storage.setItem(${keyPrefix}${i}, chunk); i += 1; } } catch (error) { return error; } }

const error = fillStorage(window.localStorage); console.log(error?.name, error?.message);

Use this in a dedicated test origin or a disposable profile. Do not run it against a shared test account unless cleanup is guaranteed.

Method 2, intercept the app’s storage wrapper

If your app uses a wrapper around storage, you can simulate the exception directly. This is often better than filling bytes when you only want to verify fallback logic.

const originalSetItem = window.localStorage.setItem.bind(window.localStorage);
window.localStorage.setItem = () => {
  throw new DOMException('Quota exceeded', 'QuotaExceededError');
};

That approach is useful when the code under test should:

  • save to IndexedDB after localStorage fails,
  • show a non-blocking warning,
  • continue with in-memory state,
  • avoid retry loops that freeze the UI.

Method 3, fill IndexedDB with realistic data

IndexedDB is asynchronous, so tests should verify that rejection or failure handling does not get lost in promise chains. The exact quota behavior varies by browser and profile state, so it is best to combine real writes with a fallback assertion rather than assuming one precise threshold.

async function writeLargeIndexedDbRecord(db: IDBDatabase, sizeMb = 10) {
  const tx = db.transaction('items', 'readwrite');
  const store = tx.objectStore('items');
  const payload = { id: crypto.randomUUID(), blob: 'x'.repeat(sizeMb * 1024 * 1024) };
  store.put(payload);

return new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error); }); }

Reproducible browser automation pattern

When you run storage tests in Playwright, the important detail is isolation. Create a fresh context, use a dedicated origin, and avoid reusing a profile unless the test specifically checks persistence across launches.

import { test, expect } from '@playwright/test';
test('shows fallback UI when localStorage write fails', async ({ page }) => {
  await page.addInitScript(() => {
    const original = window.localStorage.setItem.bind(window.localStorage);
    window.localStorage.setItem = () => {
      throw new DOMException('Quota exceeded', 'QuotaExceededError');
    };
  });

  await page.goto('http://localhost:3000');
  await page.getByRole('button', { name: 'Save draft' }).click();

  await expect(page.getByText('Draft saved locally in memory')).toBeVisible();
});

A few notes matter here:

  • addInitScript runs early enough to patch page code before app logic executes.
  • For IndexedDB, a storage wrapper or service layer is usually easier to control than browser internals.
  • If your test starts failing only on CI, compare browser version, profile reuse, and disk availability before blaming the app.

What good graceful fallback looks like

A fallback is not just “show an error toast.” It should preserve user work and keep the UI honest.

Good patterns

  • Save to in-memory state and mark it as temporary.
  • Queue retries and show the queue status.
  • Degrade to smaller records or fewer cached items.
  • Ask the user to free space only when the app can verify that it needs more room.

Bad patterns

  • Swallowing the exception and pretending the save succeeded.
  • Retrying in a tight loop.
  • Clearing unrelated data to make room without telling the user.
  • Switching from IndexedDB to localStorage for large data, which just moves the limit problem.

A strong fallback test checks three things:

  1. the user does not lose input,
  2. the UI states what happened,
  3. the next successful save path is still available.

A practical decision framework for triage

Use this when a storage-related test fails and you need to assign ownership.

Symptom Likely cause First check Owner
QuotaExceededError on a deliberately large write Expected platform limit Confirm the test filled the intended storage area App or QA, depending on fallback behavior
Write fails on a small payload App logic or environment issue Inspect profile reuse, origin, and storage wrapper code App first, then test infra
Works locally, fails in CI Environment difference Check browser version, profile cleanup, parallel execution Test infra
Fallback UI appears, but data disappears App bug Verify fallback state survives navigation or reload App
IndexedDB writes hang or time out Async handling bug or browser issue Check transaction error handlers and promise wiring App or environment

Common mistakes that hide the real failure

Reusing the same browser profile across cases

This makes quota tests noisy. One test may fill storage and the next test inherits the mess. Use clean contexts or explicit teardown.

Testing the wrong storage layer

A failing localStorage test tells you little about an IndexedDB cache. They have different APIs, persistence behavior, and error handling paths.

Only checking for thrown errors

Some apps catch the exception but still corrupt internal state. Assert on UI state, stored data, and the next navigation step.

Ignoring private or strict browsing modes

Browsers may restrict persistence differently in private contexts. If the feature must work there, include that mode in the matrix and document the expected degradation.

Treating quota as a fixed number

Do not hard-code a specific byte threshold unless your product depends on it and you have a documented reason. Browser storage limits vary by browser, storage type, device conditions, and profile state.

A compact test matrix that catches most regressions

Use a small matrix that is easy to maintain:

  • localStorage, with a forced quota failure
  • sessionStorage, with a forced quota failure
  • IndexedDB, with a large write and a fallback assertion
  • fresh profile, no existing user data
  • reused profile, near-capacity storage state
  • normal mode, private or restricted mode if supported by your product

That is enough to tell whether the app is resilient, whether the test environment is clean, and whether your fallback logic is genuinely exercised.

When to stop testing the browser and inspect your app architecture

If storage-limit tests keep failing in different browsers for different reasons, the real issue may be architectural.

Look for these signals:

  • one code path writes too much to localStorage,
  • storage writes happen inside UI event handlers without error boundaries,
  • the app has no single abstraction for persistence,
  • offline features depend on optimistic writes with no retry queue,
  • error handling is duplicated across components.

A thin persistence layer helps here. If every component writes directly to browser storage, quota handling becomes scattered and easy to miss.

Bottom line

To test browser storage quota errors well, do not chase a single byte limit. Instead, reproduce a controlled failure, verify that the app catches the right exception, and confirm that users keep their work through a documented fallback path. The best signal is not “the test failed,” it is “the failure was correctly classified as app, browser, or environment.”

FAQ

How do I know whether a QuotaExceededError is expected?

If the test deliberately filled the target storage area, the error is expected. If it happened on a small ordinary write, inspect the app logic and the test environment first.

Should I test localStorage, sessionStorage, and IndexedDB the same way?

No. localStorage and sessionStorage are synchronous key-value stores, while IndexedDB is asynchronous and transaction-based. The failure and assertion strategy should match the API.

Is it enough to assert that an error toast appears?

No. A good fallback test also verifies data preservation, UI state, and the next successful save path.

Why do these tests fail only in CI?

Common reasons are reused profiles, stale browser data, parallel test collisions, or browser version differences. Start by checking cleanup and isolation.

Can I simulate quota errors without filling storage?

Yes. For app logic, patching the storage call to throw a QuotaExceededError is often cleaner than trying to exhaust real storage.

What is the most important thing to log when storage fails?

Log the storage type, operation, origin, browser mode, and whether the app entered a fallback path. That is usually enough to separate application bugs from platform limits.