Persisting Upload State in IndexedDB
IndexedDB gives an in-progress upload durable, structured storage for its id, confirmed offset and a rolling window of chunk blobs, so a reload or crash resumes from the exact byte rather than starting over.
A resumable transfer needs a memory of where it stopped, and it needs that memory to outlive the JavaScript heap. localStorage only holds strings, caps out near 5 MB, and blocks the main thread on every write — fine for a single resume URL, useless for bytes. IndexedDB is the right tier for resumable upload state machines in the frontend UX, chunking and progress tracking layer: it stores Blob values natively, indexes them, and survives a process kill. This page covers the storage half of the problem — schema, transactions, quota and migrations — and leaves offset negotiation with the server to resuming uploads after network loss.
When to use this approach
- You chunk files yourself rather than delegating to a turnkey protocol, and you need somewhere durable to record the next offset and which chunk bytes are still owed.
- You want recovery from a tab crash, an OOM kill or a browser restart, not just the in-page network blip handled by exponential backoff for failed chunks.
- You are prepared to spend disk: after a reload the original
Filereference is gone, so unless you stored a file handle the bytes must already be on disk in your own store.
If your endpoint speaks tus, its JavaScript client already writes its resume URL to localStorage and re-reads the file from a File you supply — see building a resumable upload flow with tus before writing any of this yourself.
Prerequisites
- Any evergreen browser. IndexedDB with
Blobvalue support and compositekeyPatharrays is available in Chrome, Firefox, Safari 15+ and every Chromium fork. - Files already divided into byte ranges — see slicing large files with Blob.slice for the arithmetic and the zero-copy semantics of a slice.
- TypeScript 5.4+ with
"lib": ["dom", "dom.iterable", "esnext"]soIDBTransactionOptionsandcrypto.randomUUIDtype-check. - A server that reports its own committed offset, because the local record is a hint and never the source of truth.
How IndexedDB holds upload state
The transaction is the unit of durability
An IndexedDB transaction is not a long-lived handle you hold across await boundaries. It stays active only while the current task or microtask checkpoint is running; the moment control returns to the event loop with no outstanding requests attached, the browser commits it and marks it finished. Issue another put() after that and you get an exception, not a queued write.
This single rule causes most IndexedDB bugs in upload code, because the natural shape — “write the chunk, send it, write the new offset” — puts a fetch() in the middle of a transaction.
What survives the structured clone
Values go through the structured clone algorithm on the way in. Blob, File, ArrayBuffer, typed arrays, Date, Map, Set and plain objects all round-trip. Class instances lose their prototype, and anything holding a function, a DOM node, an AbortController or a live ReadableStream throws outright. Keep the persisted record a plain data object and hang the machinery — the controller from aborting uploads with AbortController and timeouts, the retry counters, the progress sampler — off it in memory.
Blobs are special: the browser stores them out-of-line, in a separate blob directory, and the record only carries a reference. That is why a 8 MB chunk write costs one file write rather than an 8 MB round-trip through the key-value store, and why the write is fast enough to sit on the critical path of every chunk.
Implementation
The module below wraps the callback API in promises, defines two stores — uploads for metadata and chunks for staged Blob values keyed by [uploadId, index] — and stages a bounded window of chunks ahead of the send cursor so disk use stays flat instead of doubling the file.
// upload-store.ts — durable state for a chunked, resumable upload.
type FsPermissionState = "granted" | "denied" | "prompt";
/** Chromium exposes permission methods that are not yet in lib.dom. */
export interface ReadableFileHandle extends FileSystemFileHandle {
queryPermission(descriptor: { mode: "read" }): Promise<FsPermissionState>;
requestPermission(descriptor: { mode: "read" }): Promise<FsPermissionState>;
}
export interface UploadRecord {
id: string; // primary key
fingerprint: string; // name:size:lastModified — matches a re-picked file
filename: string;
size: number;
chunkSize: number;
offset: number; // bytes the SERVER has confirmed
staged: number; // highest chunk index currently held in `chunks`
endpoint: string; // resume URL returned by the create request
handle?: ReadableFileHandle;
updatedAt: number;
}
export interface ChunkRecord {
uploadId: string;
index: number;
blob: Blob;
}
export type ByteSource =
| { kind: "handle"; file: File }
| { kind: "staged"; blob: Blob }
| { kind: "missing" };
const DB_NAME = "resumable-uploads";
const DB_VERSION = 2;
const WINDOW = 4; // chunks staged ahead of the cursor
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
function migrate(db: IDBDatabase, tx: IDBTransaction, oldVersion: number): void {
if (oldVersion < 1) {
db.createObjectStore("uploads", { keyPath: "id" });
// Composite key keeps chunks ordered per upload and range-deletable.
db.createObjectStore("chunks", { keyPath: ["uploadId", "index"] });
}
if (oldVersion < 2) {
tx.objectStore("uploads").createIndex("updatedAt", "updatedAt", { unique: false });
}
}
function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = (event) => {
migrate(req.result, req.transaction as IDBTransaction, event.oldVersion);
};
req.onblocked = () => reject(new Error("another tab holds an older database version open"));
req.onerror = () => reject(req.error);
req.onsuccess = () => {
const db = req.result;
db.onversionchange = () => db.close(); // step aside for a newer tab
resolve(db);
};
});
}
/** `body` MUST be synchronous — an await inside it commits the transaction early. */
function runTx<T>(
db: IDBDatabase,
stores: string[],
mode: IDBTransactionMode,
body: (tx: IDBTransaction) => () => T,
): Promise<T> {
return new Promise((resolve, reject) => {
const tx = db.transaction(stores, mode, { durability: "relaxed" });
let read: () => T;
try {
read = body(tx);
} catch (err) {
tx.abort();
reject(err);
return;
}
tx.oncomplete = () => resolve(read());
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error ?? new DOMException("aborted", "AbortError"));
});
}
export class UploadStore {
private constructor(private readonly db: IDBDatabase) {}
static async open(): Promise<UploadStore> {
const store = new UploadStore(await openDb());
if (navigator.storage?.persist) await navigator.storage.persist();
return store;
}
async create(file: File, endpoint: string, chunkSize = 8 * 1024 * 1024): Promise<UploadRecord> {
const record: UploadRecord = {
id: crypto.randomUUID(),
fingerprint: `${file.name}:${file.size}:${file.lastModified}`,
filename: file.name,
size: file.size,
chunkSize,
offset: 0,
staged: -1,
endpoint,
updatedAt: Date.now(),
};
await runTx(this.db, ["uploads"], "readwrite", (tx) => {
tx.objectStore("uploads").put(record);
return () => undefined;
});
return this.stage(record, file, 0);
}
/** Hold WINDOW chunks on disk from `fromIndex` onward, so a reload has bytes to send. */
async stage(record: UploadRecord, file: File, fromIndex: number): Promise<UploadRecord> {
const lastIndex = Math.ceil(record.size / record.chunkSize) - 1;
const upto = Math.min(fromIndex + WINDOW - 1, lastIndex);
const from = Math.max(fromIndex, record.staged + 1);
if (upto < from) return record;
const pending: ChunkRecord[] = [];
for (let i = from; i <= upto; i++) {
const start = i * record.chunkSize;
const end = Math.min(start + record.chunkSize, record.size);
pending.push({ uploadId: record.id, index: i, blob: file.slice(start, end) });
}
const next: UploadRecord = { ...record, staged: upto, updatedAt: Date.now() };
await runTx(this.db, ["uploads", "chunks"], "readwrite", (tx) => {
const chunks = tx.objectStore("chunks");
for (const chunk of pending) chunks.put(chunk);
tx.objectStore("uploads").put(next);
return () => undefined;
});
return next;
}
/** Record a server-confirmed offset and drop every chunk it covers. */
async advance(record: UploadRecord, confirmedOffset: number): Promise<UploadRecord> {
const doneThrough = Math.floor(confirmedOffset / record.chunkSize) - 1;
const next: UploadRecord = { ...record, offset: confirmedOffset, updatedAt: Date.now() };
await runTx(this.db, ["uploads", "chunks"], "readwrite", (tx) => {
tx.objectStore("uploads").put(next);
if (doneThrough >= 0) {
const covered = IDBKeyRange.bound([record.id, 0], [record.id, doneThrough]);
tx.objectStore("chunks").delete(covered);
}
return () => undefined;
});
return next;
}
/** Where the bytes for `index` can come from after a reload. */
async chunkFor(record: UploadRecord, index: number): Promise<ByteSource> {
const handle = record.handle;
if (handle) {
let state = await handle.queryPermission({ mode: "read" });
if (state === "prompt") state = await handle.requestPermission({ mode: "read" });
if (state === "granted") return { kind: "handle", file: await handle.getFile() };
}
const row = await runTx<ChunkRecord | undefined>(this.db, ["chunks"], "readonly", (tx) => {
const req = tx.objectStore("chunks").get([record.id, index]);
return () => req.result as ChunkRecord | undefined;
});
return row ? { kind: "staged", blob: row.blob } : { kind: "missing" };
}
/** Every unfinished upload, oldest first; anything past `maxAgeMs` is collected. */
async restore(maxAgeMs = WEEK_MS): Promise<UploadRecord[]> {
const cutoff = Date.now() - maxAgeMs;
const all = await runTx<UploadRecord[]>(this.db, ["uploads"], "readonly", (tx) => {
const req = tx.objectStore("uploads").index("updatedAt").getAll();
return () => req.result as UploadRecord[];
});
await Promise.all(all.filter((r) => r.updatedAt < cutoff).map((r) => this.remove(r.id)));
return all.filter((r) => r.updatedAt >= cutoff);
}
async remove(id: string): Promise<void> {
await runTx(this.db, ["uploads", "chunks"], "readwrite", (tx) => {
tx.objectStore("uploads").delete(id);
// [id] sorts before [id, 0]; [id, []] sorts after every [id, number].
tx.objectStore("chunks").delete(IDBKeyRange.bound([id], [id, []]));
return () => undefined;
});
}
}
Line-by-line of the critical parts
migrateis keyed onoldVersion, not onobjectStoreNames.contains. Existence checks silently skip index creation for users upgrading from v1, because the store already exists. Version gates are cumulative and replayable: a browser that has never opened the database runs both branches in order.req.transactioninsideonupgradeneededis theversionchangetransaction, and it is the only placecreateObjectStoreandcreateIndexare legal. Do not open your own transaction there.durability: "relaxed"lets the browser acknowledge a commit before the OS flushes to disk. That is the right choice here: losing the last 50 ms of offset writes costs one re-sent chunk, and"strict"adds a genuinefsyncper chunk. Use"strict"only if a lost write costs money.keyPath: ["uploadId", "index"]makes a composite primary key. Because IndexedDB orders arrays element-wise, oneIDBKeyRangecovers exactly one upload’s chunks — which is what makesadvanceandremovesingle-statement deletes rather than cursor walks.advancecomputesdoneThroughfrom the server’s offset, never from a local counter. APATCHthat half-succeeds behind a buffering proxy leaves the server at a lower offset than you sent; deriving the deletion range from the confirmed value means you never discard bytes the server did not take.stagewrites the window and the metadata in one transaction. If the tab dies mid-write, IndexedDB rolls back both, sostagedcan never claim chunks that are not on disk.restorereads through theupdatedAtindex so results come back oldest first, and opportunistically collects records older than a week. Without that sweep, abandoned uploads accumulate blobs until the origin hits quota.
Where the bytes come from after a reload
Persisting the offset is the easy half. The hard half is that a File obtained from <input type="file"> or a drop event is a live handle into the page’s process, and it does not survive navigation. After a reload you have three possible byte sources, and the store should try them in order.
A FileSystemFileHandle from showOpenFilePicker() — or from DataTransferItem.getAsFileSystemHandle() on a drop — is itself structured-cloneable, so you can put it straight into the uploads record. It is Chromium-only today and it needs a user gesture before requestPermission() will resolve to "granted", but it removes the disk cost entirely: a 4 GB upload keeps roughly 400 bytes of metadata on disk instead of a second copy of the video. When the handle path is unavailable, the staged window is what stands between the user and starting over, and it is why WINDOW should be at least two: one chunk in flight plus one queued behind it.
If both fail, do not silently resume against a file the user re-picked. Compare the fingerprint first, and for anything you bill or archive, verify the prefix hash as described in computing file checksums in the browser with Web Crypto before sending byte offset + 1.
Configuration reference
| Option | Type | Default | Effect |
|---|---|---|---|
DB_VERSION |
number | 2 |
Bumping it runs migrate with the stored oldVersion; never lower it |
chunkSize |
number | 8388608 |
Bytes per staged record. Below 1 MB the per-transaction overhead dominates |
WINDOW |
number | 4 |
Chunks kept on disk ahead of the cursor. Peak chunk storage is WINDOW × chunkSize |
durability |
"relaxed" | "strict" | "default" |
"relaxed" |
"strict" forces an fsync per commit, roughly 3–10× slower on spinning disks |
maxAgeMs |
number | 604800000 |
restore deletes records whose updatedAt is older than this |
endpoint |
string | — | Resume URL persisted with the record so a reload does not need a fresh create call |
handle |
ReadableFileHandle |
undefined |
When present, chunk bytes come from the live file and nothing is staged |
Storage quota, eviction and persistence
Every origin gets a budget, not a guarantee. Chromium allows a single origin up to roughly 60% of total disk space; Firefox caps a group at 20% of a global limit derived from free space; Safari starts near 1 GB and prompts for more. Under disk pressure the browser evicts whole origins in least-recently-used order, and a half-finished 3 GB upload is exactly the kind of thing that disappears.
Two calls make this manageable. navigator.storage.persist() requests the persistent bucket, which is exempt from best-effort eviction; Firefox prompts the user, and Chromium grants it silently based on engagement signals such as a bookmark or granted notification permission. navigator.storage.estimate() tells you whether staging is even viable before you write the first chunk.
export async function assertRoomFor(bytes: number): Promise<void> {
if (!navigator.storage?.estimate) return; // Safari <17 in private mode
const { quota = 0, usage = 0 } = await navigator.storage.estimate();
const free = quota - usage;
if (free < bytes * 1.2) {
throw new Error(
`insufficient origin storage: need ~${Math.ceil(bytes / 1e6)} MB, ${Math.floor(free / 1e6)} MB free`,
);
}
const persisted = await navigator.storage.persisted();
if (!persisted && !(await navigator.storage.persist())) {
console.warn("[uploads] best-effort storage — a staged upload may be evicted under disk pressure");
}
}
Because WINDOW × chunkSize is 32 MB by default, this check almost always passes; it is the “stage the whole file up front” variant that runs into the wall. That is the concrete reason to prefer a rolling window over a full copy for the multi-gigabyte cases discussed in best practices for handling 500MB file uploads. Safari adds one more constraint worth knowing: for sites the user has not added to the home screen, script-writable storage is cleared after seven days without interaction, which is why maxAgeMs defaults to the same window.
Two tabs, one upload
Nothing stops the user from opening your app twice and resuming the same record in both tabs. Two senders racing on the same offset produce duplicate PATCH requests, and a server that is not strictly idempotent will either double-count bytes or return 409s. Serialise with the Web Locks API rather than a flag in the store, because a lock is released automatically when a tab crashes:
export async function withUploadLock<T>(
fingerprint: string,
run: () => Promise<T>,
): Promise<T | "busy"> {
return navigator.locks.request(
`upload:${fingerprint}`,
{ ifAvailable: true },
async (lock) => (lock ? run() : "busy"),
);
}
Pair this with idempotency on the wire — see retrying fetch uploads with idempotency keys — so that a lock lost to a crash mid-request still cannot corrupt the object.
Configuration gotchas
TransactionInactiveError: Failed to execute 'put' on 'IDBObjectStore': The transaction has finished. You awaited something — a fetch, a blob.arrayBuffer(), even Promise.resolve() in some engines — between two requests on the same transaction. Collect everything you need first, then open the transaction and issue all requests synchronously, exactly as runTx enforces by taking a synchronous body.
DataCloneError: Failed to execute 'put' on 'IDBObjectStore': #<UploadJob> could not be cloned. The record contains something unclonable: an AbortController, an XMLHttpRequest, a bound callback, or a class instance with methods on its prototype. Persist a plain object, and reattach behaviour after restore().
QuotaExceededError fires on the request and aborts the whole transaction. Every put in that transaction rolls back, including the offset write, so the store stays consistent but the upload stalls. Catch it around stage, drop WINDOW to 1, and if it still fails, surface the same “out of space” path you use for 413 and 507 errors during uploads.
VersionError: The requested version (1) is less than the existing version (2). A stale bundle is still cached in another tab or a service worker. Never open with a lower version; ship the version bump and the client code together, and let db.onversionchange = () => db.close() release the old connection so the new tab’s onupgradeneeded is not stuck on onblocked.
InvalidStateError: Failed to execute 'transaction' on 'IDBDatabase': The database connection is closing. Your onversionchange handler closed the connection while a send loop was still running. Treat it as a signal: abort the in-flight chunk, drop the UploadStore, and call UploadStore.open() again after the upgrade completes.
Verification
Confirm the offset and the staged window really survive a reload. Run this in the DevTools console before and after pressing reload mid-upload:
import { UploadStore } from "./upload-store.js";
const store = await UploadStore.open();
const pending = await store.restore();
console.assert(pending.length > 0, "expected a pending upload to survive reload");
const [upload] = pending;
const nextIndex = Math.floor(upload.offset / upload.chunkSize);
const source = await store.chunkFor(upload, nextIndex);
console.assert(upload.offset % upload.chunkSize === 0, "offset must land on a chunk boundary");
console.assert(source.kind !== "missing", `no bytes available for chunk ${nextIndex}`);
console.log(
`[verify] ${upload.filename}: resume at byte ${upload.offset} (chunk ${nextIndex}) via ${source.kind}`,
);
const { usage = 0 } = await navigator.storage.estimate();
console.log(`[verify] origin usage ${(usage / 1e6).toFixed(1)} MB, persisted=${await navigator.storage.persisted()}`);
Expected output after three confirmed 8 MB chunks: [verify] keynote.mp4: resume at byte 25165824 (chunk 3) via staged, and an origin usage near 33 MB — four staged chunks plus metadata, not the whole file. In the Application panel, IndexedDB → resumable-uploads → chunks should show exactly WINDOW rows with keys [<uuid>, 3] through [<uuid>, 6]. If usage tracks the full file size, your advance deletion range is wrong.
Frequently Asked Questions
Why not keep the whole File object in the record instead of slices?
You can — File is structured-cloneable — but on Chromium the stored value is a snapshot copy in the browser’s blob directory, so a 4 GB file costs 4 GB of quota immediately. A FileSystemFileHandle gives you the same reload survival for a few hundred bytes, and the staged window is the portable fallback when handles are unavailable.
Is IndexedDB fast enough to write on every chunk?
Yes, at sane chunk sizes. A relaxed commit of one 8 MB blob plus a metadata put costs single-digit milliseconds on an SSD because the blob is written out-of-line as a file reference. It only becomes a problem at tiny chunk sizes: 64 KB chunks over a 2 GB file means 32,000 transactions, and per-transaction overhead then dominates the transfer itself.
Does this replace the server’s offset?
No. Treat the stored offset as a fast local hint that lets you skip a round trip in the common case, and reconcile it against the server’s authoritative value on every resume. The handshake, including what to do when the server reports a higher offset than you recorded, lives in resuming uploads after network loss.
Why does progress jump backwards after a resume?
Because the confirmed offset is behind the bytes you had already pushed onto the wire when the tab died. Drive the progress bar from record.offset for durability and from the in-flight byte counter for smoothness, then blend them — the sampling approach in showing accurate time-remaining estimates handles the discontinuity without a rubber-banding bar.
What happens if the user clears site data?
Everything in the store is erased; IndexedDB is per-origin client storage with no recovery. Design so that this costs one round trip rather than the upload: keep the resume URL derivable server-side from a stable key, and let a fresh HEAD re-establish the offset from nothing.