Persistent file handling with the File System Access API
Save a native file handle in IndexedDB to let a returning user reopen a local file without choosing it again. This read-only demo includes the page, startup, buttons, and permission recovery. It reads files locally; resuming an upload would additionally require saved upload state and a compatible server protocol.
Browser support and progressive enhancement
Use a desktop browser exposing showOpenFilePicker for the persistent path. The demo was tested on
Linux with Chromium 145.0.7632.6, and its file-input fallback with Firefox 146.0.1. Check the
picker compatibility table
for your target browsers. Supporting IndexedDB or the origin private file system (OPFS) does not
establish support for picking files on the user’s disk.
The fallback reads a selected File for this visit. It cannot persist a native handle or reopen the
file after reload. The page makes that difference visible and disables its reopen button.
Set up the local demo
Use Node.js 26.8.1 and Corepack 0.34.5 for the commands below. Corepack is a separate prerequisite on Node 26; follow the Corepack installation instructions if it is missing. This example pins Yarn 4.12.0 and Vite 8.3.2. Vite compiles the browser TypeScript; there is no upload receiver or server-side file access.
In Bash, check the prerequisites before creating a project. The subshell keeps your terminal in its original directory. If the name already exists, choose another name; do not overwrite that folder.
(
node --version && corepack --version &&
mkdir file-handle-demo &&
cd file-handle-demo &&
touch yarn.lock
)
Open a terminal in the new file-handle-demo folder and save the following four files there. The
local yarn.lock establishes a separate Yarn project, including inside an existing workspace. If
setup stopped after creating the folder, preserve it and create the empty lockfile there before
continuing. Replace only the files in this new demo, not an existing application’s configuration.
Save this as package.json:
{
"name": "file-handle-demo",
"private": true,
"type": "module",
"packageManager": "yarn@4.12.0",
"scripts": {
"build": "vite build",
"start": "vite preview --host 127.0.0.1 --port 4173 --strictPort"
},
"dependencies": {
"idb-keyval": "6.3.0"
},
"devDependencies": {
"vite": "8.3.2"
}
}
Save this as .yarnrc.yml to use a local node_modules installation:
nodeLinker: node-modules
Save this as index.html. Every file control starts disabled until initialization finishes.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Persistent file demo</title>
<style>
:root { color-scheme: light dark; font: 18px system-ui; }
main { max-width: 44rem; margin: 2rem auto; padding: 1rem; }
button, input { font: inherit; margin: .5rem 0; }
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
</style>
</head>
<body>
<main>
<h1>Persistent file demo</h1>
<button id="choose" disabled>Choose file</button>
<button id="reopen" disabled>Reopen saved file</button>
<button id="forget" disabled>Forget saved file</button>
<p id="fallback" hidden>
<label for="file">Select file for this visit</label>
<input id="file" type="file" disabled />
</p>
<p id="status" role="status">Loading saved handle…</p>
<pre id="result" aria-label="File read result"></pre>
</main>
<script type="module" src="/main.ts"></script>
</body>
</html>
Save both TypeScript blocks below in main.ts, in order. The declarations describe the native
picker and permission methods to TypeScript; feature detection still happens at runtime.
import { del, get, set } from 'idb-keyval'
type NativeFileHandle = FileSystemFileHandle & {
requestPermission(options: { mode: 'read' }): Promise<PermissionState>
}
declare global {
interface Window {
showOpenFilePicker?: (options: { multiple: false }) => Promise<NativeFileHandle[]>
}
}
function element<T extends HTMLElement>(id: string, kind: new () => T): T {
const value = document.getElementById(id)
if (!(value instanceof kind)) throw new Error(`Missing control: ${id}`)
return value
}
const choose = element('choose', HTMLButtonElement)
const reopen = element('reopen', HTMLButtonElement)
const forget = element('forget', HTMLButtonElement)
const input = element('file', HTMLInputElement)
const status = element('status', HTMLParagraphElement)
const result = element('result', HTMLPreElement)
const native = typeof window.showOpenFilePicker === 'function'
const key = 'selected-upload-file'
let savedHandle: NativeFileHandle | null = null
let ready = false
let busy = false
function controls(): void {
choose.hidden = !native
element('fallback', HTMLParagraphElement).hidden = native
choose.disabled = !ready || busy
input.disabled = !ready || busy
reopen.disabled = !ready || busy || !native || savedHandle === null
forget.disabled = !ready || busy || savedHandle === null
}
function failure(error: unknown): void {
const name = error instanceof Error ? error.name : ''
const messages: Record<string, string> = {
AbortError: 'Selection canceled. Previous saved file kept.',
NotAllowedError: 'Access not granted. Allow access or choose the file again.',
SecurityError: 'Use a secure page and click a file control to request access.',
NotFoundError: 'File unavailable. It may have moved or been deleted. Choose it again.',
}
status.textContent = messages[name] ?? 'Operation failed. Check storage access and try again.'
}
async function operation(work: () => Promise<void>): Promise<void> {
if (!ready || busy) return
busy = true
result.textContent = ''
status.textContent = 'Reading…'
controls()
try {
await work()
} catch (error) {
failure(error)
} finally {
busy = false
controls()
}
}
async function handleLargeFile(
fileHandle: Pick<FileSystemFileHandle, 'getFile'>,
consumeChunk: (chunk: Uint8Array) => Promise<void>,
): Promise<number> {
const file = await fileHandle.getFile()
const reader = file.stream().getReader()
let totalSize = 0
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
await consumeChunk(value)
totalSize += value.length
}
return totalSize
} catch (error) {
await reader.cancel(error).catch(() => {})
throw error
} finally {
reader.releaseLock()
}
}
async function readFile(handle: Pick<FileSystemFileHandle, 'getFile'>): Promise<string> {
let prefix: number[] = []
const bytes = await handleLargeFile(handle, async (chunk) => {
const remaining = 16 - prefix.length
if (remaining > 0) prefix = prefix.concat(Array.from(chunk.subarray(0, remaining)))
})
const hex = prefix.map((byte) => byte.toString(16).padStart(2, '0')).join(' ')
return `${bytes} bytes read. First 16 bytes (hex): ${hex || '(empty file)'}`
}
The reopen handler calls requestPermission immediately during the click, using the handle loaded
at startup. It never waits for IndexedDB before requesting permission. A stored handle can survive
longer than its permission, so an ungranted response stops the read.
choose.addEventListener('click', () => {
void operation(async () => {
const picker = window.showOpenFilePicker
if (!picker) throw new Error('Native picker unavailable')
const [handle] = await picker.call(window, { multiple: false })
if (!handle) throw new Error('No file selected')
const summary = await readFile(handle)
status.textContent = 'Saving handle…'
await set(key, handle)
savedHandle = handle
result.textContent = summary
status.textContent = `Read ${handle.name}. Handle saved; reload and reopen it.`
})
})
reopen.addEventListener('click', () => {
void operation(async () => {
if (savedHandle === null) throw new Error('No saved handle')
const permission = await savedHandle.requestPermission({ mode: 'read' })
if (permission !== 'granted') {
throw new DOMException('Access not granted', 'NotAllowedError')
}
const summary = await readFile(savedHandle)
result.textContent = summary
status.textContent = `Reopened ${savedHandle.name}.`
})
})
forget.addEventListener('click', () => {
void operation(async () => {
await del(key)
savedHandle = null
status.textContent = 'Saved handle forgotten. Choose a file to start again.'
})
})
input.addEventListener('click', () => { input.value = '' })
input.addEventListener('cancel', () => {
result.textContent = ''
status.textContent = 'Selection canceled. Select a file when ready.'
})
input.addEventListener('change', () => {
const file = input.files?.[0]
if (!file) return
void operation(async () => {
const summary = await readFile({ getFile: async () => file })
result.textContent = summary
status.textContent = `Read ${file.name} for this visit. Select it again after reload.`
})
})
async function initialize(): Promise<void> {
try {
if (native) savedHandle = (await get<NativeFileHandle>(key)) ?? null
status.textContent = native
? savedHandle === null ? 'Choose a file to start.' : 'Saved handle loaded. Click Reopen saved file.'
: 'Native reopen unavailable. Select a file for this visit.'
} catch {
status.textContent = 'Saved handle could not be loaded. Choose a file to retry storage.'
} finally {
ready = true
controls()
}
}
void initialize()
Install, build, and start the preview from that folder. The && chain stops if installation or the
build fails. If installation fails, fix the reported problem and repeat this command; keep your
source files and any existing lockfile. Stop the foreground server with Ctrl+C.
corepack yarn install && corepack yarn build && corepack yarn start
Open http://127.0.0.1:4173/ in the same browser profile throughout the exercise. The strict port
setting reports a conflict instead of silently changing the origin. Stop the conflicting service or
choose a different port in package.json and rebuild; that new origin has separate saved handles.
Reopen a file and recover access
Create a disposable hello.txt containing exactly hello with no trailing newline. Click
Choose file and select it in the system chooser. The result should
say 5 bytes read. First 16 bytes (hex): 68 65 6c 6c 6f.
Reload the page, then click Reopen saved file. Grant read access
if the browser asks. The status becomes Reopened hello.txt.
only after the read completes.
Edit the disposable file outside the browser, then reopen it again to read its current contents. The handle identifies a file; it does not freeze the original bytes. Closing and restarting the browser may require another grant, and clearing site storage removes the saved handle. Chrome’s persistent permission options do not make permanent access a requirement of this demo.
To exercise recovery, close and restart the browser, then click the reopen button and decline its
read-access prompt. You can also remove a file’s access in the browser’s site settings before
reloading. Declining or dismissing a prompt leaves the read unsuccessful. On the tested Chromium
version, a declined read request can return prompt rather than denied; both stop this example.
The page clears the previous result and shows
Access not granted. Allow access or choose the file again..
Grant access on a later click, or choose the file again. A saved handle does not bypass that choice.
Moving or deleting the disposable file can invalidate its handle. Reopening then shows File unavailable. It may have moved or been deleted. Choose it again.. Canceling the chooser keeps the previous saved handle, but clears the displayed read result. Forget saved file removes the IndexedDB entry without deleting or modifying the disk file. File controls stay disabled during each operation, preventing repeated clicks from starting overlapping reads or replacing their status.
Security considerations
Native pickers require a secure context and a user gesture. The loopback URL works locally; use HTTPS when serving a deployed app. A different scheme, host, or port is a different origin, so return to the exact URL to reuse this app’s IndexedDB entry.
IndexedDB can store native handles through structured cloning. JSON and localStorage cannot preserve their capabilities. This demo requests read permission only and never writes to the selected file. A saved handle is neither a backup nor evidence that the file still matches a previously started upload; verify that identity before resuming any transfer.
Large file handling
The demo consumes each stream chunk before requesting another and retains only a 16-byte preview.
It does not assemble a whole-file Blob or use arrayBuffer() for processing. This bounds the demo’s
retained preview, not the browser’s internal buffering or total process memory. An empty file is a
successful zero-byte read. For an upload consumer, await the actual chunk acknowledgment inside
consumeChunk; a handle alone does not provide retry or resume behavior.
Directory traversal and drag-and-drop selection need their own entry points and permission handling. They are outside this single-file reopen example. Keep its startup, busy, and failure behavior when adding those paths. For transferring the reopened file, tus provides a resumable upload protocol; persist the upload’s state separately from the local handle.
