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 from the confirmed offset A horizontal bar represents a six megabyte file. The first four megabytes are shaded and labelled confirmed by the server. A reload marker sits after that point. Below, a second bar shows the resumed upload sending only the final two megabytes, with a note that without a persisted offset the whole six megabytes would be resent. before the reload 4 MB confirmed by the server 2 MB not yet sent reload after recovery skipped — already confirmed resumed from byte 4 194 304 without a persisted offset the browser would resend all 6 MB — often the reason a user gives up

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
Attachment storage budget and eviction A horizontal capacity bar labelled sixty megabyte origin budget contains four attachment blocks of different sizes. A fifth incoming block would exceed the budget, so an arrow shows the oldest block being evicted to make room. A separate note shows completed uploads freeing their space immediately. origin budget for recovery attachments: 60 MB invoice.pdf 6 MB scan.png 18 MB notes.txt 1 MB photo.jpg 9 MB incoming: video.mp4 40 MB would exceed the budget evict oldest first invoice.pdf removed stored within budget a completed upload frees its bytes immediately — the blob is deleted, only the file id is kept the budget exists so recovery attachments never evict the session snapshots they belong to Blob copy versus a persisted file handle Two columns comparing the two approaches on four axes: storage cost, availability across browsers, permission needed after a reload, and whether the file survives if the user moves it on disk. Blob copy in IndexedDB File System Access handle storage cost full file size, counts against quota a few bytes availability every evergreen browser Chromium-based today after a reload read immediately, no prompt one permission prompt user moves the file unaffected — you hold a copy handle goes stale

Verification #

  1. 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 PATCH starting at the confirmed offset, not at zero.
  2. 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.
  3. Complete an upload. Confirm the Blob is deleted from IndexedDB immediately and navigator.storage.estimate() usage drops accordingly.
  4. 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.
  5. 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.