Skip to content
HexaTransfer
Back to blog
Technical Deep Dives

Browser Storage APIs for File Transfer Applications

Use IndexedDB, File System Access API, and Cache API for file transfer apps. Storage limits, performance, and browser compatibility.

Browser storage for a file transfer app divides cleanly across four APIs: IndexedDB for structured session and chunk metadata (transactional, async, typed), the File System Access API for reading and writing multi-gigabyte files directly on the user's disk, the Cache API for HTTP responses and app-shell assets, and the Storage Manager API for quota management and persistence hints. Origin Private File System (OPFS) sits alongside these for sandboxed high-performance I/O. Picking the right one matters because limits vary from 1 GB on iOS Safari to "60% of free disk" on desktop Chrome, and the wrong choice eventually leads to QuotaExceededError in production.

Mapping Each API to the Right Job

Use IndexedDB for anything that looks like a row: upload sessions, completed chunk indexes, share metadata, revocation tokens. It's async, transactional, indexable, and survives across sessions.

Use the File System Access API when you need to hand bytes to disk without loading the whole file into RAM — ideal for saving decrypted downloads over 500 MB. Chrome, Edge, and Opera support it; Firefox and Safari only implement a read-only subset via showOpenFilePicker.

Use the Cache API for HTTP Response objects — your JS bundle, CSS, icons, and maybe cached API responses. It's optimized for fetch interception in Service Workers.

Use OPFS (a special Origin-Private branch of the File System Access API) when you want fast, sandboxed, non-user-visible storage, for example a write buffer during a multi-gigabyte encryption pass. It reaches disk throughput an order of magnitude higher than IndexedDB for binary blobs.

IndexedDB Without the Rough Edges

Raw IndexedDB has a notoriously awkward event-based API. Use Jake Archibald's idb package (1.5 KB gzipped) or Dexie.js (20 KB, richer query API):

import { openDB } from 'idb';
const db = await openDB('transfers', 2, {
  upgrade(db, oldVersion) {
    if (oldVersion < 1) {
      const sessions = db.createObjectStore('sessions', { keyPath: 'id' });
      sessions.createIndex('by_expiry', 'expiresAt');
    }
    if (oldVersion < 2) {
      db.createObjectStore('chunks', { keyPath: ['sessionId', 'index'] });
    }
  }
});
await db.put('sessions', { id: 'abc', fileName: 'report.pdf', expiresAt: Date.now() + 86400000 });

Version migrations run in the upgrade callback. Always guard migrations by oldVersion so users jumping from v1 to v3 get both steps.

IndexedDB handles most shapes of data, including Blobs and File references, via structured clone. That means you can store a File handle in a session record and re-read the original file bytes after a tab reload — perfect for resumable uploads.

File System Access API for Large Downloads

The API lets you hand a writable stream to the browser's save dialog:

const handle = await window.showSaveFilePicker({
  suggestedName: 'decrypted-archive.zip',
  types: [{ description: 'Zip', accept: { 'application/zip': ['.zip'] } }]
});
const writable = await handle.createWritable();
await decryptionStream.pipeTo(writable);

Bytes flow straight to disk, never landing in the JS heap. This is the only practical way to save a 10 GB decrypted file in the browser.

For Firefox and Safari, fall back to StreamSaver.js, which uses a Service Worker to synthesize a streaming response that triggers the download UI. Same ergonomics, slightly more moving parts.

The persistent file handles also let an app reopen files across sessions. Once a user grants permission via showOpenFilePicker, you can persist the FileSystemFileHandle in IndexedDB and later call handle.requestPermission() to reacquire access without re-prompting for every file.

OPFS for Scratch Space

The Origin Private File System is a per-origin sandboxed storage that behaves like a filesystem but isn't visible to the user:

const root = await navigator.storage.getDirectory();
const fh = await root.getFileHandle('scratch.bin', { create: true });
const access = await fh.createSyncAccessHandle(); // workers only
access.write(buffer, { at: offset });
access.flush();
access.close();

createSyncAccessHandle is available only inside Web Workers (including Service Workers). It's synchronous and extremely fast — benchmarks show 3-10x IndexedDB for sequential writes. Use it to buffer a few hundred megabytes of encryption output before uploading, or to cache a decrypted working copy without polluting the user's Downloads folder.

Safari 17 shipped OPFS with sync access handles; Firefox 111 followed. All three major browsers now support it, which makes it viable for production code.

Storage Quotas and How to Survive Them

Every API shares the same origin quota pool. Approximate ceilings:

  • Desktop Chrome: 60% of free disk
  • Desktop Firefox: 50% of free disk, capped at 2 GB per origin by default
  • Desktop Safari: 1 GB warning, grows to ~20% of disk with user approval
  • iOS Safari: 1 GB per origin, aggressive 7-day eviction if unused
  • Chrome Android: 10% of free disk, eviction under pressure

Check quota at runtime:

const { quota, usage } = await navigator.storage.estimate();
console.log(`Using ${(usage/1e9).toFixed(2)} GB of ${(quota/1e9).toFixed(2)} GB`);

Request persistence for critical stores:

const persisted = await navigator.storage.persist();

Returns true if the browser granted persistent storage, meaning it won't evict under pressure. Chrome grants this automatically to sites the user has engaged with significantly; Firefox prompts.

Cache API for App Shell and Offline

The Cache API stores Request + Response pairs and is the right choice inside Service Workers:

const cache = await caches.open('shell-v7');
await cache.addAll([
  '/', '/app.js', '/app.css', '/icons/192.png'
]);

Retrieve on intercept:

self.addEventListener('fetch', (e) => {
  e.respondWith(caches.match(e.request).then(r => r ?? fetch(e.request)));
});

Don't put encrypted file bytes in the Cache API. A 2 GB response object blows past iOS Safari's quota in one go and can't be range-fetched after the fact. Bytes belong in OPFS or direct-to-disk via File System Access.

Handling Eviction and Data Loss Gracefully

Non-persisted storage gets evicted — you must plan for it. iOS Safari evicts after 7 days of non-use, regardless of quota. Chrome evicts only when disk is actually pressured. Firefox evicts least-recently-used origins once the quota pool fills.

Two defensive patterns:

  • Write any state you can't recreate (upload session IDs, partial chunk offsets) with a reload-friendly format so a fresh page load can re-fetch from the server and continue.
  • For long-lived state, request navigator.storage.persist() and surface a UI for users to confirm when the browser prompts.

Keep the server as the source of truth for anything you can't afford to lose. Treat browser storage as a fast cache that might vanish overnight.

Browser Support Landmines

Three traps show up again and again:

  1. indexedDB.databases() enumeration isn't supported in Firefox (users opted into "delete cookies on close" lose all IndexedDB content without firing events).
  2. FileSystemFileHandle.queryPermission() behaves differently after reloads — sometimes returns 'prompt' even when granted. Always call requestPermission() defensively.
  3. Private / incognito mode gives all three APIs a separate, smaller, session-only quota. Code that works in normal browsing can hit QuotaExceededError immediately in private windows.

HexaTransfer uses IndexedDB for session state, OPFS for ciphertext buffering during streaming encryption, and the File System Access API for 10 GB decrypted downloads on supported browsers. Try it at hexatransfer.com — free, no account, 10 GB max.

Picking a Stack for Your App

For most transfer apps, the right combo is: idb wrapper over IndexedDB for metadata, OPFS sync access handles for encrypt/decrypt scratch space, Cache API for app shell inside a Service Worker, File System Access API for final downloads with StreamSaver fallback, and a navigator.storage.persist() call during onboarding. That covers every browser shipping today, stays under quotas on mobile, and recovers gracefully when something gets evicted. Build small adapters around each API so the day OPFS lands a new method or Safari raises its quota, you change one file and ship.

Send large files securely with end-to-end encryption

Transfer files up to 10 GB for free with end-to-end encryption. No account required. Your files are encrypted in your browser before upload — no one else can read them.

Send a file