This page completes the attachment half of Form State Recovery & Multi-Step Wizards, where text fields are already covered.
The exact problem #
A user attaches a 6 MB PDF to a support request, writes four paragraphs, and the tab reloads — an update, a crash, a mis-swipe. The text comes back from the snapshot. The file does not, and the file input renders empty as if nothing had ever been chosen.
Worse, if the upload had already started, restarting it from zero wastes the two minutes of transfer that were already done. On a slow connection that is the difference between a recovered session and an abandoned one.
Zero-to-working attachment store #
// attachments.ts
export interface StoredAttachment {
id: string;
flowKey: string;
name: string;
type: string;
size: number;
/** Structured-clone stores this natively — no base64, no memory blow-up. */
blob: Blob;
/** Server-side resumable upload session, if one has started. */
uploadId?: string;
/** Bytes the server has CONFIRMED. Never optimistic. */
confirmedOffset: number;
createdAt: number;
}
const MAX_FILE_BYTES = 25 * 1024 * 1024;
const MAX_TOTAL_BYTES = 60 * 1024 * 1024;
export async function persistAttachment(db: IDBDatabase, flowKey: string, file: File): Promise<StoredAttachment | null> {
if (file.size > MAX_FILE_BYTES) return null; // too big to be worth it
if ((await totalStored(db)) + file.size > MAX_TOTAL_BYTES) {
await evictOldest(db); // make room, oldest first
}
const record: StoredAttachment = {
id: crypto.randomUUID(),
flowKey,
name: file.name,
type: file.type || 'application/octet-stream',
size: file.size,
blob: file, // a File IS a Blob
confirmedOffset: 0,
createdAt: Date.now(),
};
await put(db, record);
return record;
}
export async function markConfirmed(db: IDBDatabase, id: string, offset: number, uploadId?: string) {
const rec = await get(db, id);
if (!rec) return;
// Monotonic: a late response must never move the offset backwards.
await put(db, { ...rec, confirmedOffset: Math.max(rec.confirmedOffset, offset), uploadId: uploadId ?? rec.uploadId });
}
Resuming the upload #
// resumableUpload.ts
const CHUNK = 512 * 1024;
export async function uploadWithResume(db: IDBDatabase, rec: StoredAttachment, signal: AbortSignal) {
// Ask the server what it actually has; never trust the client's optimism.
let uploadId = rec.uploadId;
let offset = rec.confirmedOffset;
if (uploadId) {
const status = await fetch(`/uploads/${uploadId}/status`, { signal }).then((r) => r.json());
offset = Math.min(status.receivedBytes as number, rec.size);
await markConfirmed(db, rec.id, offset, uploadId);
} else {
const created = await fetch('/uploads', {
method: 'POST', signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: rec.name, size: rec.size, type: rec.type }),
}).then((r) => r.json());
uploadId = created.uploadId as string;
offset = 0;
await markConfirmed(db, rec.id, 0, uploadId);
}
while (offset < rec.size) {
if (signal.aborted) return { status: 'paused' as const, offset };
const end = Math.min(offset + CHUNK, rec.size);
const chunk = rec.blob.slice(offset, end);
const res = await fetch(`/uploads/${uploadId}`, {
method: 'PATCH',
signal,
headers: {
'Content-Range': `bytes ${offset}-${end - 1}/${rec.size}`,
'Content-Type': 'application/octet-stream',
},
body: chunk,
});
if (!res.ok) throw new Error(`chunk ${offset}-${end} failed: ${res.status}`);
offset = end;
// Persist after EVERY confirmed chunk: this is what survives the next crash.
await markConfirmed(db, rec.id, offset);
}
const done = await fetch(`/uploads/${uploadId}/complete`, { method: 'POST', signal }).then((r) => r.json());
// The bytes are now the server's problem; stop paying for them locally.
await remove(db, rec.id);
return { status: 'complete' as const, fileId: done.fileId as string };
}
Persisting after every chunk is the difference between resuming at 4 MB and resuming at 0. The write is cheap — a small record update, not a re-store of the Blob — because the Blob and the offset live in the same record and IndexedDB only rewrites what changed.
Showing what the input cannot #
// AttachmentList.tsx
export function AttachmentList({ recovered, live, onRemove }: Props) {
return (
<>
<label htmlFor="files">Add files</label>
<input id="files" type="file" multiple onChange={onPick} />
{/* The input is ALWAYS empty after a reload — the list carries the truth. */}
<ul aria-label="Attached files">
{[...recovered, ...live].map((a) => (
<li key={a.id}>
<span>{a.name}</span> <span>{formatBytes(a.size)}</span>
{a.confirmedOffset > 0 && a.confirmedOffset < a.size && (
<progress value={a.confirmedOffset} max={a.size} aria-label={`Uploading ${a.name}`} />
)}
{a.recovered && <span className="badge">Recovered</span>}
<button type="button" onClick={() => onRemove(a.id)}>Remove</button>
</li>
))}
</ul>
</>
);
}
The “Recovered” badge does real work: without it users re-pick the same file, and you upload it twice. With it, the state of the world is legible and the empty input is obviously not a bug.
| Situation | Behaviour | Reason |
|---|---|---|
| File under the per-file cap | Persisted immediately on selection | Cheap insurance |
| File over the cap | Not persisted; UI says it must be re-picked after a reload | Avoids evicting everything else |
| Total over the origin budget | Oldest attachment evicted first | Bounded, predictable footprint |
| Upload completes | Blob deleted, file id kept | The server owns the bytes now |
| Flow abandoned for 7 days | Swept by a startup cleanup pass | Storage is not a graveyard |
| Private browsing | Persistence may silently fail | Feature-detect and warn once |
Verification #
- Reload mid-upload. At roughly 60% progress, reload; the attachment list must show the file with its progress bar, and the network panel must show the next
PATCHstarting at the confirmed offset, not at zero. - Check the input is empty and that is fine. Confirm the file input renders empty while the list shows the recovered file — the expected asymmetry.
- Complete an upload. Confirm the Blob is deleted from IndexedDB immediately and
navigator.storage.estimate()usage drops accordingly. - Exceed the budget. Attach files past the origin cap and confirm the oldest is evicted rather than a write failing — and that the session snapshot itself is never the thing evicted.
- Test in private browsing. Persistence may fail; the flow must still work with a one-time “files won’t be recovered in private browsing” notice rather than an error.
Worth remembering: a Blob read from IndexedDB after a reload is a fresh object with no relationship to the original File, so anything you derived from the file at pick time — a preview URL, a computed hash, the last-modified date — has to be re-derived or stored alongside the bytes. Storing the metadata you actually display is cheaper than recomputing it, and it keeps the recovered attachment looking identical to the one the user chose.
Frequently asked questions #
Why can’t I just restore the file into the input element?
Browsers forbid setting an input’s file list from arbitrary data, since a page could otherwise upload files the user never chose. Hold the bytes yourself and show the attachment separately.
Should I store the whole file or just the unsent remainder?
Store the whole Blob and remember the confirmed offset. Slicing saves little and makes a server restart or checksum mismatch unrecoverable.
What about the File System Access API?
Where available, a persisted handle is better — no byte copying, just a permission prompt on re-read. Feature-detect, fall back to Blob storage, keep the same resume logic.
Related #
- Form State Recovery & Multi-Step Wizards — the snapshot this attachment store sits beside
- Handling QuotaExceededError When Persisting Session State — what happens when the budget is not respected
- Queuing Failed POST Requests with the Background Sync API — finishing the upload after the tab has gone