A localStorage draft fails in three ways once forms get big: the synchronous write blocks the main thread on every autosave, the roughly 5 MB per-origin limit throws QuotaExceededError, and it cannot hold the File a user attached — so the restored draft is missing its attachment.

Draft persistence and autosave sets out when and how often to save. This page is about where, once the draft is too large or too rich for a string store. IndexedDB is asynchronous, stores structured data including Blob and File natively, and has quotas measured as a share of free disk rather than a fixed few megabytes.


Context and prerequisites

Move a draft to IndexedDB when any of these is true:

  • It includes files. An attached PDF or image can be stored as the File itself, so a restored draft still has it. localStorage can only hold strings; base64-encoding a file inflates it by a third and still blows the quota.
  • It is large. Long rich-text bodies, spreadsheets-as-forms or hundreds of repeatable rows can exceed 5 MB across all drafts on an origin.
  • Autosave causes jank. localStorage.setItem with a 1 MB string can take several milliseconds on a mid-range phone, on the main thread, every debounce interval.

Keep a tiny index — draft ids, versions and timestamps — in localStorage if you need a synchronous read at startup, and put the bodies in IndexedDB.

localStorage and IndexedDB for drafts localStorage is synchronous, limited to roughly five megabytes per origin, stores only strings so files must be encoded, blocks the main thread in proportion to size, and throws QuotaExceededError when full. IndexedDB is asynchronous, allows a large share of free disk, stores File and Blob objects natively, does its work off the main thread, and reports quota errors through the transaction. Property localStorage IndexedDB API synchronous asynchronous (transactions) Capacity about 5 MB per origin share of free disk Files strings only; base64 +33% File and Blob stored natively Main thread blocks while serialising structured clone, off-thread I/O When full throws on setItem transaction error; handle it

The core pattern: a minimal promise wrapper and a drafts store

const DB_NAME = "form-drafts";
const DB_VERSION = 1;
const STORE = "drafts";

export interface StoredDraft<T> {
  id: string;             // e.g. "claim-form:record-812"
  schemaVersion: number;  // see the migration guide
  savedAt: number;
  values: T;              // may contain File / Blob objects
}

let dbPromise: Promise<IDBDatabase> | null = null;

function openDb(): Promise<IDBDatabase> {
  // Reuse one connection per page: opening is slow and each open holds a lock.
  return (dbPromise ??= new Promise((resolve, reject) => {
    const req = indexedDB.open(DB_NAME, DB_VERSION);
    req.onupgradeneeded = () => {
      const db = req.result;
      if (!db.objectStoreNames.contains(STORE)) {
        const store = db.createObjectStore(STORE, { keyPath: "id" });
        store.createIndex("savedAt", "savedAt");   // for age-based cleanup
      }
    };
    req.onsuccess = () => {
      const db = req.result;
      // Another tab upgrading the schema asks us to close; if we refuse, its
      // upgrade blocks forever.
      db.onversionchange = () => { db.close(); dbPromise = null; };
      resolve(db);
    };
    req.onerror = () => { dbPromise = null; reject(req.error); };
    req.onblocked = () => reject(new Error("IndexedDB upgrade blocked by another tab"));
  }));
}

function tx<R>(mode: IDBTransactionMode, run: (s: IDBObjectStore) => IDBRequest<R>): Promise<R> {
  return openDb().then((db) => new Promise<R>((resolve, reject) => {
    const t = db.transaction(STORE, mode);
    const req = run(t.objectStore(STORE));
    // Resolve on transaction COMPLETE, not request success: a write is only
    // durable once the transaction commits.
    t.oncomplete = () => resolve(req.result);
    t.onerror = () => reject(t.error);
    t.onabort = () => reject(t.error ?? new Error("transaction aborted"));
  }));
}

export const drafts = {
  put: <T>(d: StoredDraft<T>) => tx("readwrite", (s) => s.put(d)),
  get: <T>(id: string) => tx<StoredDraft<T> | undefined>("readonly", (s) => s.get(id)),
  delete: (id: string) => tx("readwrite", (s) => s.delete(id)),
};

Values go in as they are: File objects, Dates and nested arrays survive structured cloning, so restoring a draft hands back a real File you can put straight back into the upload field’s state.


Step-by-step walkthrough

  1. Open one connection and cache the promise. Every indexedDB.open is expensive; opening per save adds tens of milliseconds and contends for locks.
  2. Create the store in onupgradeneeded with a key path. Using id as the key path lets you put whole draft objects and index them by savedAt for cleanup.
  3. Handle versionchange. Close the connection when another tab needs to upgrade; otherwise a new deployment’s upgrade stalls in every tab still running the old code.
  4. Resolve writes on complete. A request’s success fires before the transaction commits; if the tab closes in between, the write is lost. Wait for oncomplete.
  5. Store File objects directly. No base64. The restored draft’s file field then works with the same code as a freshly chosen file, including validating file type and size before upload.
  6. Catch quota and blocked-storage errors and keep the form working. Show a quiet “draft not saved on this device” status; never block typing because persistence failed.
What a stored draft record contains The id key combines the form name and the record id so drafts for different records never collide. The schema version and savedAt timestamp support migration and age-based cleanup through an index. The values hold ordinary field data including dates and arrays. Attached files are stored as File objects inside the values, so they come back as real files on restore. id (key path) form name + record id One draft per record. schemaVersion, savedAt Migration on load. savedAt is indexed for cleanup. values Plain field data. Dates and arrays survive cloning. File objects Stored as-is. Restored as real files.

Failure modes and edge cases

1. Safari and private browsing

Private windows in some browsers provide an IndexedDB that works but is wiped when the window closes, or has a very small quota. Feature-detect by attempting an open and a small write at startup; if it fails, fall back to memory and tell the user drafts are not kept.

2. Eviction under storage pressure

Unless the origin has persistent storage, the browser may evict IndexedDB data when the disk is low. For forms where losing a draft is costly, request it:

if (navigator.storage?.persist && !(await navigator.storage.persisted())) {
  await navigator.storage.persist();   // browsers may grant silently or decline
}

Use navigator.storage.estimate() to show usage in settings for heavy users.

3. Structured-clone failures

Functions, DOM nodes, class instances with private fields and some proxies (including reactive proxies from Vue) throw DataCloneError. Convert state to plain objects before put — in Vue, toRaw on each level or a deliberate serialiser.

4. Many tabs, one database

Two tabs can write the same draft id; IndexedDB serialises transactions, so you will not corrupt data, but you can still overwrite. Combine this store with the lease from syncing drafts across tabs with BroadcastChannel.

5. Drafts that never get cleaned up

Delete the draft on successful submit, and periodically delete drafts older than your retention window using the savedAt index. Stale files attached to abandoned drafts are the main source of surprising storage use.

A resilient save path The autosave first tries an IndexedDB put and reports saved on this device when the transaction completes. If that fails and the draft has no files and is small, it tries localStorage. If that also fails, the draft is kept in memory only and the status line says the draft will not survive closing the tab. Typing is never blocked at any level. IndexedDB put Resolve on transaction complete. Status: saved on this device. error localStorage (small, no files) Only if the draft fits and has no File. Status: saved on this device (without attachments). error Memory only Keep state; stop retrying each keystroke. Status: draft will not survive closing this tab.

Verification checklist


Frequently Asked Questions

Should I use a library such as idb or Dexie instead of the raw API?

Either is a good choice and removes most of the boilerplate here; idb is a thin promise wrapper and Dexie adds querying and schema helpers. The rules on this page — one connection, resolve on complete, handle versionchange, catch quota errors — apply regardless of the wrapper.

Is IndexedDB fast enough to save on every keystroke?

It is fast, but you should still debounce. Each transaction has fixed overhead, and saving hundreds of times per minute gains nothing over saving a second after the user pauses. Debounce as for any draft and flush on pagehide.

Are drafts in IndexedDB private?

They are readable by any script running on your origin and by anyone with access to the device profile. Do not store secrets such as passwords or full card numbers in drafts; exclude those fields from the persisted values entirely.


Related

← Draft Persistence and Autosave