Persistent file handling with the File System Access API
Traditional file upload mechanisms have long been constrained by the browser's sandbox. The typical
<input type="file"> approach forces you to re-select files after a page refresh and does not
expose direct file system paths. The File System Access API offers a modern solution by enabling
persistent file handles that applications can save in IndexedDB. After permission is granted, a
returning user can reopen the same local file without selecting it again. Resuming an upload also
requires a resumable server protocol and saved upload state; a file handle alone does not provide
that. Because browser support varies, keep a standard file-selection fallback.
Browser support and progressive enhancement
The browser-fs-access library falls back to a file input where native pickers are unavailable. Check each picker method rather than assuming all file-system APIs are supported together. Consult the picker compatibility table for your target browsers. Fallback file selection does not provide a reusable native handle.
In a JavaScript project with a bundler, install browser-fs-access and idb-keyval. The latter is
used for IndexedDB handle storage below.
yarn add browser-fs-access idb-keyval
import { fileOpen, supported } from 'browser-fs-access'
async function handleFileAccess() {
if (supported) {
console.log('Using native File System Access API')
} else {
console.log('Using legacy fallback via browser-fs-access')
}
try {
const blob = await fileOpen({
mimeTypes: ['image/*'],
multiple: false,
})
return blob
} catch (err) {
if (err.name === 'AbortError') {
console.log('User cancelled file selection')
} else {
console.error('Error accessing file:', err)
}
return null
}
}
Security considerations
The File System Access API enforces several security measures to protect user data:
- Permissions are origin-bound. Saved handles can outlive a permission grant, so recheck access.
- A secure context (HTTPS) is mandatory for production use.
- Write operations request explicit user consent before modifying files.
- Access is limited to user-approved files or directories; some sensitive locations are blocked.
Call permission requests from a user action, such as a button click. If write access is required, use
{ mode: 'readwrite' } instead of { mode: 'read' }.
async function verifyPermissions(handle) {
const options = { mode: 'read' }
try {
if ((await handle.queryPermission(options)) === 'granted') {
return true
}
const permission = await handle.requestPermission(options)
return permission === 'granted'
} catch (err) {
console.error('Permission error:', err)
return false
}
}
To persist a native handle, store it with IndexedDB's structured-clone support, not JSON or
localStorage. Load the saved handle before enabling the reopen button so permission requests can
run directly from the click. Wire the following functions to your page's buttons after importing
the earlier verifyPermissions helper into the same module:
import { get, set } from 'idb-keyval'
let savedHandle = null
async function loadSavedHandle() {
savedHandle = await get('selected-upload-file') ?? null
return savedHandle !== null
}
async function choosePersistentFile() {
if (typeof window.showOpenFilePicker !== 'function') {
return handleFileAccess()
}
const [handle] = await window.showOpenFilePicker({ multiple: false })
await set('selected-upload-file', handle)
savedHandle = handle
return handle.getFile()
}
async function reopenSavedFile() {
if (!savedHandle) return null
if (!(await verifyPermissions(savedHandle))) return null
return savedHandle.getFile()
}
On page load, await loadSavedHandle() and enable a reopen button only when it returns true.
Call choosePersistentFile() or reopenSavedFile() from their respective click handlers. Catch
storage, picker cancellation and file-access errors in those handlers and show a useful status.
Users can delete browser storage, revoke permission, or move the file, so always retain the choose
button. Before resuming an upload, verify the reopened file still matches the original upload.
Directory handling
Enabling directory uploads allows you to process entire file hierarchies. The following example recursively collects files from a user-selected directory:
async function handleDirectoryUpload() {
if (typeof window.showDirectoryPicker !== 'function') {
throw new Error('Directory selection is unavailable in this browser')
}
try {
const dirHandle = await window.showDirectoryPicker()
const files = []
async function* getFilesRecursively(entry) {
for await (const handle of entry.values()) {
if (handle.kind === 'file') {
const file = await handle.getFile()
if (file) yield file
} else if (handle.kind === 'directory') {
yield* getFilesRecursively(handle)
}
}
}
for await (const file of getFilesRecursively(dirHandle)) {
files.push(file)
}
return files
} catch (err) {
if (err.name === 'AbortError') {
console.log('User cancelled directory selection')
} else if (err.name === 'SecurityError') {
console.error('Permission denied')
} else {
console.error('Error accessing directory:', err)
}
return []
}
}
Drag and drop integration
Integrate drag and drop to improve the file selection experience. The example below demonstrates type checking for dropped items and processes both file handles and standard file objects:
function setupDragAndDrop(dropZone) {
dropZone.addEventListener('dragover', (e) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
dropZone.classList.add('drag-active')
})
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('drag-active')
})
dropZone.addEventListener('drop', async (e) => {
e.preventDefault()
dropZone.classList.remove('drag-active')
const items = Array.from(e.dataTransfer.items).filter((item) => item.kind === 'file')
try {
const handles = await Promise.all(
items.map((item) => item.getAsFileSystemHandle?.() ?? item.getAsFile()),
)
for (const handle of handles) {
if (!handle) continue
if (handle instanceof File) {
// Process a regular file dropped
console.log('Processing dropped file:', handle.name)
} else if (handle.kind === 'file') {
const file = await handle.getFile()
console.log('Processing file handle:', file.name)
} else if (handle.kind === 'directory') {
console.log('Processing directory:', handle.name)
}
}
} catch (err) {
console.error('Error processing dropped items:', err)
}
})
}
Large file handling
To keep memory bounded, consume each chunk before reading another; do not collect chunks into a
new Blob. Supply an asynchronous consumeChunk callback that finishes processing or sending each
chunk before resolving. This controls application buffering, not the browser's internal buffers.
async function handleLargeFile(fileHandle, consumeChunk) {
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
// Report progress
const progress = (totalSize / file.size) * 100
console.log(`Processing: ${progress.toFixed(2)}%`)
}
return totalSize
} catch (err) {
await reader.cancel(err).catch(() => {})
throw err
} finally {
reader.releaseLock()
}
}
Error handling strategies
Ensure your file operations are robust with comprehensive error handling. The strategy below checks permissions, validates file access, and addresses common error cases:
async function safeFileOperation(handle) {
if (!handle) {
throw new Error('No file handle provided')
}
try {
const permissionGranted = await verifyPermissions(handle)
if (!permissionGranted) {
throw new DOMException('Permission denied', 'NotAllowedError')
}
const file = await handle.getFile()
if (!file) {
throw new Error('Could not access file')
}
return file
} catch (err) {
switch (err.name) {
case 'NotFoundError':
console.error('File no longer exists')
break
case 'SecurityError':
console.error('Permission denied')
break
case 'NotAllowedError':
console.error('User denied permission')
break
default:
console.error('Unexpected error:', err)
}
throw err
}
}
The File System Access API transforms web-based file handling with direct integration into the local file system. Although browser support is limited to certain environments, implementing progressive enhancement ensures that your users have a reliable experience across platforms.
For advanced file upload implementations, consider exploring tools like Uppy and tus to enable resilient, resumable uploads.
