Skip to content

File System Access API

The File System Access API lets a web app open files and folders the user picks, read them, and write changes straight back to the same files on disk instead of uploading copies and downloading new ones. It adds three pickers (showOpenFilePicker(), showSaveFilePicker() and showDirectoryPicker()), a per-handle permission model, and a transactional FileSystemWritableFileStream, which together make browser-based editors such as VS Code for the Web possible. It ships only in Chromium-based browsers (desktop since Chrome 86, Android and WebView since Chrome 132), while Firefox and Safari implement only the origin private file system subset, so every PWA that depends on it needs a fallback path.

Key takeaways

  • Pickers work only in a secure, top-level (or same-origin) window context and consume transient user activation. Cancelling rejects with AbortError; when a picker resolves, Chromium grants the page fresh activation so you can chain requestPermission() or another picker.
  • You get handles, never paths. Handles are structured-cloneable, so they survive in IndexedDB and cross postMessage() to same-origin windows and workers, but their permissions do not automatically survive with them.
  • showSaveFilePicker() creates or empties the chosen file immediately, before you write a byte. Writes go to a sibling swap file (name.crswap in Chromium) and replace the original atomically only when close() resolves, after Safe Browsing and quarantine checks.
  • Since Chrome 122, users can choose "Allow on every visit", and installed desktop PWAs keep file grants across sessions automatically. Without that, access ends when the origin's last tab closes.
  • Chromium blocks sensitive locations (system folders, the browser profile, ~/.ssh, ~/Library, /etc and more) and warns before saving dangerous file types.
  • Firefox's standards position is negative and WebKit's is oppose. Build on <input type="file">, <a download> or a library such as browser-fs-access, and treat the File System Access API as a progressive enhancement.

The API family: what belongs to which specification

"File System Access" is used loosely for several related APIs that have different specifications and very different browser support. Knowing which part you are calling tells you immediately whether it will work in Safari.

Piece Specified in Shipped by
FileSystemHandle, FileSystemFileHandle, FileSystemDirectoryHandle, FileSystemWritableFileStream, FileSystemSyncAccessHandle, navigator.storage.getDirectory() WHATWG File System Standard Chromium, Firefox 111+, Safari 15.2+ (the writable stream only since Safari 26)
showOpenFilePicker(), showSaveFilePicker(), showDirectoryPicker(), queryPermission(), requestPermission(), DataTransferItem.getAsFileSystemHandle() WICG File System Access (Community Group draft) Chromium only
FileSystemHandle.remove(), createWritable({ mode }), FileSystemObserver Proposals and explainers Chromium only
FileSystemFileHandle.move() Proposal Chromium (files only), plus origin private file system entries in Firefox and Safari
DataTransferItem.webkitGetAsEntry(), FileSystemEntry File and Directory Entries API All engines (legacy, read-only)

The WHATWG standard defines handles and streams on top of an abstract file system entry. The only entry point it defines itself is the origin private file system (OPFS), a sandboxed, quota-managed storage area that is invisible to the user. The WICG draft adds the entry points into the user's real file system (the pickers, drag and drop) plus a permission layer, because a user-visible file needs consent that an origin-private file does not. Everything on this page about pickers, permissions and swap files applies to user-visible files. OPFS gets its own page.

The API also has an unrelated namesake: the old FileSystem/FileSystemEntry interfaces from the File and Directory Entries API, which you still get from webkitGetAsEntry() and <input webkitdirectory>. Those are read-only, callback-based, and not interchangeable with FileSystemHandle.

Browser support

Feature Chrome / Edge desktop Chrome Android Firefox Safari macOS Safari iOS/iPadOS
showOpenFilePicker(), showSaveFilePicker(), showDirectoryPicker() ✅ 86 ✅ 132 ❌ ❌ ❌
DataTransferItem.getAsFileSystemHandle() ✅ 86 ✅ 132 ❌ ❌ ❌
queryPermission() / requestPermission() ✅ 86 ✅ 109 ❌ ❌ ❌
showDirectoryPicker({ mode: "readwrite" }) ✅ 105 ✅ 132 ❌ ❌ ❌
Persistent permissions ("Allow on every visit") ✅ 122 ⚠️ ❌ ❌ ❌
FileSystemWritableFileStream ✅ 86 ✅ 109 ✅ 111 (OPFS) ✅ 26 (OPFS) ✅ 26 (OPFS)
createWritable({ mode }) locking ✅ 121 ✅ 121 ❌ ❌ ❌
FileSystemHandle.remove() ✅ 110 ✅ 110 ❌ ❌ ❌
FileSystemObserver ✅ 133 ❌ ❌ ❌ ❌
Origin private file system (navigator.storage.getDirectory()) ✅ 86 ✅ 109 ✅ 111 ✅ 15.2 ✅ 15.2

Support data as of September 2026. Versions come from MDN's browser compatibility data and chromestatus.com; check caniuse for live data. Edge and Opera follow the Chromium milestone (Opera 72 for the pickers), Samsung Internet added the pickers in version 29, and Android WebView gained them in 132. Brave ships the API only behind a flag. Chrome's Android rollout is recent: pickers there use the Android system document picker, and the installed-app permission persistence described below is implemented only on desktop, hence the ⚠️.

The positions of the other engines are unlikely to change soon. Mozilla rates the API negative, explaining that it could support a read/write file API as a storage endpoint but does not think meaningful consent is possible for cross-site access to the local file system. WebKit's position is oppose. Both engines ship OPFS instead.

Feature detection

Detect each capability separately. The pickers, drag-and-drop handles and permission methods can be missing independently of each other, and OPFS support says nothing about pickers.

src/fs-support.js
export const fsSupport = {
  // Pickers into the user's real file system (Chromium only).
  openPicker: "showOpenFilePicker" in globalThis,
  savePicker: "showSaveFilePicker" in globalThis,
  directoryPicker: "showDirectoryPicker" in globalThis,
  // Handles from drag and drop (Chromium only).
  dropHandles:
    typeof DataTransferItem !== "undefined" &&
    "getAsFileSystemHandle" in DataTransferItem.prototype,
  // Permission methods exist wherever handles to user files can exist.
  permissions:
    typeof FileSystemHandle !== "undefined" &&
    "requestPermission" in FileSystemHandle.prototype,
  // Cross-browser subset: the origin private file system.
  opfs: typeof navigator.storage?.getDirectory === "function",
  // Change notifications (Chromium desktop 133+).
  observer: "FileSystemObserver" in globalThis,
};

Do not use window.chooseFileSystemEntries, which was the origin-trial name of the API (Chrome 78 to 80, then called the Native File System API). It was removed before the API shipped, and old tutorials that test for it report no support in browsers that have it.

Preconditions for showing a picker

All three pickers run the same gatekeeping steps before any UI appears. Most "the picker does nothing" bugs come from one of them.

  1. Secure context and a window. The methods exist only on Window in secure contexts (https: or http://localhost). Workers cannot open pickers, although they can use handles that are posted to them.
  2. An origin that can access the file system. Documents with an opaque origin (a sandboxed iframe without allow-same-origin, a data: URL) get SecurityError ("Sandboxed documents aren't allowed to show a file picker.").
  3. Same origin as the top-level page. A cross-origin iframe gets SecurityError ("Cross origin sub frames aren't allowed to show a file picker."). Same-origin iframes are allowed. Fenced frames are rejected too.
  4. Transient user activation. Without a recent click, key press or similar gesture, Chromium throws SecurityError ("Must be handling a user gesture to show a file picker."). Showing the picker consumes the activation, so you cannot open two pickers from one click.
  5. One picker at a time per frame. A second call while a picker is open rejects with NotAllowedError ("File picker already active.").

Options are validated first: an invalid types entry or id rejects with TypeError before any of these checks run. If the user dismisses the dialog, the promise rejects with AbortError; treat that as a normal outcome, not an error to report.

When a picker resolves, the spec tells the browser to perform the activation notification steps: the page receives a fresh transient activation. That is deliberate, so that code like the following works without a second click.

src/open-folder.js
// One click: pick a folder, then immediately ask for write access to it.
async function openProjectFolder() {
  const dir = await window.showDirectoryPicker({ id: "project" });
  // The picker resolved, so the page has fresh transient activation and
  // requestPermission() is allowed to show its prompt.
  if ((await dir.requestPermission({ mode: "readwrite" })) !== "granted") {
    throw new DOMException("Write access denied", "NotAllowedError");
  }
  return dir;
}

The consequence in the other direction bites often: do slow work after the picker, not before it. Transient activation expires after a few seconds. If a "Save" handler first serializes a large document, renders a thumbnail, or awaits a network request, the activation window can close before showSaveFilePicker() runs, and the call fails with the user-gesture SecurityError. Get the handle first, then compute what to write.

sequenceDiagram
    participant User
    participant Page
    participant Browser
    User->>Page: click "Save as"
    Page->>Browser: showSaveFilePicker(options)
    Browser->>Browser: validate options, check frame and activation
    Browser->>User: native save dialog
    User-->>Browser: chooses report.txt
    Browser->>Browser: blocklist and dangerous-type checks
    Browser->>Browser: create or truncate report.txt
    Browser-->>Page: FileSystemFileHandle (read and write granted)
    Page->>Browser: createWritable, write, close

showOpenFilePicker(): choosing existing files

WICG File System Access: Window
Promise<sequence<FileSystemFileHandle>> showOpenFilePicker(
  optional OpenFilePickerOptions options = {});

The method always resolves with an array, even for a single file, so destructure it: const [handle] = await showOpenFilePicker(). Every returned handle has read permission already granted; write permission is still "prompt".

Option Type Default Meaning
types Array of { description, accept } [] Filters offered in the dialog's type menu. Each entry is one filter.
types[].description String "" Label for the filter. If empty, the browser generates one from the types.
types[].accept Object: MIME type to extension or array of extensions Required for a useful filter What the filter matches.
excludeAcceptAllOption Boolean false Removes the "All files" filter so the user can only pick matching files.
multiple Boolean false Allows selecting more than one file.
id String "" Key for remembering the last-used directory separately per purpose.
startIn Well-known directory name or a FileSystemHandle None Suggested starting directory.

How types and accept are validated

accept maps MIME types to one extension string or an array of them. Both halves matter: some platforms filter by extension and others by MIME type, so always give both. The validation rules are strict and throw TypeError for the whole call if any single value is wrong:

  • The key must parse as a MIME type without parameters. "text/plain" and "image/*" are valid; "text/plain; charset=utf-8" and "csv" throw. A * subtype matches every subtype of that type.
  • Each extension must start with ., contain only ASCII letters, digits, + and ., must not end with ., and must be at most 16 characters long. ".tar.gz" is valid; ".my file", ".c#" and "txt" throw.
  • Chromium enforces those character and length rules in the renderer, then filters again in the browser process: extensions that are not legal file-name components and shell-integrated extensions (.lnk, .local, .scf, .url, and CLSID-style {...} extensions, which Windows can use to run code, load DLLs or read arbitrary files) are silently dropped from the dialog's filter rather than throwing. A suggestedName ending in one of them is renamed to end in .download.
  • excludeAcceptAllOption: true combined with no usable types throws TypeError: Need at least one accepted type, because the dialog would have no filter at all.
src/open-images.js
const IMAGE_TYPES = [
  {
    description: "Images",
    accept: {
      "image/png": [".png"],
      "image/jpeg": [".jpg", ".jpeg"],
      "image/webp": ".webp", // A single string is allowed.
      "image/avif": [".avif"],
    },
  },
  {
    description: "Vector graphics",
    accept: { "image/svg+xml": [".svg"] },
  },
];

export async function pickImages() {
  try {
    const handles = await window.showOpenFilePicker({
      types: IMAGE_TYPES,
      excludeAcceptAllOption: true, // Only matching files are selectable.
      multiple: true,
      id: "images", // Remember the image folder separately from documents.
      startIn: "pictures",
    });
    // Read permission is already granted for every returned handle.
    return Promise.all(handles.map(async (handle) => ({
      handle,
      file: await handle.getFile(),
    })));
  } catch (err) {
    if (err.name === "AbortError") return []; // User cancelled: not an error.
    throw err;
  }
}

The filter is advisory for the dialog, not a security boundary. On platforms where the dialog filters by extension, a file named photo.png that actually contains a PDF passes; with the "All files" filter available the user can pick anything. Validate content (magic bytes, createImageBitmap() success, a parser) before trusting it.

Controlling where pickers open: startIn and id

Pickers remember the last directory used by each origin. Two options influence that, and their interaction is spelled out step by step in the spec's determine the directory the picker will start in algorithm.

startIn accepts either a handle or one of six well-known directory names:

Value Meaning
"desktop" The user's desktop directory, if the platform has one
"documents" Where documents created by the user are typically stored
"downloads" Where downloaded files are typically stored
"music" Where audio files are typically stored
"pictures" Where photos and other still images are typically stored
"videos" Where videos are typically stored

A FileSystemFileHandle passed as startIn opens the dialog in the file's parent directory; a FileSystemDirectoryHandle opens that directory. Handles from the origin private file system are ignored, because they have no location the user can see.

id names a purpose ("exports", "project", "images"). Each id gets its own remembered directory, so an "Import image" dialog does not jump to the folder the user last saved a document in. The id may contain only ASCII letters, digits, _ and -, and at most 32 characters; anything else throws TypeError. Chromium stores up to 32 custom ids per origin (plus the default, id-less entry) and evicts the least recently used one beyond that.

The precedence rules are the part people get wrong:

  1. A handle in startIn always wins.
  2. Otherwise, if id is given and a directory was remembered for that id, the remembered directory wins, even over a well-known startIn.
  3. Otherwise, a well-known startIn is used.
  4. Otherwise, with no id, the origin's default remembered directory is used.
  5. Otherwise, the browser picks a default.

So { id: "exports", startIn: "documents" } means "Documents the first time, then wherever the user last saved an export". That is usually what you want. If you need a fixed start location every time, omit id, or pass a handle.

After every successful pick, the browser records the directory of the first selected entry under the id (or the default key). None of this is exposed to the page: you cannot read the remembered path.

showSaveFilePicker(): creating and overwriting files

WICG File System Access: Window
Promise<FileSystemFileHandle> showSaveFilePicker(
  optional SaveFilePickerOptions options = {});

It accepts types, excludeAcceptAllOption, id and startIn with the same rules as the open picker, plus suggestedName, a string pre-filled as the file name (added in Chrome 91). The handle it returns has both read and write permission granted, so createWritable() does not prompt.

The chosen file is emptied before your code runs

The spec requires the browser to set the selected file's contents to an empty byte sequence before resolving, and Chromium does exactly that: it creates the file if it does not exist and truncates it to zero bytes if it does. If the user picks an existing report.docx to overwrite and your code then fails to serialize the document, the user is left with an empty file. Have the data (or a reliable way to produce it) ready before calling the picker, write immediately after it resolves, and on failure tell the user plainly that the file was not saved.

Other details of the save flow:

  • suggestedName is a suggestion. The browser may sanitize dangerous names the way it sanitizes download file names, and the interaction with the selected type filter is implementation-defined. Include the extension you want in suggestedName; with a types entry selected, the dialog may also append or adjust the extension.
  • Dangerous file types trigger a confirmation. When the chosen name has a type that Chrome's Safe Browsing file-type policy classifies as dangerous (executables, scripts and similar), Chrome shows a dialog titled "Save filename?" with the text "This file of type (.ext) can be dangerous. Only save this file if you trust origin" and Save / Don't save buttons. Declining rejects the promise with AbortError.
  • Restricted locations re-prompt. Choosing a blocked directory (see the security model) shows a "Can't open this folder" dialog with a Choose a different folder button instead of returning a handle.
  • The OS confirms overwrites, not the browser. Picking an existing file shows the native "Replace?" confirmation of the platform dialog.
src/save-as.js
export async function saveAs(serialize, suggestedName = "Untitled.txt") {
  // serialize() must be cheap or pre-computed: the picker needs the click's
  // activation, so it has to run before any slow work.
  let handle;
  try {
    handle = await window.showSaveFilePicker({
      suggestedName,
      id: "documents",
      startIn: "documents",
      types: [{ description: "Text", accept: { "text/plain": [".txt"] } }],
    });
  } catch (err) {
    if (err.name === "AbortError") return null; // Cancelled.
    throw err;
  }

  // From here on, the file exists and is EMPTY on disk.
  const writable = await handle.createWritable(); // No prompt: write granted.
  try {
    await writable.write(await serialize());
    await writable.close(); // The data reaches the real file only now.
  } catch (err) {
    await writable.abort().catch(() => {}); // Discards the swap file.
    throw new Error(`"${handle.name}" was created but could not be written`, {
      cause: err,
    });
  }
  return handle;
}

showDirectoryPicker(): read-only and read-write folders

WICG File System Access: Window
Promise<FileSystemDirectoryHandle> showDirectoryPicker(
  optional DirectoryPickerOptions options = {});

The options are id, startIn and mode ("read" by default, or "readwrite", added in Chrome 105). Unlike the file pickers, choosing a directory is not enough: after the dialog closes, the browser shows a permission prompt for the chosen folder, and the promise rejects with AbortError if the user does not grant it. Chrome's prompts read:

Mode Prompt title Body
"read" Allow this site to view and copy files? origin will be able to view and make its own copies of files in folder
"readwrite" Allow this site to edit files? origin will be able to edit files in folder

Before Chrome 105 a directory picker always returned read access, and writing required a second prompt. Pass mode: "readwrite" whenever you know you will write (an IDE, a static-site generator, a photo organizer) so the user sees one prompt instead of two. Permission on a directory covers everything inside it: handles you later obtain with getFileHandle() or by iterating inherit the directory's grant.

Chromium refuses a few kinds of directories outright, with a "Can't open this folder" dialog ("origin can't open this folder because it contains system files"). That includes the user's entire home directory, Desktop, Documents and Downloads folders themselves (subfolders are fine), the browser's own installation and profile directories, and system locations. The complete list is in the security model section.

src/open-project.js
export async function openProject() {
  try {
    return await window.showDirectoryPicker({
      id: "project",
      mode: "readwrite", // One combined prompt instead of read now, write later.
    });
  } catch (err) {
    // AbortError covers both "dialog cancelled" and "permission denied".
    if (err.name === "AbortError") return null;
    throw err;
  }
}

Handles: the objects you work with

A handle is a reference to a file system entry plus a permission state. It carries a name but no path, and it is the only way to reach a user file through this API.

FileSystemHandle (base interface)

Member Returns Notes
kind "file" or "directory" Use it instead of instanceof, which fails across realms (iframes, workers).
name String The entry's name only, never a path.
isSameEntry(other) Promise<boolean> True if both handles refer to the same entry. Use it to deduplicate "recent files" lists; names are not identities.
queryPermission({ mode }) Promise<"granted" \| "prompt" \| "denied"> Never shows UI. mode is "read" (default) or "readwrite".
requestPermission({ mode }) Promise<"granted" \| "prompt" \| "denied"> May prompt. Needs transient activation.
remove({ recursive }) Promise<undefined> Chromium 110+. Deletes the entry itself. Needs write permission.
move(...) Promise<undefined> On FileSystemFileHandle only; see Chromium-only extras.

FileSystemFileHandle

Member Returns Notes
getFile() Promise<File> A snapshot File. Needs read permission.
createWritable({ keepExistingData, mode }) Promise<FileSystemWritableFileStream> Needs write permission; prompts if it is "prompt".
createSyncAccessHandle() Promise<FileSystemSyncAccessHandle> OPFS files in dedicated workers only. On a user file it rejects with InvalidStateError ("Access Handles may only be created on temporary file systems").

FileSystemDirectoryHandle

Member Returns Notes
getFileHandle(name, { create }) Promise<FileSystemFileHandle> create: true creates an empty file if missing.
getDirectoryHandle(name, { create }) Promise<FileSystemDirectoryHandle> create: true creates the directory if missing.
removeEntry(name, { recursive }) Promise<undefined> Non-empty directories need recursive: true.
resolve(possibleDescendant) Promise<string[] \| null> Path components from this directory to the descendant, or null if it is not inside.
entries(), keys(), values(), [Symbol.asyncIterator] Async iterator Direct children only, in no guaranteed order.

Entry names passed to getFileHandle(), getDirectoryHandle() and removeEntry() must be valid file names: not empty, not . or .., and without / or the platform's other path separators. Invalid names reject with TypeError. That rule is what keeps a handle from escaping its directory; there is no API that accepts a path.

The errors are consistent across these methods:

Exception Typical cause
NotAllowedError Permission is not "granted" for the required mode, or the user revoked it.
NotFoundError The entry was deleted or moved on disk since the handle was created, or create was false and it does not exist.
TypeMismatchError getFileHandle() on a name that is a directory, or getDirectoryHandle() on a file.
InvalidModificationError removeEntry() or remove() on a non-empty directory without recursive: true.
NoModificationAllowedError A lock conflict (an exclusive writer or a sync access handle is open), or the file is read-only on disk.
TypeError Invalid entry name or invalid option values.
SecurityError Missing user activation for a prompt, or a disallowed context.
AbortError Picker cancelled, permission prompt refused inside a picker, or Safe Browsing blocked a write.

Handles are serializable, but they are not paths

FileSystemHandle objects are [Serializable]: you can store them in IndexedDB, send them with postMessage() to same-origin windows, iframes and workers, and put them in BroadcastChannel messages. They cannot go to another origin, and they cannot be stored in localStorage or turned into JSON. A handle deserialized in a worker points to the same entry and shares the same permission grant, which is how you move heavy parsing off the main thread without re-prompting.

What you cannot do is learn where the file lives. There is no path property and no way to get one. resolve() returns path components relative to a directory you already hold, which is enough to build a project tree but reveals nothing above the directory the user chose.

Reading files

getFile() returns a File, which is a Blob with name, lastModified and type. Chromium backs it with the file on disk rather than a copy, which makes it cheap for multi-gigabyte files, and it has one consequence people trip over: the File is a snapshot. If the file changes on disk after getFile(), reading the old File object rejects (Chromium reports a NotReadableError). Call getFile() again for the current contents, and use lastModified plus size to detect that the file changed under you.

Pick the reading method by size and access pattern:

Method Use for
await file.text() Small to medium text, decoded as UTF-8
await file.arrayBuffer() Binary formats you parse in memory
file.stream() Large files processed sequentially (logs, CSV, media)
file.slice(start, end) Random access into large files (archives, indexes, video containers)
src/read-text.js
const MAX_IN_MEMORY = 50 * 1024 * 1024; // 50 MiB: beyond this, stream it.

// Reads a text file, streaming large files line by line instead of
// allocating one giant string.
export async function readText(handle, { onLine } = {}) {
  const file = await handle.getFile(); // Snapshot: re-call after changes.
  if (file.size <= MAX_IN_MEMORY || !onLine) {
    return { file, text: await file.text() };
  }

  const reader = file
    .stream()
    .pipeThrough(new TextDecoderStream()) // UTF-8 by default.
    .getReader();
  let pending = "";
  for (;;) {
    const { value, done } = await reader.read();
    if (done) break;
    pending += value;
    const lines = pending.split("\n");
    pending = lines.pop(); // Keep the incomplete last line.
    for (const line of lines) onLine(line);
  }
  if (pending) onLine(pending);
  return { file, text: null };
}

// Detects external modification since a previous snapshot.
export async function changedOnDisk(handle, previous) {
  const current = await handle.getFile();
  return (
    current.lastModified !== previous.lastModified ||
    current.size !== previous.size
  );
}

getFile() on a handle whose entry has been deleted or moved rejects with NotFoundError. That is common with handles restored from IndexedDB days later; treat it as "remove from recent files", not as a crash.

Writing files with FileSystemWritableFileStream

createWritable() returns a FileSystemWritableFileStream, a WritableStream subclass with three convenience methods. Its most important property is that nothing you write touches the real file until close() resolves.

WHATWG File System (plus Chromium mode)
dictionary FileSystemCreateWritableOptions {
  boolean keepExistingData = false;
  FileSystemWritableFileStreamMode mode = "siloed"; // Chromium 121+, non-standard.
};

interface FileSystemWritableFileStream : WritableStream {
  Promise<undefined> write(FileSystemWriteChunkType data);
  Promise<undefined> seek(unsigned long long position);
  Promise<undefined> truncate(unsigned long long size);
};

dictionary WriteParams {
  required WriteCommandType type; // "write" | "seek" | "truncate"
  unsigned long long? size;
  unsigned long long? position;
  (BufferSource or Blob or USVString)? data;
};

What createWritable() does

  1. Permission check. If write permission is "prompt", Chrome shows "Save changes to file?" with a Save changes button. If it is not granted, the promise rejects with NotAllowedError. A file the OS marks read-only rejects with NoModificationAllowedError ("Cannot write to a read-only file.").
  2. Lock. The default "siloed" mode lets several writers coexist, each with its own swap file; the last one to close wins. mode: "exclusive" makes a second createWritable() on the same file reject with NoModificationAllowedError until the first writer closes, which is what you want when two windows of your PWA might save the same document.
  3. Swap file. Chromium creates a sibling file named <name>.crswap (then <name>.1.crswap, <name>.2.crswap and so on if that name is taken, up to 100 attempts) in the same directory. With keepExistingData: true the current contents are copied into it first (on macOS as an APFS copy-on-write clone, which is nearly free); otherwise the swap file starts empty.

Writing, seeking and truncating

The stream keeps a cursor that starts at 0 and advances by the number of bytes written.

Call Effect
write(data) Writes at the cursor, then advances it. Strings are encoded as UTF-8; ArrayBuffer, typed arrays, DataView and Blob are written as bytes.
write({ type: "write", position, data }) Writes at position and leaves the cursor after the written data. Missing data rejects with TypeError.
seek(position) or write({ type: "seek", position }) Moves the cursor. Missing position rejects with TypeError.
truncate(size) or write({ type: "truncate", size }) Shrinks the swap file or extends it with zero bytes; if the cursor is beyond the new size it moves to size.

Writing past the current end first fills the gap with NUL bytes. The spec expects implementations to use sparse files, so seeking to offset 10 GB and writing one byte does not consume 10 GB of disk on file systems that support sparse files. Seeking past the end has been allowed since Chrome 90.

keepExistingData defaults to false

Patching part of a file (updating a header, appending to a log) with seek() or position only works if you open the stream with keepExistingData: true. With the default, the swap file starts empty, so writing 16 bytes at position 1024 produces a file of 1,040 bytes whose first 1,024 bytes are zeros, and the rest of the original file is gone after close().

What close() does

Closing is where the atomic replace happens, and in Chromium it involves more than a rename:

sequenceDiagram
    participant Page
    participant Browser
    participant Disk
    Page->>Browser: createWritable()
    Browser->>Disk: create notes.txt.crswap
    Page->>Browser: write, seek, truncate
    Browser->>Disk: modify the swap file only
    Page->>Browser: close()
    Browser->>Disk: hash the swap file (SHA-256)
    Browser->>Browser: Safe Browsing check on hash, size and name
    Browser->>Disk: move swap file over notes.txt
    Browser->>Disk: apply quarantine metadata
    Browser-->>Page: close() resolves
  1. Chromium waits for pending writes, hashes the complete swap file, and runs its Safe Browsing download-protection checks with the target file name. If they fail, close() rejects with AbortError ("Blocked by Safe Browsing.") and the original file is untouched.
  2. It moves the swap file over the target, preserving the destination's file permissions. On the same volume that is an atomic rename, so other programs see either the old or the new file, never a half-written one.
  3. It applies the same quarantine step used for downloads (Mark-of-the-Web on Windows, the quarantine attribute on macOS), recording the page URL as the source outside Incognito, so the OS knows the file came from the web.

Two practical consequences follow. First, close() on a large file takes time proportional to its size, because the whole swap file is hashed; show progress for multi-hundred-megabyte saves. Second, files written this way carry web-origin quarantine metadata, which can make desktop apps show "downloaded from the internet" warnings when they open them.

If you stop without closing, nothing reaches the file. abort() (or an error in a piped stream) deletes the swap file. If the tab is closed or crashes mid-write, the original is still intact, though a crash can leave a stale .crswap file next to it. The spec requires the implementation to check write permission again at close(), so a user who revokes access mid-edit makes close() reject with NotAllowedError.

Streaming into a file

Because the stream is a WritableStream, anything that produces a ReadableStream can be piped into it, and backpressure keeps memory flat. pipeTo() closes the destination on success and aborts it on failure, so you do not call close() yourself.

src/download-to-file.js
// Downloads a large resource straight into a user-chosen file.
export async function downloadToFile(url, suggestedName) {
  const handle = await window.showSaveFilePicker({ suggestedName });
  const response = await fetch(url);
  if (!response.ok || !response.body) {
    throw new Error(`Download failed: HTTP ${response.status}`);
  }
  const writable = await handle.createWritable();
  // pipeTo() closes the file on success and aborts it (deleting the swap
  // file) on error, so the target never ends up half-written. The save
  // picker already emptied it, though.
  await response.body.pipeTo(writable);
  return handle;
}

// Appends a line to an existing log file without rewriting it by hand.
export async function appendLine(handle, line) {
  const { size } = await handle.getFile();
  const writable = await handle.createWritable({ keepExistingData: true });
  try {
    await writable.write({ type: "write", position: size, data: `${line}\n` });
    await writable.close();
  } catch (err) {
    await writable.abort().catch(() => {});
    throw err;
  }
}

Note that appendLine() still rewrites the whole file on every call from the disk's point of view: the swap file is a full copy that replaces the original. For high-frequency appends, batch lines in memory and write periodically, or keep the working data in OPFS with a sync access handle and export on demand.

Exceptions from writing

Method Exception When
createWritable() NotAllowedError Write permission not granted (prompt refused or revoked)
createWritable() NoModificationAllowedError Lock conflict, read-only file, or swap file could not be locked
createWritable() NotFoundError The file no longer exists
createWritable() AbortError Swap file could not be created, or a malware check failed
write() TypeError data missing, or position/size missing for seek/truncate commands
write() QuotaExceededError OPFS quota exceeded (user files have no quota, but can still hit a full disk)
close() AbortError Safe Browsing blocked the result
close() NotAllowedError Permission revoked before closing
write() after close() TypeError Streams rule: a closing or closed stream accepts no more chunks

A WritableStream that has thrown is errored: every later write() rejects. Always call abort() in your error path to release the lock and delete the swap file, then start over with a new createWritable().

Permissions in depth

Every handle has two independent permission states, one for "read" and one for "readwrite". The page inspects them with queryPermission() and asks for them with requestPermission(); both take { mode } and resolve to a PermissionState.

State Meaning What operations do
"granted" The user allowed this mode Reads (and writes, for "readwrite") succeed
"prompt" No decision in this session Reads or writes reject with NotAllowedError; createWritable() may prompt itself
"denied" The user refused in this session, or policy blocks it Operations reject; requestPermission() resolves "denied" without showing UI

queryPermission() never shows UI and works anywhere a handle exists, including workers. requestPermission() may show a prompt and therefore needs transient user activation: without it Chromium rejects with SecurityError ("User activation is required to request permissions."). Two exceptions make it resolve without UI or activation: when the state is already decided (it returns the current state), and when the browser can grant from a persistent permission (below).

Where the initial grants come from

How the page got the handle Read Write
showOpenFilePicker() Granted Prompt
showSaveFilePicker() Granted Granted
showDirectoryPicker() (default mode: "read") Granted after the folder prompt Prompt
showDirectoryPicker({ mode: "readwrite" }) Granted after the folder prompt Granted after the same prompt
Drag and drop (getAsFileSystemHandle()) Granted, for files and folders Prompt
File Handling launch (launchQueue) Granted Prompt in Chromium
getFileHandle() / iteration inside a granted directory Inherits the directory's grant Inherits the directory's grant
Deserialized from IndexedDB in a later session Prompt Prompt
Deserialized, with persistent permission active Granted for the modes granted before Granted if it was granted before

The prompts differ by handle kind and mode. For a single file, requesting "read" shows "Allow this site to view and copy file?" and "readwrite" shows "Allow this site to edit file?"; the implicit prompt from createWritable() reads "Save changes to file?". Requesting "readwrite" when you open a document replaces two prompts (view now, save later) with one, which is how Chrome's own text editor sample does it.

When the user clicks Don't allow, the grant becomes "denied" for the rest of the session and later requestPermission() calls resolve "denied" silently. Dismissing the prompt without choosing leaves it at "prompt", so a later click can ask again.

How long grants last

Without persistent permissions, grants are held in memory per origin. They survive navigations and reloads within the origin, and they end when the last top-level tab or window of the origin closes or navigates to another origin. Chrome's prompts say this explicitly: "origin will be able to edit file until you close all tabs for this site". With persistent permissions enabled (Chrome 122+), Chrome also revokes session grants when the origin's tabs have been in the background for a while, using the same expiry logic as its one-time ("Allow this time") permissions. When that happens, queryPermission() flips back to "prompt" and the next write needs a click.

stateDiagram-v2
    state "prompt" as P
    state "granted (session)" as G
    state "granted (persistent)" as PG
    state "denied" as D
    [*] --> P: handle restored from storage
    [*] --> G: picked, saved, dropped or launched
    P --> G: user allows this time
    P --> PG: user allows on every visit
    P --> D: user clicks Dont allow
    G --> P: all tabs closed or backgrounded too long
    PG --> P: user removes access in site settings
    D --> P: new session

Persistent permissions (Chrome 122 and later)

Chrome 122 made "remember my files" possible without any API change. It works through two new UI surfaces:

  • A three-way prompt. When the page calls requestPermission() on a handle that was granted in a previous visit (typically restored from IndexedDB), or on a handle whose grant was revoked because the tab sat in the background, Chrome shows a restore prompt headed "View and edit files from the last time you visited this site:" with the list of all previously granted files and folders, not just the one you asked about. The options are Allow this time (session grant, the old behavior), Allow on every visit (persistent grant), and Don't allow.
  • Installed apps get persistence by default. If the origin has an installed desktop PWA, grants persist automatically once the user allows access, without the three-way prompt. Chromium applies this when the app is installed with OS integration and the user has not already made an explicit choice in the restore prompt or site settings.

With a persistent grant active, restored handles report "granted" from queryPermission() straight away, so an installed editor can reopen the last document on startup with no click at all. If the user denies or dismisses the three-way prompt more than three times, Chrome stops showing it and falls back to the regular prompt. Users review and revoke persistent grants in the site's settings (a link next to the File editing toggle lists each file and folder with a delete button) or with Remove access in the address-bar file indicator.

The practical recipe for a PWA:

  1. Store every handle the user opens in IndexedDB (next section).
  2. On startup, queryPermission() the most recent handle. If it is "granted", reopen it; if not, show a "Reopen name" button.
  3. In that button's click handler, call requestPermission({ mode: "readwrite" }). Chrome shows the three-way prompt, and "Allow on every visit" makes step 2 succeed from then on.
src/permissions.js
// Returns true if the handle is usable in the requested mode, prompting if
// needed. Call from a user gesture when the state may be "prompt".
export async function ensurePermission(handle, mode = "read") {
  const descriptor = { mode };
  const state = await handle.queryPermission(descriptor);
  if (state === "granted") return true;
  if (state === "denied") return false; // Asking again shows no UI.
  try {
    return (await handle.requestPermission(descriptor)) === "granted";
  } catch (err) {
    // SecurityError: no user activation. The caller must retry from a click.
    if (err.name === "SecurityError") return false;
    throw err;
  }
}

Persisting handles in IndexedDB

Because handles are structured-cloneable, IndexedDB stores them like any other value; no library is needed, and the handle you read back in a later session points to the same entry. What does not come back automatically is permission (see above), and the entry itself may have been renamed, moved or deleted in the meantime, in which case getFile() rejects with NotFoundError.

Three rules keep a "recent files" store correct:

  • Identify files with isSameEntry(), not names. Two notes.txt files in different folders are different entries; the same file opened twice should update one record.
  • Do async handle work outside IndexedDB transactions. A transaction commits as soon as it has no pending requests at the end of a task, so awaiting isSameEntry() inside one lets it commit early and the next request throws TransactionInactiveError.
  • Store only handles and your own metadata. Keep document contents in the file itself (or in OPFS for autosave), not duplicated in IndexedDB.

The complete store used by the text editor is in the example below. IndexedDB itself, including versioning and multi-tab upgrades, is covered on IndexedDB; handles count toward the origin's storage like any other record and are cleared when the user clears site data (see Storage Quotas & Persistence).

Working with directories

A directory handle is a capability for everything beneath it. Iterating it yields [name, handle] pairs (entries()), names (keys()) or handles (values()), with for await. Only direct children are returned, in no guaranteed order, so sort before display and recurse yourself.

src/walk.js
const SKIP = new Set([".git", "node_modules", ".DS_Store"]);

// Recursively lists files under a directory handle.
// - Runs directory reads concurrently but bounded, to avoid thousands of
//   simultaneous IPC calls on large trees.
// - Stops early through an AbortSignal (for example, a "Cancel" button).
// - Returns paths relative to the root, computed without resolve().
export async function walk(root, { signal, maxFiles = 50_000, concurrency = 8 } = {}) {
  const files = [];
  const queue = [{ dir: root, path: [] }];
  let active = 0;

  return new Promise((resolve, reject) => {
    const pump = () => {
      if (signal?.aborted) return reject(signal.reason);
      if (queue.length === 0 && active === 0) {
        files.sort((a, b) => a.path.join("/").localeCompare(b.path.join("/")));
        return resolve(files);
      }
      while (active < concurrency && queue.length > 0) {
        const { dir, path } = queue.shift();
        active += 1;
        readDir(dir, path).then(() => {
          active -= 1;
          pump();
        }, reject);
      }
    };

    const readDir = async (dir, path) => {
      for await (const [name, handle] of dir.entries()) {
        if (signal?.aborted) return;
        if (SKIP.has(name)) continue;
        if (handle.kind === "directory") {
          queue.push({ dir: handle, path: [...path, name] });
        } else {
          files.push({ handle, path: [...path, name] });
          if (files.length >= maxFiles) {
            throw new RangeError(`More than ${maxFiles} files; narrow the folder`);
          }
        }
      }
    };

    pump();
  });
}

Creating and deleting inside a granted directory uses the directory methods. None of them accept paths, so nested paths are resolved one component at a time:

src/paths.js
// Resolves "src/components/Button.jsx" to a file handle, optionally
// creating missing directories and the file itself.
export async function getFileByPath(root, relativePath, { create = false } = {}) {
  const parts = relativePath.split("/").filter(Boolean);
  const fileName = parts.pop();
  if (!fileName) throw new TypeError("Path must name a file");
  let dir = root;
  for (const part of parts) {
    // Rejects with TypeError for "..", "." or names with separators, so a
    // path from untrusted input cannot escape the root.
    dir = await dir.getDirectoryHandle(part, { create });
  }
  return dir.getFileHandle(fileName, { create });
}

// Returns "src/components/Button.jsx" for a handle inside root, or null.
export async function relativePathOf(root, handle) {
  const parts = await root.resolve(handle);
  return parts ? parts.join("/") : null;
}

Two details matter for editors and build tools. First, removeEntry(name, { recursive: true }) deletes a whole tree with no undo and no trash; confirm with the user. Second, current Chromium refuses write access to any directory path ending in .git/hooks, even inside a folder the user granted, because hooks are executable code that Git runs automatically. Reading them still works.

Drag and drop with getAsFileSystemHandle()

Files and folders dropped on the page can be turned into handles with DataTransferItem.getAsFileSystemHandle(). The dropped entries get read permission immediately, including folders, and like any handle they can be stored and later upgraded to write access. Drag and drop is often a better entry point than a picker for folders, because users already have the folder open in their file manager.

The rule that breaks most implementations: call getAsFileSystemHandle() synchronously inside the drop event handler. The DataTransfer is only readable while the event is being dispatched; once the handler returns, or after its first await, the items are no longer in read-only mode and the method resolves null. Collect the promises first, await them afterwards.

src/drop-zone.js
// Accepts dropped files and folders. Uses handles where supported and
// falls back to plain File objects (read-only copies) elsewhere.
export function createDropZone(element, { onHandles, onFiles }) {
  element.addEventListener("dragover", (event) => {
    if ([...event.dataTransfer.items].some((item) => item.kind === "file")) {
      event.preventDefault(); // Required, or the browser opens the file.
      event.dataTransfer.dropEffect = "copy";
    }
  });

  element.addEventListener("drop", (event) => {
    const items = [...event.dataTransfer.items].filter((i) => i.kind === "file");
    if (items.length === 0) return;
    event.preventDefault();

    // Synchronous phase: extract everything before the first await.
    const supportsHandles = "getAsFileSystemHandle" in DataTransferItem.prototype;
    const handlePromises = supportsHandles
      ? items.map((item) => item.getAsFileSystemHandle())
      : [];
    const files = items.map((item) => item.getAsFile()).filter(Boolean);

    // Asynchronous phase.
    (async () => {
      if (supportsHandles) {
        const handles = (await Promise.all(handlePromises)).filter(Boolean);
        // DataTransferItem.kind is "file" for folders too; the handle's
        // kind distinguishes "file" from "directory".
        return onHandles(handles);
      }
      return onFiles(files);
    })().catch((err) => console.error("Drop failed", err));
  });
}

In browsers without getAsFileSystemHandle(), webkitGetAsEntry() still lets you walk a dropped folder read-only (it is supported in every engine), but you cannot save back to it.

Watching for changes with FileSystemObserver

Editors need to know when a file changes outside the app (a git checkout, another editor, a build step). Before FileSystemObserver, the only option was polling getFile() and comparing lastModified. The observer shipped in Chrome 133 on desktop after an origin trial in Chrome 129 to 134; it is not available on Android or in other engines.

src/watch.js
// Watches a directory tree and reports changes, with a polling fallback.
export async function watch(dirHandle, onChange) {
  if (!("FileSystemObserver" in window)) {
    return pollFallback(dirHandle, onChange);
  }
  const observer = new FileSystemObserver((records) => {
    for (const record of records) {
      switch (record.type) {
        case "appeared":    // Created, or moved into the observed tree
        case "disappeared": // Deleted, or moved out
        case "modified":
          onChange({ type: record.type, path: record.relativePathComponents.join("/") });
          break;
        case "moved":       // Moved within the observed tree
          onChange({
            type: "moved",
            from: record.relativePathMovedFrom.join("/"),
            path: record.relativePathComponents.join("/"),
          });
          break;
        case "unknown":     // Events were dropped: rescan the tree
          onChange({ type: "rescan" });
          break;
        case "errored":     // Observation ended (root deleted, permission
          observer.disconnect(); // revoked, or OS watch limit reached)
          onChange({ type: "stopped" });
          break;
      }
    }
  });
  await observer.observe(dirHandle, { recursive: true });
  return () => observer.disconnect();
}

function pollFallback(dirHandle, onChange, intervalMs = 5_000) {
  // Minimal fallback: tell the app to rescan periodically while visible.
  const timer = setInterval(() => {
    if (document.visibilityState === "visible") onChange({ type: "rescan" });
  }, intervalMs);
  return () => clearInterval(timer);
}

Each record has root (the observed handle), changedHandle (null for "disappeared", "unknown" and "errored"), relativePathComponents, type, and, for "moved", relativePathMovedFrom. Detail varies by OS: recursive changes may arrive as generic "unknown" records that require a rescan, and on Windows a move between directories is reported as "disappeared" plus "appeared". Chromium caps the number of OS watches per origin with platform-specific budgets, and hitting the cap produces "errored". unobserve() exists only behind a flag, so stop observation with disconnect() and re-observe the handles you still need.

Chromium-only extras: remove(), move() and locking modes

These methods are not in the WHATWG standard. Feature-detect them and keep a fallback.

FileSystemHandle.remove({ recursive }) (Chrome 110) deletes the entry a handle points to without needing its parent directory, which is the only way to delete a file you got from showOpenFilePicker(). It needs write permission on the handle; directories that are not empty need recursive: true or it rejects with InvalidModificationError.

FileSystemFileHandle.move() renames and moves files: move("new-name.txt"), move(destinationDirHandle) or move(destinationDirHandle, "new-name.txt"). It is exposed on file handles only; directory moves are not supported. It shipped for origin private file system files in Chrome 102, and Firefox and Safari implement it there too. For user-visible files the history is murkier: an intent to ship landed for Chrome 111, Chrome's own documentation still describes moves involving user-visible files as flag-gated, and current Chromium source runs them without a flag. The source also shows the permission rules: the handle needs write access, and the destination needs either write access to the target file or write access to its parent directory. Moves between OPFS and the user's file system reject with InvalidModificationError, as do renames of Android content:// documents. Treat move() on user files as best-effort:

src/rename.js
// Renames a file inside a directory the app has write access to.
export async function renameFile(dirHandle, fileHandle, newName) {
  if (typeof fileHandle.move === "function") {
    try {
      await fileHandle.move(newName);
      return fileHandle; // The handle now points to the renamed entry.
    } catch (err) {
      // NotSupportedError, InvalidModificationError or NotAllowedError on
      // builds or file systems without native moves: fall through.
      if (!["NotSupportedError", "InvalidModificationError", "NotAllowedError"].includes(err.name)) {
        throw err;
      }
    }
  }
  // Fallback: copy, then delete the original.
  const target = await dirHandle.getFileHandle(newName, { create: true });
  const writable = await target.createWritable();
  await (await fileHandle.getFile()).stream().pipeTo(writable); // Closes it.
  await dirHandle.removeEntry(fileHandle.name);
  return target;
}

Locking modes (Chrome 121) add mode to createWritable() ("siloed" by default, or "exclusive") and to OPFS's createSyncAccessHandle() ("readwrite", "read-only" or "readwrite-unsafe"). Browsers that do not know the option ignore it, because unknown dictionary members are dropped during WebIDL conversion, so passing { mode: "exclusive" } is safe everywhere.

Experimental

FileSystemHandle.getUniqueId() (a stable identifier for an entry) and getCloudIdentifiers() (provider IDs for files synced by cloud storage clients, ChromeOS only) exist in Chromium behind flags and are not shipped. Do not build on them.

The security model in detail

Reading and writing arbitrary local files from a web page is exactly as dangerous as it sounds, and the API layers several independent defenses. Knowing them helps you predict which user actions will fail.

  • Nothing without a gesture. Pickers and permission prompts need transient activation, pickers only run in the top-level page's origin, and a site can never enumerate or open files the user did not hand it.
  • A visible indicator. While a page has access, Chrome shows a file icon in the address bar ("This page is allowed to view files" or "This page is allowed to edit files"). Clicking it lists the granted files and folders and offers Remove access.
  • Enterprise control. Administrators can block or pre-configure access with the DefaultFileSystemReadGuardSetting, DefaultFileSystemWriteGuardSetting, FileSystemReadAskForUrls and FileSystemWriteBlockedForUrls family of Chrome policies, and content-analysis connectors can scan files selected in pickers.

Blocked locations

Chromium checks every picked or dropped path against a blocklist and shows "Can't open this folder" or "Can't open this file" ("origin can't open files in this folder because it contains system files") instead of returning a handle. The rules below come from the current Chromium source; "folder only" means the folder itself is blocked but anything inside it may be chosen.

Location Platforms Rule
Home directory, Desktop, Documents, Downloads All Folder only
Browser installation, modules and assets; the browser's user-data (profile) directory All Blocked with all contents
~/.ssh, ~/.gnupg All Blocked with all contents
Any .git/hooks directory All Write access blocked
Program Files (all variants), C:\Windows, AppData\Roaming, AppData\Local, ProgramData Windows Blocked with all contents
Temporary Internet Files Windows Individual files allowed (Windows stages files from phones and cameras there), subfolders blocked
UNC paths that point back to the local machine (\\localhost\, \\127.0.0.1\, \\?\, \\wsl.localhost\, drive and admin shares such as C$, ADMIN$, IPC$) Windows Rejected
~/Library (except CloudStorage, Containers and iCloud Drive's Mobile Documents), ~/Library/Application Support, /Applications, ~/Applications, /System/Volumes, the browser's own bundle macOS Blocked with all contents
/dev, /proc, /sys, /boot, /etc, ~/.config, ~/.dbus, ~/.cache Linux, ChromeOS Blocked with all contents
The browser's app data directory Android Blocked with all contents

The spec's security considerations add more that user agents should block, but these are the paths Chromium actually enforces. If your users report "Can't open this folder" for a project folder, check whether it lives directly in one of the "folder only" locations (asking for the whole Documents folder fails; Documents/my-project works).

Dangerous content and malware

  • Save dialogs warn about dangerous types, as described for showSaveFilePicker(). Shell-integrated extensions (.lnk, .local, .scf, .url) are dropped from types filters, and a suggested name that ends in one is rewritten to end in .download.
  • Every write is checked on close(): Chromium hashes the result and runs Safe Browsing's download-protection check, then applies OS quarantine metadata, just as for downloads.
  • There is no way to mark a file executable. The API has no chmod, so a page cannot drop a runnable script into a folder and set its execute bit.
  • User files are not subject to storage quota. A site with write access to a folder could fill the disk; the spec accepts this because the user explicitly granted access, and mitigates it through sparse files for large seeks.

Treat file contents as untrusted input

A file the user opens may have been crafted by an attacker (a downloaded .svg, .html or .md). Never insert its contents with innerHTML, never eval it, and render formats that can carry script (SVG, HTML) only through <img>, a sandboxed iframe, or a sanitizer. The same applies to file names, which can contain markup. The browser protects the file system from the page; protecting the page from the file is your job.

Android and WebView specifics

Chrome 132 brought the pickers, drag-and-drop handles and writes to Chrome for Android and Android WebView. The API surface is the same, but the platform underneath differs:

  • The pickers open Android's system document picker, and the resulting handles refer to content:// documents from document providers (local storage, Google Drive and others) rather than plain paths. Treat startIn and id as hints: the system picker controls far more of the navigation than desktop dialogs do.
  • Writes to content:// documents cannot use a sibling swap file. Chromium writes the swap file into its own cache directory and copies it back to the document when close() runs.
  • move() cannot rename content:// documents and rejects with InvalidModificationError.
  • The installed-app permission persistence described above is desktop-only code in Chromium.
  • In WebView, Chromium hands the pickers to the embedding app's file-chooser callback, the same path that serves <input type="file">, so what users see depends on the host app. Test inside the actual host app.

Test on real devices with the document providers your users rely on; cloud providers can be slow to return file contents and may not support every operation.

Fallbacks for Firefox, Safari and older browsers

The File System Access API cannot be polyfilled: nothing else gives a page a writable reference to a user file. What you can do is degrade gracefully to the classic primitives, which cover opening and saving, just not saving in place.

Capability File System Access Fallback
Open files showOpenFilePicker() <input type="file" accept multiple>, opened with showPicker() (Chrome 99, Firefox 101, Safari 16) or click()
Detect cancel AbortError The input's cancel event (Chrome 113, Firefox 91, Safari 16.4)
Open a folder showDirectoryPicker() <input type="file" webkitdirectory> (read-only File list with webkitRelativePath; directory choice on iOS since Safari 18.4)
Save a new file showSaveFilePicker() <a download="name"> with a Blob URL: goes to the downloads location or a browser save dialog
Save in place createWritable() on the original handle Not possible: every save is a new download
Remember files Handles in IndexedDB Not possible; keep drafts in OPFS or IndexedDB instead
Drop folders getAsFileSystemHandle() webkitGetAsEntry() (read-only)

Two design choices make the fallback experience acceptable. First, autosave the working copy to OPFS or IndexedDB in every browser, so the lack of in-place save never means lost work. Second, label the actions honestly: "Download" instead of "Save" when there is no handle, so users are not surprised by report (3).txt in their Downloads folder.

src/legacy-file.js
// Opens files with <input type="file">. Resolves File or File[];
// rejects with AbortError on cancel, matching showOpenFilePicker().
export function pickWithInput({ accept = "", multiple = false, directory = false } = {}) {
  return new Promise((resolve, reject) => {
    const input = document.createElement("input");
    input.type = "file";
    input.accept = accept;
    input.multiple = multiple;
    input.webkitdirectory = directory;
    input.hidden = true;
    const finish = () => input.remove();
    input.addEventListener("change", () => {
      const files = [...input.files];
      finish();
      if (files.length === 0) {
        reject(new DOMException("No file selected", "AbortError"));
      } else {
        resolve(multiple || directory ? files : files[0]);
      }
    }, { once: true });
    input.addEventListener("cancel", () => {
      finish();
      reject(new DOMException("The user aborted a request.", "AbortError"));
    }, { once: true });
    document.body.append(input);
    try {
      // Both showPicker() and click() need transient activation.
      if (typeof input.showPicker === "function") input.showPicker();
      else input.click();
    } catch (err) {
      finish();
      reject(err);
    }
  });
}

// Saves by downloading. Cannot overwrite an existing file.
export function downloadBlob(blob, fileName) {
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = fileName;
  document.body.append(a);
  a.click();
  a.remove();
  // Give the browser time to start the download before revoking.
  setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
src/files.js
// npm install browser-fs-access  (0.38.0 at the time of writing)
import { fileOpen, fileSave, directoryOpen, supported } from "browser-fs-access";

export async function openImage() {
  // Uses showOpenFilePicker() where available, <input type="file"> elsewhere.
  const blob = await fileOpen({
    mimeTypes: ["image/*"],
    extensions: [".png", ".jpg", ".jpeg", ".webp"],
    description: "Images",
    id: "images",
    startIn: "pictures",
  });
  // Non-standard: the library attaches the FileSystemFileHandle (if any).
  return { blob, handle: blob.handle ?? null };
}

export async function save(blob, existingHandle) {
  // With a handle and File System Access support this overwrites in place;
  // otherwise it shows a save picker or falls back to a download.
  return fileSave(blob, { fileName: "Untitled.png", extensions: [".png"] }, existingHandle);
}

export async function openFolder() {
  // Recursively returns File objects with webkitRelativePath set.
  return directoryOpen({ recursive: true, skipDirectory: (e) => e.name.startsWith(".") });
}

export const inPlaceSaving = supported;

browser-fs-access is maintained by Google Chrome Labs and used by Excalidraw. It returns Blobs everywhere and exposes the handle as a non-standard handle property, so your app code has one path; the trade-off is that it hides permission states, which you still need to handle for restored handles.

Complete example: a text editor PWA

This editor puts the pieces together: open, save and save-as with in-place writes, a persistent "Recent" menu backed by IndexedDB, conflict detection when the file changed on disk, drag and drop, reopening the last file on startup when persistent permission allows, keyboard shortcuts, launch through File Handling, and fallbacks for browsers without the API. It has no dependencies.

index.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Editor</title>
  <link rel="manifest" href="/manifest.webmanifest">
  <link rel="stylesheet" href="/styles.css">
  <script type="module" src="/app.js"></script>
</head>
<body>
  <header role="toolbar" aria-label="File">
    <button id="new" type="button">New</button>
    <button id="open" type="button">Open…</button>
    <button id="save" type="button">Save</button>
    <button id="save-as" type="button">Save as…</button>
    <details id="recent-menu" hidden>
      <summary>Recent</summary>
      <ul id="recent"></ul>
    </details>
  </header>
  <main>
    <textarea id="editor" spellcheck="false" aria-label="Document"></textarea>
  </main>
  <footer>
    <output id="status" role="status" aria-live="polite"></output>
  </footer>
</body>
</html>
file-store.js
// IndexedDB store of FileSystemFileHandle objects for the "Recent" menu.
const DB_NAME = "editor";
const DB_VERSION = 1;
const STORE = "recent-files";
const MAX_RECENT = 8;

let dbPromise = null;

function promisify(request) {
  return new Promise((resolve, reject) => {
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

function transactionDone(tx) {
  return new Promise((resolve, reject) => {
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
    tx.onabort = () => reject(tx.error ?? new DOMException("Aborted", "AbortError"));
  });
}

function openDb() {
  if (!dbPromise) {
    dbPromise = new Promise((resolve, reject) => {
      const request = indexedDB.open(DB_NAME, DB_VERSION);
      request.onupgradeneeded = () => {
        request.result.createObjectStore(STORE, { keyPath: "id" });
      };
      request.onsuccess = () => {
        const db = request.result;
        // A newer version in another tab wants to upgrade: step aside.
        db.onversionchange = () => {
          db.close();
          dbPromise = null;
        };
        resolve(db);
      };
      request.onerror = () => reject(request.error);
      request.onblocked = () => reject(new Error("Database upgrade blocked"));
    });
    dbPromise.catch(() => {
      dbPromise = null; // Allow a retry after a failed open.
    });
  }
  return dbPromise;
}

export async function listRecent() {
  const db = await openDb();
  const tx = db.transaction(STORE, "readonly");
  const records = await promisify(tx.objectStore(STORE).getAll());
  return records.sort((a, b) => b.openedAt - a.openedAt);
}

export async function rememberFile(handle) {
  const existing = await listRecent();
  // isSameEntry() is async, so deduplicate BEFORE opening the write
  // transaction: awaiting it inside would let the transaction auto-commit.
  let match = null;
  for (const record of existing) {
    try {
      if (await record.handle.isSameEntry(handle)) {
        match = record;
        break;
      }
    } catch {
      // A stale handle cannot be compared; it will age out.
    }
  }
  const record = {
    id: match?.id ?? crypto.randomUUID(),
    handle,
    name: handle.name,
    openedAt: Date.now(),
  };
  const evicted = existing
    .filter((r) => r.id !== record.id)
    .slice(MAX_RECENT - 1);

  const db = await openDb();
  const tx = db.transaction(STORE, "readwrite");
  const store = tx.objectStore(STORE);
  store.put(record);
  for (const old of evicted) store.delete(old.id);
  await transactionDone(tx);
  return record;
}

export async function forgetFile(id) {
  const db = await openDb();
  const tx = db.transaction(STORE, "readwrite");
  tx.objectStore(STORE).delete(id);
  await transactionDone(tx);
}
fs-adapter.js
// File operations with the File System Access API and fallbacks.
export const canUsePickers =
  "showOpenFilePicker" in window && "showSaveFilePicker" in window;

const TEXT_TYPES = [
  {
    description: "Text documents",
    accept: {
      "text/plain": [".txt", ".text", ".log"],
      "text/markdown": [".md", ".markdown"],
    },
  },
];
const INPUT_ACCEPT = ".txt,.text,.log,.md,.markdown,text/plain,text/markdown";

function pickWithInput(accept) {
  return new Promise((resolve, reject) => {
    const input = Object.assign(document.createElement("input"), {
      type: "file",
      accept,
      hidden: true,
    });
    const finish = () => input.remove();
    input.addEventListener("change", () => {
      const [file] = input.files;
      finish();
      if (file) resolve(file);
      else reject(new DOMException("No file selected", "AbortError"));
    }, { once: true });
    input.addEventListener("cancel", () => {
      finish();
      reject(new DOMException("The user aborted a request.", "AbortError"));
    }, { once: true });
    document.body.append(input);
    try {
      if (typeof input.showPicker === "function") input.showPicker();
      else input.click();
    } catch (err) {
      finish();
      reject(err);
    }
  });
}

// Resolves { handle, file }. handle is null when only a copy is available.
export async function pickTextFile() {
  if (canUsePickers) {
    try {
      const [handle] = await window.showOpenFilePicker({
        id: "documents",
        startIn: "documents",
        types: TEXT_TYPES,
      });
      return { handle, file: await handle.getFile() };
    } catch (err) {
      // Cross-origin iframe: the classic input still works there.
      if (err.name !== "SecurityError") throw err;
    }
  }
  return { handle: null, file: await pickWithInput(INPUT_ACCEPT) };
}

// Resolves the new handle, or null if the fallback downloaded a copy.
export async function saveTextAs(text, suggestedName) {
  if (!canUsePickers) {
    downloadText(text, suggestedName);
    return null;
  }
  const handle = await window.showSaveFilePicker({
    id: "documents",
    startIn: "documents",
    suggestedName,
    types: TEXT_TYPES,
  });
  // The file now exists and is empty: write immediately.
  await writeText(handle, text);
  return handle;
}

export async function writeText(handle, text) {
  // "exclusive" stops two windows from saving the same file concurrently.
  // Browsers without locking modes ignore the unknown option.
  const writable = await handle.createWritable({ mode: "exclusive" });
  try {
    await writable.write(text);
    await writable.close(); // Atomic replace happens here.
  } catch (err) {
    await writable.abort().catch(() => {});
    throw err;
  }
}

export async function ensurePermission(handle, mode) {
  const descriptor = { mode };
  const state = await handle.queryPermission(descriptor);
  if (state === "granted") return true;
  if (state === "denied") return false;
  return (await handle.requestPermission(descriptor)) === "granted";
}

export function downloadText(text, fileName) {
  const url = URL.createObjectURL(
    new Blob([text], { type: "text/plain;charset=utf-8" }),
  );
  const a = Object.assign(document.createElement("a"), {
    href: url,
    download: fileName,
  });
  document.body.append(a);
  a.click();
  a.remove();
  setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
app.js
import { listRecent, rememberFile, forgetFile } from "./file-store.js";
import {
  canUsePickers,
  pickTextFile,
  saveTextAs,
  writeText,
  ensurePermission,
} from "./fs-adapter.js";

const $ = (selector) => document.querySelector(selector);
const editor = $("#editor");
const statusOutput = $("#status");
const recentList = $("#recent");
const recentMenu = $("#recent-menu");

const state = {
  handle: null, // FileSystemFileHandle, or null (new document or fallback copy)
  name: "Untitled.txt",
  snapshot: null, // { lastModified, size } as last read or written
  savedText: "",
};

const isDirty = () => editor.value !== state.savedText;
const setStatus = (message) => {
  statusOutput.value = message;
};
const updateTitle = () => {
  document.title = `${isDirty() ? "* " : ""}${state.name} - Editor`;
};
const snapshotOf = (file) => ({ lastModified: file.lastModified, size: file.size });

// Runs an async action; cancelled dialogs are not errors.
function run(action) {
  Promise.resolve()
    .then(action)
    .catch((err) => {
      if (err?.name === "AbortError") return;
      console.error(err);
      setStatus(`Error: ${err.message}`);
    });
}

function loadDocument({ handle = null, name, text, file = null }) {
  Object.assign(state, {
    handle,
    name,
    savedText: text,
    snapshot: file ? snapshotOf(file) : null,
  });
  editor.value = text;
  updateTitle();
  setStatus(handle || !file ? `Opened ${name}` : `Opened a copy of ${name}`);
}

const confirmDiscard = () =>
  !isDirty() || window.confirm(`Discard unsaved changes to ${state.name}?`);

async function openHandle(handle) {
  if (!confirmDiscard()) return;
  const file = await handle.getFile();
  loadDocument({ handle, name: file.name, text: await file.text(), file });
  await rememberFile(handle);
  await renderRecent();
}

function newDocument() {
  if (!confirmDiscard()) return;
  loadDocument({ name: "Untitled.txt", text: "" });
}

async function openFromPicker() {
  // Picker first: it needs this click's activation, and a confirm() dialog
  // shown before it could outlast the activation window.
  const { handle, file } = await pickTextFile();
  if (!confirmDiscard()) return;
  loadDocument({ handle, name: file.name, text: await file.text(), file });
  if (handle) {
    await rememberFile(handle);
    await renderRecent();
  }
}

async function openRecent(record) {
  // Ask for read AND write now: one prompt instead of one per action.
  if (!(await ensurePermission(record.handle, "readwrite"))) {
    setStatus(`Access to ${record.name} was not granted`);
    return;
  }
  try {
    await openHandle(record.handle);
  } catch (err) {
    if (err.name !== "NotFoundError") throw err;
    await forgetFile(record.id);
    await renderRecent();
    setStatus(`${record.name} was moved or deleted`);
  }
}

async function save() {
  const { handle } = state;
  if (!handle) return saveAs();
  if (!(await ensurePermission(handle, "readwrite"))) {
    setStatus(`Write access to ${handle.name} was not granted`);
    return;
  }
  let onDisk;
  try {
    onDisk = await handle.getFile();
  } catch (err) {
    if (err.name !== "NotFoundError") throw err;
    state.handle = null;
    setStatus(`${handle.name} was moved or deleted. Use "Save as".`);
    return;
  }
  const changedExternally =
    state.snapshot &&
    (onDisk.lastModified !== state.snapshot.lastModified ||
      onDisk.size !== state.snapshot.size);
  if (
    changedExternally &&
    !window.confirm(`${handle.name} changed on disk. Overwrite those changes?`)
  ) {
    return;
  }
  const text = editor.value; // Capture: typing may continue during the write.
  await writeText(handle, text);
  state.snapshot = snapshotOf(await handle.getFile());
  state.savedText = text;
  updateTitle();
  setStatus(`Saved ${handle.name}`);
}

async function saveAs() {
  const text = editor.value;
  const handle = await saveTextAs(text, state.name);
  state.savedText = text;
  if (!handle) {
    updateTitle();
    setStatus(`Downloaded ${state.name}`);
    return;
  }
  Object.assign(state, {
    handle,
    name: handle.name,
    snapshot: snapshotOf(await handle.getFile()),
  });
  updateTitle();
  setStatus(`Saved ${handle.name}`);
  await rememberFile(handle);
  await renderRecent();
}

async function renderRecent() {
  let records = [];
  try {
    records = await listRecent();
  } catch (err) {
    console.warn("Recent files unavailable", err);
  }
  recentList.replaceChildren(
    ...records.map((record) => {
      const button = Object.assign(document.createElement("button"), {
        type: "button",
        textContent: record.name, // textContent: names are untrusted input.
      });
      button.addEventListener("click", () => run(() => openRecent(record)));
      const item = document.createElement("li");
      item.append(button);
      return item;
    }),
  );
  recentMenu.hidden = records.length === 0;
}

async function restoreLastFile() {
  const [last] = await listRecent();
  if (!last || state.handle || isDirty()) return;
  // Persistent permission (installed desktop app, or "Allow on every
  // visit") makes restored handles "granted": reopen without a click.
  if ((await last.handle.queryPermission({ mode: "readwrite" })) === "granted") {
    await openHandle(last.handle).catch(() => forgetFile(last.id));
  } else {
    setStatus(`Reopen ${last.name} from the Recent menu`);
  }
}

// Launches through manifest "file_handlers" (see File Handling).
if ("launchQueue" in window) {
  window.launchQueue.setConsumer((params) => {
    const handle = params.files?.find((h) => h.kind === "file");
    if (handle) run(() => openHandle(handle));
  });
}

// Drag and drop: handles where supported, read-only copies elsewhere.
editor.addEventListener("dragover", (event) => {
  if ([...event.dataTransfer.items].some((item) => item.kind === "file")) {
    event.preventDefault();
    event.dataTransfer.dropEffect = "copy";
  }
});
editor.addEventListener("drop", (event) => {
  const item = [...event.dataTransfer.items].find((i) => i.kind === "file");
  if (!item) return; // Plain text drops keep their default behavior.
  event.preventDefault();
  // Synchronous extraction: the DataTransfer is cleared after this handler.
  const handlePromise =
    typeof item.getAsFileSystemHandle === "function"
      ? item.getAsFileSystemHandle()
      : Promise.resolve(null);
  const file = item.getAsFile();
  run(async () => {
    const handle = await handlePromise;
    if (handle?.kind === "directory") {
      setStatus("Drop a file, not a folder");
      return;
    }
    if (handle) return openHandle(handle);
    if (file && confirmDiscard()) {
      loadDocument({ name: file.name, text: await file.text(), file });
    }
  });
});

document.addEventListener("keydown", (event) => {
  if (!(event.ctrlKey || event.metaKey)) return;
  const key = event.key.toLowerCase();
  if (key === "s") {
    event.preventDefault();
    run(event.shiftKey ? saveAs : save);
  } else if (key === "o") {
    event.preventDefault();
    run(openFromPicker);
  }
});

window.addEventListener("beforeunload", (event) => {
  if (isDirty()) event.preventDefault(); // Shows the "Leave site?" dialog.
});

editor.addEventListener("input", updateTitle);
$("#new").addEventListener("click", () => run(newDocument));
$("#open").addEventListener("click", () => run(openFromPicker));
$("#save").addEventListener("click", () => run(save));
$("#save-as").addEventListener("click", () => run(saveAs));
if (!canUsePickers) $("#save-as").textContent = "Download";

updateTitle();
run(async () => {
  await renderRecent();
  await restoreLastFile();
});

To make the editor an Open with target for .txt and .md files once installed, add a file_handlers entry and route file launches into the existing window. The consumer in app.js already handles the launch; File Handling explains the manifest side in detail.

manifest.webmanifest
{
  "name": "Editor",
  "start_url": "/",
  "scope": "/",
  "display": "standalone",
  "icons": [
    { "src": "/icons/192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icons/512.png", "sizes": "512x512", "type": "image/png" }
  ],
  "file_handlers": [
    {
      "action": "/",
      "accept": {
        "text/plain": [".txt", ".text", ".log"],
        "text/markdown": [".md", ".markdown"]
      }
    }
  ],
  "launch_handler": { "client_mode": "focus-existing" }
}

Common pitfalls

  1. Doing slow work before the picker. Serializing, rendering or fetching before showSaveFilePicker() lets the user activation expire, and the call throws SecurityError: Must be handling a user gesture. Pick first, work second.
  2. Assuming the save picker is harmless. It empties the chosen file immediately. A failure between picking and writing destroys the user's existing file.
  3. Forgetting close(). Nothing reaches the file until close() resolves, and an unclosed writer leaves a .crswap file next to the target. Wrap writes in try/catch and abort() on failure.
  4. Patching files without keepExistingData: true. The swap file starts empty, so positional writes zero-fill everything you did not rewrite.
  5. Holding on to old File objects. A File from getFile() becomes unreadable once the file changes on disk. Call getFile() again.
  6. Calling requestPermission() on page load. It needs a gesture and throws SecurityError otherwise. Use queryPermission() on load and ask from a button.
  7. Awaiting in the drop handler before extracting handles. getAsFileSystemHandle() resolves null once the drop event is over.
  8. Awaiting non-IndexedDB promises inside a transaction. The transaction auto-commits and the next request throws TransactionInactiveError.
  9. Comparing handles by name. Use isSameEntry().
  10. Expecting paths or instanceof to work. There are no paths, and instanceof FileSystemFileHandle fails for handles created in another realm; check kind.
  11. Asking for the whole Documents or home folder. Chromium blocks those folders themselves; ask users to pick a project subfolder.
  12. Assuming Android behaves like desktop. content:// documents, no rename, different persistence behavior. Test there separately.
  13. Rendering file contents as HTML. Files are untrusted input; see the security section.

Debugging

  • Read the exception name, not just the message. AbortError from a picker usually means the user cancelled or refused the folder prompt; SecurityError means missing activation or a cross-origin frame; NotAllowedError means a permission is not granted; NotFoundError means the file moved.
  • Watch the address bar. The file-access indicator shows which files and folders the page holds and in which mode, and Remove access resets the state so you can test the prompts again. The site settings page (File editing) shows persistent grants.
  • Look for .crswap files. While a writable stream is open, its swap file sits next to the target. Leftover swap files mean a writer was never closed or aborted.
  • Test permission lifetimes deliberately. Close all of the origin's tabs to clear session grants, and test once with the app installed (automatic persistence) and once in a tab ("Allow on every visit" prompt).
  • Stub pickers in automated tests. When a DevTools Protocol client enables Page.setInterceptFileChooserDialog (the mechanism behind browser-automation file-chooser helpers), File System Access pickers reject with AbortError ("Intercepted by Page.setInterceptFileChooserDialog()") instead of returning handles. Replace window.showOpenFilePicker in tests with a function that returns OPFS handles, which support the same getFile() and createWritable() calls and are always granted. See Automated Testing and Browser DevTools.
test/stub-pickers.js
// Test helper: makes pickers return files from the origin private file system.
export async function stubOpenPicker(fixtures) {
  const root = await navigator.storage.getDirectory();
  const handles = [];
  for (const [name, contents] of Object.entries(fixtures)) {
    const handle = await root.getFileHandle(name, { create: true });
    const writable = await handle.createWritable();
    await writable.write(contents);
    await writable.close();
    handles.push(handle);
  }
  window.showOpenFilePicker = async () => handles;
  window.showSaveFilePicker = async ({ suggestedName = "saved.txt" } = {}) =>
    root.getFileHandle(suggestedName, { create: true });
  return handles;
}

Further reading

On this site

External references