Persisting Upload State in IndexedDB

IndexedDB gives an in-progress upload durable, structured storage for its id, current offset, and chunk records, so a reload or crash resumes from the exact byte rather than starting over.

A resumable transfer needs a memory of where it stopped. localStorage only holds strings and caps out around 5 MB, which is fine for a single URL but useless for chunk payloads. IndexedDB is the right tier for resumable upload state machines in the frontend UX, chunking and progress tracking layer: it stores Blobs natively, supports indexed lookups, and persists across sessions. This guide stores one record per upload plus the slices produced by slicing large files with Blob.slice, then restores them on reload.

When to use this approach

  • You chunk files yourself (not via a turnkey protocol) and need somewhere durable to track the next offset and which chunks already succeeded.
  • You want recovery from a tab crash or browser restart, not just an in-page network blip.
  • You must store the actual chunk bytes because the original File reference is lost after a reload (a re-selected file is a new object).

Prerequisites

  1. A modern browser with IndexedDB (all evergreen browsers; Safari included).
  2. Files sliced into Blob chunks — see slicing large files with Blob.slice.
  3. TypeScript with lib: ["dom", "esnext"] for the IndexedDB types.

Implementation

The module wraps the callback-based IndexedDB API in promises, defines two stores (uploads for metadata, chunks for pending Blobs keyed by [uploadId, index]), and exposes save/restore/advance operations.

interface UploadRecord {
  id: string;
  filename: string;
  size: number;
  chunkSize: number;
  offset: number; // bytes confirmed by the server
  updatedAt: number;
}

interface ChunkRecord {
  uploadId: string;
  index: number;
  blob: Blob;
}

const DB_NAME = "resumable-uploads";
const DB_VERSION = 1;

function openDb(): Promise<IDBDatabase> {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(DB_NAME, DB_VERSION);
    req.onupgradeneeded = () => {
      const db = req.result;
      if (!db.objectStoreNames.contains("uploads")) {
        db.createObjectStore("uploads", { keyPath: "id" });
      }
      if (!db.objectStoreNames.contains("chunks")) {
        // Composite key keeps chunk order per upload.
        db.createObjectStore("chunks", { keyPath: ["uploadId", "index"] });
      }
    };
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

function tx<T>(
  db: IDBDatabase,
  stores: string[],
  mode: IDBTransactionMode,
  body: (t: IDBTransaction) => IDBRequest<T>,
): Promise<T> {
  return new Promise((resolve, reject) => {
    const t = db.transaction(stores, mode);
    const request = body(t);
    t.oncomplete = () => resolve(request.result);
    t.onerror = () => reject(t.error);
    t.onabort = () => reject(t.error);
  });
}

export class UploadStore {
  constructor(private db: IDBDatabase) {}

  static async open(): Promise<UploadStore> {
    return new UploadStore(await openDb());
  }

  async create(file: File, chunkSize: number): Promise<UploadRecord> {
    const record: UploadRecord = {
      id: crypto.randomUUID(),
      filename: file.name,
      size: file.size,
      chunkSize,
      offset: 0,
      updatedAt: Date.now(),
    };
    await tx(this.db, ["uploads", "chunks"], "readwrite", (t) => {
      const uploads = t.objectStore("uploads");
      const chunks = t.objectStore("chunks");
      let index = 0;
      for (let start = 0; start < file.size; start += chunkSize) {
        const blob = file.slice(start, Math.min(start + chunkSize, file.size));
        chunks.put({ uploadId: record.id, index, blob } satisfies ChunkRecord);
        index++;
      }
      return uploads.put(record);
    });
    return record;
  }

  async advance(id: string, newOffset: number, doneIndex: number): Promise<void> {
    await tx(this.db, ["uploads", "chunks"], "readwrite", (t) => {
      const uploads = t.objectStore("uploads");
      t.objectStore("chunks").delete([id, doneIndex]); // free the sent blob
      const getReq = uploads.get(id);
      getReq.onsuccess = () => {
        const rec = getReq.result as UploadRecord;
        rec.offset = newOffset;
        rec.updatedAt = Date.now();
        uploads.put(rec);
      };
      return getReq;
    });
  }

  async restore(): Promise<UploadRecord[]> {
    return tx(this.db, ["uploads"], "readonly", (t) =>
      t.objectStore("uploads").getAll(),
    );
  }

  async getChunk(id: string, index: number): Promise<Blob | undefined> {
    const rec = await tx(this.db, ["chunks"], "readonly", (t) =>
      t.objectStore("chunks").get([id, index]),
    );
    return (rec as ChunkRecord | undefined)?.blob;
  }

  async remove(id: string): Promise<void> {
    await tx(this.db, ["uploads", "chunks"], "readwrite", (t) => {
      t.objectStore("uploads").delete(id);
      const range = IDBKeyRange.bound([id, 0], [id, Number.MAX_SAFE_INTEGER]);
      return t.objectStore("chunks").delete(range);
    });
  }
}

// --- Resume on reload ---
const store = await UploadStore.open();
const pending = await store.restore();
for (const upload of pending) {
  const nextIndex = Math.floor(upload.offset / upload.chunkSize);
  console.log(`[resume] ${upload.filename} at byte ${upload.offset}, chunk ${nextIndex}`);
  // Hand `upload` + store.getChunk(upload.id, nextIndex) to your sender loop.
}

Line-by-line of the critical parts

  • onupgradeneeded is the only place schema changes are allowed. Bump DB_VERSION to add a store; existing data is preserved.
  • keyPath: ["uploadId", "index"] makes a composite primary key. Chunks sort by upload then sequence, so a range query fetches one upload’s chunks in order.
  • create writes metadata and all chunk blobs in one readwrite transaction. If the page dies mid-write, IndexedDB rolls the whole transaction back — there are no half-written uploads.
  • advance deletes the just-sent chunk and updates offset atomically. Storing the offset only after the server confirms means a crash never advances past unacknowledged bytes.
  • restore returns every unfinished upload via getAll(). On reload you reconstruct the sender from offset alone; nextIndex = offset / chunkSize points at the first unsent chunk.
  • remove uses IDBKeyRange.bound to drop all chunks for a completed upload in a single delete, keeping the store from growing unbounded.

The diagram shows the two stores and how offset and chunk deletion stay in lockstep.

IndexedDB upload and chunk stores An uploads store holds id, offset, and size; a chunks store holds blobs keyed by upload id and index. Confirmed chunks are deleted as the offset advances. uploads (keyPath: id) id, filename, size offset: 16777216 advanced only after the server confirms chunks ([uploadId, index]) index 0 — sent, deleted index 1 — sent, deleted index 2 — next to send index 3 — pending offset/chunkSize
Metadata and chunk stores stay consistent: the offset names the next chunk index.

Configuration gotchas

DataCloneError: Failed to execute 'put' on 'IDBObjectStore'. You tried to store something non-serializable (a function, a File wrapped in a class instance with methods). Store the raw Blob; IndexedDB clones Blob and ArrayBuffer natively but not arbitrary class instances.

VersionError / upgrade never runs. Another tab holds the database open at the old version, so onupgradeneeded is blocked. Listen for req.onblocked and prompt the user, or call db.onversionchange = () => db.close() in every tab.

Storage silently evicted. Browsers evict “best-effort” IndexedDB under disk pressure, which wipes a half-finished upload. Call await navigator.storage.persist() to request durable storage and check navigator.storage.estimate() before storing multi-GB of chunk blobs.

Quota exceeded on large files. Storing every chunk doubles disk use (original file plus copies). Delete each chunk in advance the moment it is confirmed, as shown, so peak usage stays near one chunk plus metadata.

Verification

Confirm the offset persists across a simulated reload by reopening the database in a fresh DevTools console session:

// Run after the first chunk was confirmed, then run again after reload.
const store = await UploadStore.open();
const all = await store.restore();
console.assert(all.length > 0, "expected a pending upload to survive reload");
console.log("resume offset:", all[0]?.offset);
// The offset must equal a multiple of chunkSize and be > 0 after one confirmed chunk.

FAQ

Why not store the whole File instead of slices?

A re-selected file after reload is a brand-new File object, and you cannot re-open the original path from JavaScript without a stored FileSystemFileHandle. Persisting the Blob slices guarantees the bytes are available regardless of how the page was reloaded.

Is IndexedDB fast enough for many chunks?

Yes for sane chunk counts. Batch writes inside one transaction (as create does) rather than one transaction per chunk; per-transaction overhead dominates otherwise. Keep chunks at 5–10 MB so a multi-GB file is hundreds, not millions, of records.

How does this combine with the tus protocol?

tus already persists its resume URL in localStorage, so you usually do not also need IndexedDB. Use this store when you implement chunking yourself; see building a resumable upload flow with tus for the protocol alternative.

What happens if the user clears site data?

Everything is erased — IndexedDB is per-origin client storage. Treat the server’s reported offset (via a HEAD request) as the source of truth on resume, and use the local record only as an optimization. See resuming uploads after network loss.