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
Fileitself, so a restored draft still has it.localStoragecan 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.setItemwith 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.
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
- Open one connection and cache the promise. Every
indexedDB.openis expensive; opening per save adds tens of milliseconds and contends for locks. - Create the store in
onupgradeneededwith a key path. Usingidas the key path lets youputwhole draft objects and index them bysavedAtfor cleanup. - 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. - Resolve writes on
complete. A request’ssuccessfires before the transaction commits; if the tab closes in between, the write is lost. Wait foroncomplete. - Store
Fileobjects 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. - 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.
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.
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.