A storage quota failure is not just an edge case. If your app autosaves drafts, caches documents, or keeps offline state in the browser, a write that fails silently can look like success until a user refreshes and loses data. The goal of a good test is not to prove a specific byte limit, because that limit varies by browser, storage type, profile, and device. The goal is to prove your app detects failure, preserves user intent, and shows a usable fallback.

For teams that need to test browser storage quota exceeded error behavior, the practical question is simple: what should happen when localStorage, sessionStorage, or IndexedDB refuses another write? The answer should be explicit in product code, then verified in automation.

Do not make the test assert an exact quota size. Assert the fallback path, the user-facing message, and the data-preservation behavior.

What actually fails, and why the distinction matters

The APIs are easy to confuse because they all store data in the browser, but they fail differently:

  • localStorage and sessionStorage are synchronous key-value stores and typically throw immediately on setItem() when the browser refuses the write.
  • IndexedDB is asynchronous, so quota pressure usually appears as a rejected request, transaction error, or aborted transaction.
  • The browser may also evict data later, especially for origin storage under pressure, so a successful write does not guarantee long-term persistence.

That means your test strategy should separate three concerns:

  1. Write-time failure, can the app catch the exception or rejected transaction?
  2. Fallback behavior, does the UI switch to a safer mode?
  3. Recovery behavior, can the user continue, export, retry, or sign in to a server-backed path?

For background on the platform APIs, see the MDN docs for Web Storage, IndexedDB, and the StorageManager API. The quota and eviction model also depends on browser implementation details, so browser-specific documentation is worth checking when a failure is intermittent.

The minimum behavior to define before you automate

Before writing a test, define the expected product behavior in code or in a short test plan. If the team has not agreed on this, the automation will only encode ambiguity.

A useful checklist is:

  • What data is at risk, for example drafts, form state, offline queue, or cache.
  • What should happen on failure, for example inline warning, toast, modal, or switch to server save.
  • Whether the app retries, compresses, prunes old items, or falls back to memory.
  • Whether the user can export or copy unsaved content.
  • Whether the error is telemetry-worthy and deduplicated.

A good fallback is usually one of these patterns:

  • Degrade gracefully, keep the app usable and warn the user.
  • Switch storage tier, move from browser persistence to session-only or server-backed storage.
  • Reduce payload, drop nonessential cached items or compress large blobs.
  • Block the action, if the data is too important to risk silent loss.

How to trigger quota failures reliably enough for automation

There is no universal, deterministic “fill browser storage to exactly X MB” recipe. That is why reliable tests usually use one of three approaches.

1) Fill the store until the browser throws

This is the most direct approach for localStorage and sessionStorage. Write repeatedly until setItem() throws a QuotaExceededError or a browser-specific equivalent.

import { test, expect } from '@playwright/test';
test('shows fallback when localStorage is full', async ({ page }) => {
  await page.goto('/drafts');

  const result = await page.evaluate(() => {
    const payload = 'x'.repeat(1024 * 256); // 256 KB chunks
    let i = 0;
    try {
      while (i < 200) {
        localStorage.setItem(`fill-${i}`, payload);
        i++;
      }
      return { filled: i, threw: false };
    } catch (error) {
      return {
        filled: i,
        threw: true,
        name: error instanceof Error ? error.name : 'unknown'
      };
    }
  });

  expect(result.threw).toBeTruthy();
  await expect(page.getByText(/storage full|could not save|offline draft/i)).toBeVisible();
});

This style is useful because it exercises real browser behavior. The downside is that the exact stopping point varies, so do not assert on the iteration count.

2) Stub the storage layer in app code

If your goal is to verify UI fallback logic, a mock storage adapter is often more stable than forcing a true browser limit. For example, if your app wraps persistence behind saveDraft(), make that wrapper reject with a quota-like error in test mode.

This approach is especially useful for failure paths that are hard to reproduce in every browser or CI runtime. It tests your application logic, not the browser’s quota implementation.

3) Use a high-volume payload against IndexedDB

IndexedDB failure usually takes longer to reproduce because the quota is larger and browser-managed. A test can write records with large blobs until a request or transaction fails, then verify the app catches the failure path.

import { test, expect } from '@playwright/test';
test('handles IndexedDB write failure gracefully', async ({ page }) => {
  await page.goto('/editor');

  const failed = await page.evaluate(async () => {
    const open = indexedDB.open('quota-test', 1);
    const db = await new Promise<IDBDatabase>((resolve, reject) => {
      open.onupgradeneeded = () => open.result.createObjectStore('docs');
      open.onsuccess = () => resolve(open.result);
      open.onerror = () => reject(open.error);
    });

    try {
      for (let i = 0; i < 500; i++) {
        const tx = db.transaction('docs', 'readwrite');
        tx.objectStore('docs').put(new Blob([new Uint8Array(1024 * 1024)]), `doc-${i}`);
        await new Promise<void>((resolve, reject) => {
          tx.oncomplete = () => resolve();
          tx.onerror = () => reject(tx.error);
          tx.onabort = () => reject(tx.error);
        });
      }
      return false;
    } catch {
      return true;
    } finally {
      db.close();
    }
  });

  expect(failed).toBeTruthy();
  await expect(page.getByRole('alert')).toContainText(/storage/i);
});

This is intentionally blunt. The point is not elegance, it is forcing the app to cross the failure boundary.

A practical test matrix for localStorage, sessionStorage, and IndexedDB

Use a small matrix so the team knows which layer each test covers.

Storage type Failure mode to simulate Best assertion Notes
localStorage setItem() throws Warning UI, alternate save path Synchronous, easiest to test directly
sessionStorage setItem() throws Session-only fallback, cleared-state messaging Similar to localStorage, but scoped to the tab/session
IndexedDB request or transaction failure Retry, conflict handling, offline queue behavior Asynchronous, test transaction completion and abort paths
Any browser storage origin storage pressure Data preserved elsewhere, user informed Browser behavior varies, avoid exact quota assertions

If you only have time for one scenario, test the path that protects user-authored text first. Draft editors and autosave systems deserve priority over caches.

What to assert in the UI, not just in the console

A lot of storage-failure bugs pass unit tests because the exception is caught somewhere, but the user still loses data later. A browser automation test should confirm visible behavior.

Recommended assertions:

  • The save indicator changes from “saved” to “save failed” or similar.
  • The app keeps the draft text in the editor.
  • The user can retry, export, or continue editing.
  • The app does not keep spinning forever.
  • The app does not show a false success message after the write failed.

If your product uses a storage fallback UI, verify the copy is specific. “Something went wrong” is not enough. “Your browser storage is full, we saved this draft in the current session only” gives the user a path forward.

The most expensive bug here is not the thrown exception, it is the false success state that convinces a user their content is safe.

How to make these tests stable in CI

Browser storage behavior is sensitive to profile reuse, test isolation, and parallelism. A flaky quota test usually means the environment is not controlled enough.

A few rules help:

  • Start with a fresh browser context per test.
  • Do not depend on prior test runs to fill storage.
  • Clear storage explicitly in setup and teardown.
  • Use a deterministic fixture page that stores data only when the test asks it to.
  • Prefer visible app state over internal storage counts.

If you need to inspect storage while debugging, use the browser devtools protocol or the test runner’s page evaluation, but keep the verification path in the product UI. The test should remain readable even when the storage implementation changes.

Failure modes worth including in code review

When a team adds quota handling, the bug is often not the missing catch block. It is one of these mistakes:

  • Catching the exception but swallowing it without notifying the user.
  • Showing success before the write completes.
  • Falling back to memory, then losing the draft on navigation.
  • Assuming localStorage and IndexedDB fail the same way.
  • Testing on one browser only and assuming the same quota behavior everywhere.

For browser-specific behavior, remember that Quota Management is not standardized as a single number. The Storage Standard and browser documentation describe the platform model, but individual engines can still differ in limits and eviction behavior.

A simple decision rule for teams

If the question is whether to simulate a true storage limit or mock the failure, I would use this rule:

  • Use real browser quota pressure when you want to prove the browser/runtime path, especially for localStorage and session-scoped behavior.
  • Use a mocked failure when you want a stable regression test for UI and fallback logic.
  • Use both when the app stores drafts or offline work that must not disappear silently.

That split keeps the suite fast without hiding the real browser behavior entirely.

Who should skip the quota-fill approach

This technique is not the best fit if:

  • Your app stores only noncritical preferences, where a mocked failure test is enough.
  • The storage layer is abstracted behind a backend sync service, and browser storage is just a cache.
  • Your CI environment makes real quota pressure too slow or unstable to reproduce consistently.

In those cases, keep one focused integration test for the browser behavior and move the rest of the failure coverage into unit or component tests around the storage adapter.

FAQ

Is QuotaExceededError always the exact error name?

No. Browsers may expose slightly different error details or exception names depending on the API and engine. Test the user-facing fallback and inspect the exception only as a secondary signal.

Can I set a fake quota in the browser?

Not through standard web APIs. For deterministic tests, it is usually easier to mock the storage wrapper in the app or fill the storage until the browser rejects the write.

Should I test localStorage and IndexedDB the same way?

No. localStorage and sessionStorage fail synchronously, while IndexedDB fails through request or transaction errors. Your test should match the API’s failure model.

How do I know whether the fallback UI is good enough?

A good fallback preserves the user’s content, explains what happened, and gives a next step. If the user can continue working without guessing, the fallback is probably acceptable.

What matters more, exact quota size or graceful recovery?

Graceful recovery. The exact size is browser-dependent and can change. The product requirement should be that storage failures do not silently discard user data.

The short version is this: if your app uses browser storage for anything important, test the failure path as deliberately as the happy path. Quota pressure is one of the few browser behaviors that can turn a green save indicator into permanent data loss if the app does not respond correctly.