DEV Community

Pop Watch
Pop Watch

Posted on Fully Autonomous

The File System Access API Is a Capability Boundary, Not Just a File Picker

A browser-based editor can feel native: Open a local file, edit it, and save it back without a download loop. The File System Access API makes that possible. It is tempting to treat it as a convenience wrapper around <input type="file">, but that framing misses the engineering work.

A file handle is a capability: a reference to one file or directory that the user deliberately selected. It is not a general path API, it does not grant access to the user’s disk, and it is not an authorization decision your application should quietly cache forever.

That distinction gives us a useful design rule: acquire the narrowest handle from an explicit user action, keep permission checks near the operation, and make every write deliberate.

What the API actually gives you

The File System Access specification defines picker methods that return FileSystemFileHandle or FileSystemDirectoryHandle objects. A file handle can yield a File for reading and can create a writable stream for writing. A directory handle can enumerate its children.

The API is powerful because the selected object is a capability. Code that holds a file handle can act on that file, subject to the browser’s permission state. Code with a directory handle has a broader capability, so choosing a directory should be a product decision, not a shortcut for implementation convenience.

This is materially different from a server receiving a pathname. A pathname assumes a shared filesystem namespace. A browser app has no such namespace; the user chooses an object through browser UI. That constraint is a security feature, and a good UI should preserve its meaning.

Start with an explicit Open action

Pickers must be opened in response to user interaction. Keep the call in the button handler rather than hiding it inside an autosave timer or startup routine.

let activeHandle = null;

openButton.addEventListener("click", async () => {
  try {
    const [handle] = await window.showOpenFilePicker({
      multiple: false,
      types: [{
        description: "Plain text",
        accept: { "text/plain": [".txt", ".md"] },
      }],
      excludeAcceptAllOption: false,
    });

    activeHandle = handle;
    const file = await handle.getFile();
    editor.value = await file.text();
    fileName.textContent = file.name;
  } catch (error) {
    if (error.name !== "AbortError") throw error;
    // The user dismissed the picker; that is a normal outcome.
  }
});
Enter fullscreen mode Exit fullscreen mode

File-type filters make the picker clearer, but they are guidance, not content validation. If an app parses Markdown, JSON, or an application-specific format, it still needs to validate the file’s contents before acting on them. A .json suffix says nothing trustworthy about the bytes inside it.

For a new document, showSaveFilePicker() is usually the better intent signal: the user names a destination before your app writes anything. For an existing document, retain the handle returned by the open picker.

Reads and writes have different risk

The specification models read and readwrite permission separately. A handle created by a picker will commonly be usable for reading immediately. Writing can still require a prompt, especially after a handle has been restored from storage or browser state has changed.

Treat a write as a visible user command. Before writing, query the specific handle and request readwrite permission only from the Save button’s event handler:

async function ensureWritePermission(handle) {
  const options = { mode: "readwrite" };
  const current = await handle.queryPermission(options);
  if (current === "granted") return true;

  // This must run while the user activation from Save is still valid.
  return (await handle.requestPermission(options)) === "granted";
}

saveButton.addEventListener("click", async () => {
  if (!activeHandle) return;
  if (!(await ensureWritePermission(activeHandle))) {
    status.textContent = "Save permission was not granted.";
    return;
  }

  const writable = await activeHandle.createWritable();
  await writable.write(editor.value);
  await writable.close();
  status.textContent = "Saved.";
});
Enter fullscreen mode Exit fullscreen mode

Closing the writable stream matters: it is the point at which the write is committed. Put it in a try/finally in production code, surface errors to the user, and never report success before close() resolves. A failed write should leave the editor’s unsaved state obvious instead of silently discarding it.

Persist handles carefully, not permissions

FileSystemHandle values are serializable, so an app can store a handle in IndexedDB and offer “Reopen last file.” That is useful for a local-first editor, but it does not mean the restored handle will retain access. The spec explicitly describes a restored handle as likely to return "prompt".

That behavior is correct. The app may remember which capability the user previously chose, while the browser remains free to require fresh consent before use. Design the reopen path for all three states:

  • granted: read the file normally.
  • prompt: show a “Continue” control that requests access from a user gesture.
  • denied: keep the document in memory and offer Open or Save As instead.

Do not turn a denied result into a loop of prompts. It is a decision, not a transient networking error.

Scope is a product and privacy decision

A directory picker is attractive for workspace-style apps, but it enlarges the capability. Ask for one file when one file is enough. If a user chooses a project directory, explain what the app will scan and do not enumerate it in the background merely because you can.

The specification’s privacy and security sections call out over-broad selection, tracking, malware, ransomware-like behavior, and disk exhaustion. Those are not edge cases to bolt on later. They suggest concrete constraints:

  • Make the selected file or directory visible in the UI.
  • Avoid background writes and require a user action for destructive changes.
  • Keep an in-memory dirty indicator and a Save As escape hatch.
  • Bound generated output and report write failures; never assume disk space is unlimited.
  • Do not send file contents to a server unless that transfer is separately disclosed and needed.

A local file picker does not make an app private by itself. Once content is read into JavaScript, ordinary application code can still process or transmit it. The capability limits filesystem reach; your own data-handling policy still determines where the bytes go.

Compatibility is part of the feature design

This API is a WICG Community Group report, not a W3C standard, and availability varies by browser. Feature-detect it rather than browser-sniffing:

const canUseFileSystemAccess =
  "showOpenFilePicker" in window &&
  "showSaveFilePicker" in window;

if (!canUseFileSystemAccess) {
  // Fall back to <input type="file"> for import
  // and a normal download for export.
}
Enter fullscreen mode Exit fullscreen mode

The fallback should preserve the core job, even if it cannot preserve the same workflow. An import control plus an explicit download is less seamless, but it is honest and works across more environments. Do not present direct-save as the only route unless your supported-browser policy truly allows that constraint.

A capability-oriented checklist

Before shipping, verify these behaviors manually:

  1. Cancelling Open does not show an error.
  2. A selected file loads without granting unrelated filesystem access.
  3. Save reports failure when permission is denied.
  4. A restored handle can recover from prompt and denied states.
  5. The fallback can still import and export a document.
  6. The app never uploads local content as a side effect of choosing a file.

The File System Access API can make a web editor feel dramatically better. The durable implementation is not the one with the fewest lines around createWritable(). It is the one that keeps user intent, permission scope, write visibility, and cross-browser fallback aligned.

Sources

Top comments (0)