File System Access API: Read & Write Workflows

This guide is part of Native Device Integration Patterns, the reference for giving web applications direct access to hardware, sensors, and OS-level file capabilities.

The File System Access API lets web applications read and write local files with explicit, per-handle user consent. Without it, every “save” operation forces you to synthesise a Blob, create a temporary anchor element, and trigger a download — the user picks a new file every time and never edits the original. With the API, a web text editor or data tool can open a file, stream updates into it in place, and release the OS lock cleanly — matching what a native desktop application does, without leaving the browser.


What breaks without this pattern

Absent the File System Access API, web apps are limited to one-way data flow: read via <input type="file">, write via <a download>. That creates three practical problems:

  • No in-place editing. Each save creates a new file in the Downloads folder. Users accumulate dozens of document-1.txt, document-2.txt files.
  • No streaming for large assets. FileReader and Blob.text() pull the entire file into memory before the application can act on it.
  • No directory traversal. There is no way to enumerate a folder’s contents or walk a project tree without the user selecting files one by one.

This guide closes all three gaps with a dual-path implementation: Chromium-based browsers use the native API; Firefox and Safari fall back gracefully.


Prerequisites

Before writing a single line of file I/O code, confirm these conditions:


Browser support

Browser Read (showOpenFilePicker) Write (createWritable) Minimum version
Chrome (desktop) Yes Yes 86
Edge (desktop) Yes Yes 86
Opera Yes Yes 72
Chrome for Android Yes Partial — no createWritable on Android 86
Safari No No
Firefox No No
Samsung Internet No No

The write path (createWritable) is not available on Android even in Chrome. On mobile Chromium, fall back to <a download> for writes and use the read path only for file inspection.


Architecture overview

The diagram below shows the decision tree from a user gesture through to a completed read/write cycle, including the permission cache check and both fallback branches.

File System Access API Read/Write Workflow Decision flow from user gesture through feature detection, permission handling, read/write operations, and legacy fallback paths for unsupported browsers. User gesture (click) showOpenFilePicker in window? Yes No Legacy path <input> / <a> Permission granted? Denied Show denial recovery UI Granted getFile() → ReadableStream createWritable() → write → close() Error boundary (finally) AbortError / QuotaExceededError / SecurityError Done / stream closed

Step-by-step implementation

Step 1 — Feature detection and secure context validation

Evaluate environment constraints before touching any file API. Both conditions must pass or the modern path must not execute.

export function isFileSystemAccessSupported() {
  if (!window.isSecureContext) {
    console.warn('File System Access API requires HTTPS or localhost.');
    return false;
  }
  return 'showOpenFilePicker' in window && 'createWritable' in FileSystemFileHandle.prototype;
}

createWritable is separately probed because Chrome on Android ships showOpenFilePicker but omits createWritable. Checking both avoids a partial-support trap that would surface as a runtime error deep in a write path.

Step 2 — Bind picker calls to user gestures

Every picker method (showOpenFilePicker, showSaveFilePicker, showDirectoryPicker) throws SecurityError when called outside a trusted event handler. Wire them directly to click or pointerdown — never call them from a setTimeout, a Promise chain that crossed a tick boundary without a user gesture, or a service worker.

export function attachOpenButton(buttonElement, onFileHandleReady) {
  buttonElement.addEventListener('click', async () => {
    if (!isFileSystemAccessSupported()) {
      return initLegacyReadFallback(onFileHandleReady);
    }
    try {
      const [fileHandle] = await window.showOpenFilePicker({
        types: [
          { description: 'Text files', accept: { 'text/plain': ['.txt', '.md'] } },
          { description: 'JSON files', accept: { 'application/json': ['.json'] } }
        ],
        multiple: false,
        excludeAcceptAllOption: false
      });
      onFileHandleReady(fileHandle);
    } catch (err) {
      if (err.name === 'AbortError') return; // User dismissed — normal control flow
      throw err;
    }
  });
}

Step 3 — Cache and re-validate permission across sessions

FileSystemHandle objects can be serialised to IndexedDB and retrieved across page reloads, enabling a “reopen last file” workflow. Before any I/O, always re-check permission — the browser may have revoked it.

export async function getOrRequestPermission(fileHandle, mode = 'readwrite') {
  const current = await fileHandle.queryPermission({ mode });
  if (current === 'granted') return true;

  const requested = await fileHandle.requestPermission({ mode });
  return requested === 'granted';
}

export async function reopenLastHandle(storedHandle) {
  if (!storedHandle) return null;
  const hasPermission = await getOrRequestPermission(storedHandle, 'readwrite');
  if (!hasPermission) return null;
  return storedHandle;
}

For the broader pattern of surfacing permission states in your UI and recovering from denials, see handling permission denials gracefully.

Step 4 — Read file content

FileSystemFileHandle.getFile() returns a File object (a Blob subclass). For small files under roughly 5 MB, file.text() is fine. For anything larger, use file.stream() to avoid materialising the entire content in memory. Streaming large assets is covered in detail in handling large file uploads with File System Access API.

export async function readFileHandle(fileHandle) {
  const file = await fileHandle.getFile();

  if (file.size > 5 * 1024 * 1024) {
    // Stream large files rather than loading into memory
    const stream = file.stream();
    const reader = stream.getReader();
    const chunks = [];
    let done = false;

    while (!done) {
      const { value, done: streamDone } = await reader.read();
      if (value) chunks.push(value);
      done = streamDone;
    }
    return new TextDecoder().decode(
      chunks.reduce((acc, chunk) => {
        const merged = new Uint8Array(acc.byteLength + chunk.byteLength);
        merged.set(acc, 0);
        merged.set(chunk, acc.byteLength);
        return merged;
      }, new Uint8Array(0))
    );
  }

  return file.text();
}

Step 5 — Write content via FileSystemWritableFileStream

createWritable() opens an OS-level write stream. The stream buffers writes to a temporary swap location and atomically replaces the target file only when close() succeeds — protecting the original if a write is interrupted.

write() accepts a string, ArrayBuffer, ArrayBufferView, Blob, or a WriteParams object. Use WriteParams with seek and truncate for partial updates without rewriting the full file.

export async function writeFileHandle(fileHandle, content) {
  let writable = null;
  try {
    writable = await fileHandle.createWritable();
    await writable.write(content);
    await writable.close();
    writable = null; // Mark closed so finally block skips redundant close
  } catch (err) {
    if (err.name === 'QuotaExceededError') {
      throw new Error('Not enough disk space to save this file.');
    }
    if (err.name === 'SecurityError') {
      throw new Error('Write permission was revoked. Please re-open the file.');
    }
    throw err;
  } finally {
    if (writable) {
      await writable.close().catch(() => {}); // Best-effort close on error path
    }
  }
}

Step 6 — Partial writes with seek and truncate

For append-only logs or editors that update a section of a large file, avoid rewriting everything:

export async function appendToFileHandle(fileHandle, newContent) {
  const file = await fileHandle.getFile();
  let writable = null;
  try {
    writable = await fileHandle.createWritable({ keepExistingData: true });
    await writable.seek(file.size); // Position cursor at end of existing content
    await writable.write(newContent);
    await writable.close();
    writable = null;
  } finally {
    if (writable) await writable.close().catch(() => {});
  }
}

export async function overwriteSection(fileHandle, offset, replacement) {
  let writable = null;
  try {
    writable = await fileHandle.createWritable({ keepExistingData: true });
    await writable.seek(offset);
    await writable.write(replacement);
    // Truncate removes any trailing bytes if the replacement is shorter
    await writable.truncate(offset + replacement.length);
    await writable.close();
    writable = null;
  } finally {
    if (writable) await writable.close().catch(() => {});
  }
}

Error handling reference

All File System Access API errors surface as DOMException. Identify them by err.name, not err.message, which varies between browsers.

Error name Trigger Recovery
AbortError User dismissed the picker dialog Treat as normal cancellation; do not surface an error UI
SecurityError Called outside a user gesture, non-secure context, or after permission revocation Re-prompt via requestPermission() or activate the legacy path
NotAllowedError User denied the permission prompt Show denial recovery UI; offer QR code sharing or clipboard as alternatives
QuotaExceededError Disk quota exhausted during a write Surface a storage warning; write in smaller chunks; offer a download fallback
NotFoundError The file was moved or deleted since the handle was acquired Re-open via showOpenFilePicker
TypeMismatchError Tried to call a directory method on a file handle, or vice versa Validate handle type before operations

Wrap the full read/write cycle in a single try/catch/finally. Close the writable stream in finally regardless of outcome — an unclosed stream holds an OS file lock that persists until the page unloads.


Payload validation and serialisation

When writing structured data (JSON, CSV, binary), validate and serialise before passing the payload to write(). Passing a raw object reference silently produces [object Object] in the file.

export async function writeJsonToHandle(fileHandle, data) {
  let serialised;
  try {
    serialised = JSON.stringify(data, null, 2);
  } catch (err) {
    throw new TypeError(`Cannot serialise data to JSON: ${err.message}`);
  }

  await writeFileHandle(fileHandle, serialised);
}

When the file is intended to hold rich text alongside file data, you may want to bridge to the Clipboard API for rich text — for example, reading HTML from the clipboard and writing it as a sanitised file. The async payload formatting guide covers the serialisation pipeline for mixed-type payloads in detail.


Platform gotchas

Chrome for Android — no createWritable.

The write path is unavailable on Android regardless of Chrome version. Feature-detect createWritable separately (see Step 1) and fall back to <a download> on mobile Chromium.

iOS and Safari — complete absence.

Safari does not implement showOpenFilePicker or any part of the File System Access API as of mid-2026. The legacy fallback (Step 7) is the only viable path for all Safari users.

Iframe restrictions.

The API requires a top-level browsing context. Calling any picker from within an <iframe> without allow="file-system-access" throws SecurityError. Add the permission policy header or attribute if you embed the feature in an iframe.

Persistent handles and incognito mode.

Serialising a FileSystemHandle to IndexedDB fails silently in incognito/private browsing in some Chromium versions. Wrap idb.set() in a try/catch and treat the serialisation failure as a non-fatal cache miss — the user will simply re-select the file on next visit.

File lock duration.

On Windows, the OS lock held by an open FileSystemWritableFileStream prevents other processes from writing to the same file. Minimise the window between createWritable() and close(). Do not hold the stream open across idle user time.


Graceful degradation and legacy fallback

Step 7 — Legacy read and write paths

For browsers that fail the feature detection check, implement a two-function fallback. The read path uses a dynamically created <input type="file"> element; the write path synthesises a Blob and triggers a download.

export function initLegacyReadFallback(onContentReady) {
  const input = document.createElement('input');
  input.type = 'file';
  input.accept = 'text/plain,application/json,.md';

  input.onchange = async (event) => {
    const file = event.target.files?.[0];
    if (!file) return;
    try {
      const text = await file.text();
      onContentReady(text, file.name);
    } catch (err) {
      console.error('Legacy file read failed:', err);
    }
  };

  input.click();
}

export function downloadViaBlob(content, filename, mimeType = 'text/plain') {
  const blob = new Blob([content], { type: mimeType });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = filename;
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
  // Revoke after a tick to ensure the browser has started the download
  setTimeout(() => URL.revokeObjectURL(url), 100);
}

When offline or cross-device continuity matters — for example, a user on Safari who wants to exchange a file with another device — consider routing through Web NFC tag reading and writing for proximity-based transfer, or the offline share queue for deferred delivery.


Testing and verification

DevTools — Application panel.

Open DevTools → Application → File System to inspect active file handles and their permission states in supported Chromium versions.

Manual device checklist.

  • Chrome desktop (Windows/macOS): test the full read/write cycle including keepExistingData: true appends.
  • Chrome for Android: confirm createWritable is absent and the Blob download fallback activates.
  • Safari (desktop and iOS): confirm the legacy <input> path loads without errors.
  • Firefox: same as Safari.
  • Private/incognito mode in Chrome: verify the IndexedDB serialisation failure is caught and does not crash the application.
Quota testing.

Use the Storage Manager API to inspect available quota before large writes:

export async function hasStorageQuota(requiredBytes) {
  if (!('storage' in navigator && 'estimate' in navigator.storage)) return true;
  const { quota, usage } = await navigator.storage.estimate();
  return (quota - usage) >= requiredBytes;
}

Call this before opening a write stream for files above a few megabytes, and surface a user-facing warning if space is insufficient.


Common pitfalls

  • Missing user gesture. Calling picker APIs outside a direct click or tap handler triggers an immediate SecurityError. Async code that awaits a network call or timer before invoking the picker loses the gesture context.
  • Unclosed streams. Failing to call close() on FileSystemWritableFileStream holds an OS file lock and corrupts subsequent writes. Always use a finally block.
  • Main thread blocking. Using Blob.arrayBuffer() on multi-gigabyte files freezes the UI. Use File.stream() and process chunks in a Web Worker.
  • Ignoring AbortError. Users frequently dismiss native picker dialogs. Treat AbortError as normal control flow — catch it separately and return silently.
  • Quota blindness. Large stream writes can exhaust available storage quota without warning. Catch QuotaExceededError and implement chunked writes with progress feedback.
  • Passing objects to write(). The write() method does not serialise JavaScript objects. Always convert to a string, ArrayBuffer, or Blob before calling it.

FAQ

Why does showOpenFilePicker throw a SecurityError?

The API requires execution within a secure context (HTTPS or localhost) and must be triggered by a direct, trusted user gesture. Background timers or programmatic calls are blocked by the browser’s security model. If your code crosses an await boundary (e.g., awaiting a network fetch) before calling the picker, the gesture context is lost.

How do I handle large files without blocking the main thread?

Use file.stream() to get a ReadableStream, then pipe it to a TransformStream or WritableStream. For CPU-heavy parsing (CSV, JSON), pass chunks to a Web Worker via postMessage with transferable ArrayBuffer ownership to avoid copying.

What is the recommended fallback for browsers without File System Access API support?

Use <input type="file"> for reading and <a download> with a dynamically generated Blob URL for writing. Firefox and Safari users will follow this path. Always detect support with 'showOpenFilePicker' in window before branching; do not use user-agent sniffing.

Can I persist a FileSystemFileHandle across page reloads?

Yes. Serialise the handle to IndexedDB (using idb-keyval or a similar library). On reload, deserialise and call requestPermission({ mode: 'readwrite' }) before any file operation — the browser will re-prompt if permission has lapsed. This does not work reliably in private browsing mode; wrap the serialisation in a try/catch.