How to Store Big Files in the Browser
We store things in the browser all the time but rarely think about how limited our options are.
localStorage holds 5 MB of strings. IndexedDB handles structured data and blobs, but serializes everything through structured cloning and can't do partial reads. Neither was designed for large files.
What if you need to store a 200 MB PDF in the browser? Or run a database that reads and writes to disk? These sound impossible, but modern browsers have a real filesystem API that makes both work. It's called the Origin Private File System, or OPFS.
What is OPFS?
OPFS is a private, sandboxed filesystem scoped to your origin. No permission prompts, no file picker dialogs. You get a root directory and can create files and folders inside it.
const root = await navigator.storage.getDirectory()That single line gives you a filesystem. The user never sees a dialog. The files are invisible to the OS file explorer¹, but to your code, they behave like real files.
// Create a file
const fileHandle = await root.getFileHandle("data.bin", { create: true })
// Write to it
const writable = await fileHandle.createWritable()
await writable.write("Hello from the browser filesystem")
await writable.close()
// Read it back
const file = await fileHandle.getFile()
const text = await file.text()You can create subdirectories, list contents, delete entries. It feels like working with fs in Node.
But the async API isn't what makes OPFS special. The real power comes from a second mode.
Synchronous access in Web Workers
createSyncAccessHandle() gives you a handle with synchronous read and write methods. No await, no promises. Direct byte-level access, like fread() and fwrite() in C.
It only works inside a dedicated Web Worker. The browser won't let synchronous file I/O block your main thread.
// opfs-worker.js
onmessage = async (e) => {
const root = await navigator.storage.getDirectory()
const fileHandle = await root.getFileHandle("fast.bin", { create: true })
const access = await fileHandle.createSyncAccessHandle()
// synchronous writes
const data = new Uint8Array([72, 101, 108, 108, 111])
access.write(data, { at: 0 })
access.flush()
// synchronous reads
const size = access.getSize()
const buffer = new Uint8Array(size)
access.read(buffer, { at: 0 })
access.close() // always close to release the lock
postMessage(new TextDecoder().decode(buffer))
}Getting the handle is async (it returns a Promise), but once you have it, every read(), write(), flush(), and getSize() is synchronous. This distinction matters a lot for databases, where a single query might do hundreds of small reads and writes.
Why this matters for large files
The sync access handle gives you something no other browser storage API has: random access.
Reading 1 KB from a 500 MB file
IndexedDB loads the entire blob into memory through structured cloning, then you extract the bytes you need. For large files, most of that memory is wasted.
Need bytes 1,000,000 through 1,001,000 of a 500 MB file? Read exactly that range:
const chunk = new Uint8Array(1000)
access.read(chunk, { at: 1_000_000 })With IndexedDB, you'd load the entire 500 MB blob into memory first. There's no way to read a slice.
Want to modify byte 42 of a file without rewriting the whole thing?
access.write(new Uint8Array([0xff]), { at: 42 })
access.flush()IndexedDB would make you read the entire blob, mutate it, then write the entire blob back. OPFS writes in place.
The use case that makes this click: SQLite
SQLite compiled to WebAssembly can use OPFS as its storage backend. A full SQL database, running in your browser, persisted to the local filesystem.
The official SQLite WASM build ships with an opfs-sahpool VFS² that uses sync access handles under the hood. Libraries like wa-sqlite pioneered this approach. PGlite does the same thing for PostgreSQL.
Why does OPFS work so well here? SQLite's storage engine does tons of small, random reads and writes. Page reads, journal writes, WAL appends. It needs seek(), read(), write() at arbitrary offsets.
IndexedDB can't seek. You can't jump to byte 4096 of a blob and read 4096 bytes. OPFS can. That's the reason SQLite WASM exists in a usable form today.
Storage limits
OPFS shares a quota pool with IndexedDB and Cache API. The limits are generous: Chrome and Edge give you up to 60% of total disk space. Firefox allows up to 10 GiB in best-effort mode. Safari gives around 60% of disk on macOS 14+ and iOS 17+.
On a laptop with a 512 GB drive, Chrome gives you roughly 300 GB per origin.
You can check your usage:
const estimate = await navigator.storage.estimate()
console.log(`Used: ${(estimate.usage / 1e6).toFixed(1)} MB`)
console.log(`Available: ${(estimate.quota / 1e6).toFixed(1)} MB`)By default this storage is "best-effort." The browser can evict it under storage pressure. To prevent that, request persistent storage:
const persisted = await navigator.storage.persist()
// true = the browser will keep your dataGotchas
OPFS files don't show up in DevTools. Chrome's Application panel doesn't list them. You need the OPFS Explorer extension to inspect them, and it's not always reliable.
The sync access handle takes an exclusive lock by default. A second createSyncAccessHandle() call on the same file throws until the first handle is closed. Chrome 121+ added a readwrite-unsafe mode that relaxes this, but Firefox and Safari don't support it yet.
Two tabs writing to the same file will conflict. There's no built-in cross-tab coordination. You need the Web Locks API or BroadcastChannel.
Safari disables OPFS entirely in private browsing. Chrome incognito caps it at around 100 MB. And Safari may delete your data if the origin hasn't had user interaction in 7 days, so navigator.storage.persist() is important if your data matters.
Browser support
All major browsers have supported OPFS since March 2023. Chrome added getDirectory() in version 86 and the sync access handle in 102. Firefox and Safari both support the full API from versions 111 and 15.2 respectively.
The core API works everywhere. Features like readwrite-unsafe mode and move() are Chrome-only for now.
Why this matters
For years, storing large files in the browser meant picking between bad options. Serialize to strings. Stuff blobs into a transaction-heavy database. Accept that browsers aren't really meant for this.
OPFS changes that. Write bytes, read bytes, seek to an offset, flush to disk. The same primitives every operating system has had for decades, now available in a browser tab.
SQLite in the browser. Photoshop in the browser. These aren't possible because browsers got faster. They're possible because browsers got a filesystem.
¹: OPFS files live in the browser's internal storage, separate from the user-visible filesystem. They're scoped to the origin and can't be accessed by other sites or apps.
²: VFS stands for Virtual File System. SQLite uses a VFS layer to abstract away the underlying storage, which is what makes it portable to environments like the browser.
Written by Harshit Sharma. If you want to know when new posts are out, follow me on Twitter.