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
Filereference is lost after a reload (a re-selected file is a new object).
Prerequisites
- A modern browser with IndexedDB (all evergreen browsers; Safari included).
- Files sliced into
Blobchunks — see slicing large files with Blob.slice. - 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
onupgradeneededis the only place schema changes are allowed. BumpDB_VERSIONto 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.createwrites metadata and all chunk blobs in onereadwritetransaction. If the page dies mid-write, IndexedDB rolls the whole transaction back — there are no half-written uploads.advancedeletes the just-sent chunk and updatesoffsetatomically. Storing the offset only after the server confirms means a crash never advances past unacknowledged bytes.restorereturns every unfinished upload viagetAll(). On reload you reconstruct the sender fromoffsetalone;nextIndex = offset / chunkSizepoints at the first unsent chunk.removeusesIDBKeyRange.boundto 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.
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.