#AI#TypeScript#DevOps

Offline AI Drafts in IndexedDB: Migrations, Multi-Tab Coordination, and Crash Recovery

Writing offline drafts to IndexedDB does not guarantee reliable persistence. This article addresses multiple tabs, crashes, and storage eviction with the versionchange protocol, atomic schema upgrades, batched backfills, a transactional outbox, and idempotent recovery.

Key Points

  • Topic Focus: AI、TypeScript、DevOps
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

Offline AI applications often store prompts, drafts, and pending sync jobs in IndexedDB, but a “successful write” proves only that one transaction committed. Another outdated tab may block an upgrade, the browser may redeliver a job after a crash, and site data may be cleared by the user or evicted under storage policy. Local-first AI applications explains the product value of a local-first approach; this article focuses on the database lifecycle and provable recovery contracts.

Turn version upgrades into a cross-tab protocol

Explicitly pass an integer version when opening the database. Increasing the version triggers upgradeneeded; its versionchange transaction is the only schema-change boundary for creating object stores and indexes or performing small, deterministic transformations. The handler applies migrations one version at a time based on event.oldVersion, rather than inferring the current state because “some store appears to exist.” If any request in any step fails, call abort(); until the transaction commits, both the version number and schema remain in their previous state.

Before an upgrade, other connections receive versionchange. Every page should immediately stop accepting new writes, close its connection, and prompt the user to refresh. The upgrade request should also listen for blocked and record the blocking duration and target version. BroadcastChannel can notify other tabs to drain in advance, but it cannot replace the correctness guarantees of IndexedDB's own versionchange mechanism. After a timeout, display an explicit “Close outdated pages” state; never silently fall back to the old schema and continue writing.

ts
const request = indexedDB.open("ai-workspace", 7);

request.onupgradeneeded = (event) => {
  const db = request.result;
  const tx = request.transaction!;
  if (event.oldVersion < 7) {
    db.createObjectStore("outbox", { keyPath: "operationId" });
    const meta = db.objectStoreNames.contains("meta")
      ? tx.objectStore("meta")
      : db.createObjectStore("meta", { keyPath: "key" });
    meta.put({ key: "backfill", phase: "pending" });
  }
};
request.onblocked = () => reportBlockedUpgrade(7);

Do not wait for network requests inside an upgrade handler, and do not introduce ordinary Promises and assume the transaction will remain active; IndexedDB transactions commit automatically according to request-queue and event-loop rules. This approach to schema upgrades can be compared with Managing database migrations, but browsers have no operations staff to close every connection on the user's behalf.

Use recoverable phases for large backfills, not one long upgrade

A few hundred records can be transformed inside a versionchange transaction, but hundreds of thousands of embeddings or sessions should not lock the page inside one extremely long upgrade. The schema transaction should only add new stores, indexes, and migration metadata. After the page opens, process a limited number of records across multiple ordinary readwrite transactions. Each transaction writes both the destination records and a persistent cursor, allowing recovery to continue from the last committed cursor after a crash. Transformations must derive stable primary keys from stable inputs so that repeating a batch does not create duplicates.

During the backfill, the read path checks the new format first, then reads and converts the old format if the new record is missing. The write path either dual-writes according to the migration phase or writes a compatible envelope that both old and new versions can understand. Migration state may advance only in one direction: pending -> copying -> verifying -> complete. Stop writing the old format only after sampled counts, primary-key sets, and content digests pass verification. Retain the old store for at least one application rollback window, then remove it in a later, separate version. Atomicity here means “each data batch and its cursor share one transaction”; do not present a backfill spanning multiple transactions as an atomic database-wide cutover.

Drafts and outbox entries must commit together

Saving a draft and creating an AI or synchronization job should happen in the same readwrite transaction. The outbox uses a client-generated operationId that remains unchanged across retries and stores the payload digest, schemaVersion, createdAt, attempt, status, and server-side idempotency key. If the transaction fails, neither record is visible. Once it commits, the recovery worker can still find the work even if the page crashes immediately afterward.

Claim jobs with a lease carrying a fencing token, rather than a permanent running flag. A short transaction reads a pending or expired job, increments its monotonic leaseGeneration, and writes a random ownerId, the new generation, and leaseExpiresAt. The worker retains this token set in the current execution context before making the network call.

Acknowledging the result must open a new readwrite transaction and reread the job. It may CAS the job into a terminal state only if the status is still leased and the ownerId, leaseGeneration, and payload digest all match. If A's lease expires and B has already claimed the job, A's late response may only be discarded or written to a separate, bounded reconciliation record; it must never overwrite B. A crash can still occur after the server succeeds but before the client records the acknowledgment, so recovery semantics can guarantee only at-least-once attempts. The server must deduplicate by operationId, but remote deduplication cannot replace local fencing. Multi-tab contention can borrow techniques from Optimistic vs. pessimistic locking; it must not rely on an in-memory Boolean for mutual exclusion.

Treat eviction and rollback as routine events

navigator.storage.persist() merely requests persistent storage, and the result may be false. persisted() and estimate() provide observability, not a promise that data will never be lost. After catching QuotaExceededError, first stop accepting large model assets and derived caches, preserve unsynchronized drafts and the outbox, and provide export or synchronization options. If an original draft exists only in the browser, users must be told explicitly. A server-side replica, encrypted export, or another device is the disaster-recovery solution. Caches in Progressive Web Applications cannot replace backups of user data.

Database versions can only move forward. When rolling back to an older bundle, the old code may encounter a higher version and trigger VersionError, or it may open the current version but misinterpret new records. The release manifest must declare the range of schemas the application can read and write. If they are incompatible, enter a read-only or export interface; never use deleteDatabase() to masquerade as a rollback. First release a version that can read both formats, then upgrade the database. Remove the old decoder and old store only after the observation period ends.

At minimum, tests should use two real pages to produce a blocked upgrade and terminate a page separately during the schema upgrade, batch write, after network success, and during confirmation writeback. They should also let A's lease expire, allow B to complete with a higher generation, then deliver A's late response and assert that the stale token cannot change the job state. Continue injecting quota exhaustion, duplicate claims, an old bundle opening a new database, and cleared site data. Release gates should verify that there are no partially written drafts, no permanently running jobs, and only one server-side effect for a duplicated operation, and that processing can resume from the most recently committed cursor. Only then is IndexedDB a recoverable local source-of-truth layer rather than a cache that merely happens to still exist.

Further reading

Related

Related Content