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 support persistent file uploads and direct file system integration. This means you may resume interrupted uploads without requiring users to reselect their files. Because browser support varies, it is crucial to implement progressive enhancement via fallback libraries.

Browser support and progressive enhancement

For optimal compatibility, use the browser-fs-access library, which provides a reliable fallback for browsers that do not support the native File System Access API. Currently, the API is supported in Chromium-based browsers (e.g., Chrome and Edge) from version 86 onward, while Brave offers experimental support behind a flag. In contrast, stable releases of Safari and Firefox do not support this API. By checking for native support, you can seamlessly switch between the API and the fallback.

import { fileOpen, fileSave, 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 and automatically revoked when all tabs for the origin close.
  • A secure context (HTTPS) is mandatory for production use.
  • Write operations request explicit user consent before modifying files.
  • Access remains restricted to specific system folders and does not allow cross-origin file access.

Implement robust permission checks as shown below. 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
  }
}

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() {
  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

When working with large files, streaming data from the file minimizes memory consumption. The following example processes a file in manageable chunks while reporting progress:

async function handleLargeFile(fileHandle) {
  try {
    const file = await fileHandle.getFile()
    const stream = file.stream()
    const reader = stream.getReader()

    const chunks = []
    let totalSize = 0

    while (true) {
      const { done, value } = await reader.read()
      if (done) break

      chunks.push(value)
      totalSize += value.length

      // Report progress
      const progress = (totalSize / file.size) * 100
      console.log(`Processing: ${progress.toFixed(2)}%`)
    }

    return new Blob(chunks, { type: file.type })
  } catch (err) {
    console.error('Error processing file:', err)
    throw err
  }
}

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 Error('Permission denied')
    }

    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.