· deepdives · 7 min read
Unlocking the File System Access API: A Developer's Guide
Learn how to use the File System Access API to open, read, edit, save and manage files and directories from the browser. Includes hands‑on examples, permission handling, fallbacks and best practices.

What you’ll build and why it matters
By the end of this guide you’ll be able to open local files and directories from the browser, edit and save them back to disk, persist access across sessions, and handle the common gotchas that trip up production apps. Build editors, import/export tools, file managers, and desktop-like web apps that interact with the user’s filesystem - without leaving the browser.
Short story: this API lets your web app treat the user’s file system like a first-class resource. Use it responsibly. It requires secure contexts and explicit user permission. Support is improving but not universal.
Quick feature checklist (what the API does)
- Open files with a native file picker (showOpenFilePicker)
- Save files or present “Save as” (showSaveFilePicker)
- Browse, create, and iterate directories (showDirectoryPicker)
- Read and write files via streams and atomic write patterns
- Persist file/directory handles across sessions (store them in IndexedDB)
Browser support and requirements
- Requires a secure context (HTTPS or localhost).
- Requires a user gesture for pickers and some permission requests.
- Not universally available in every browser (check before using).
Check current compatibility: https://caniuse.com/file-system-access Official primer from Google: https://web.dev/file-system-access/ MDN reference: https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API
Feature detection and a safe starting pattern
Always feature-detect before you call any File System Access APIs. Fallback to a classic when not available.
function supportsFileSystemAccess() {
return (
'showOpenFilePicker' in window ||
'showSaveFilePicker' in window ||
'showDirectoryPicker' in window
);
}
if (!supportsFileSystemAccess()) {
// show a fallback file input or inform the user
}Use feature detection at app startup and show an appropriate UI path for users whose browsers don’t support the API.
Core flows with code examples
We’ll cover three common flows: open & read, edit & save, and directory traversal + batch operations.
1) Open and read a file
This is the simplest flow: present a picker, read the file contents, and show them to the user.
// Triggered by a user action (button click)
async function openAndReadFile() {
// showOpenFilePicker returns an array of handles (multi-select possible)
const [fileHandle] = await window.showOpenFilePicker({
types: [
{
description: 'Text files',
accept: { 'text/plain': ['.txt'] },
},
],
});
// Get the actual File object and read text
const file = await fileHandle.getFile();
const text = await file.text();
// Use content in your UI
document.querySelector('#editor').value = text;
// Optionally store the handle for future saves (see persistence section)
return fileHandle;
}Notes:
- getFile() gives you a standard File object (you can use .arrayBuffer(), .text(), or .stream()).
- Large files: prefer .stream() and process chunks rather than loading everything into memory.
2) Edit and save (writing back to the same file)
After you have a FileSystemFileHandle (fileHandle), you can write back to it. Use createWritable(), call write(), then close() to commit.
async function saveToFile(fileHandle, newContents) {
// Ensure we have permission to write
const permission = await fileHandle.requestPermission({ mode: 'readwrite' });
if (permission !== 'granted') throw new Error('Write permission denied');
// Create a FileSystemWritableFileStream
const writable = await fileHandle.createWritable();
// Overwrite file contents
await writable.write(newContents);
// Commit the changes to disk
await writable.close();
}Important details:
- createWritable() performs the actual write operation; close() commits it.
- Always call close() to flush and finalize the write.
- The user may revoke permissions or move/delete the file outside your app; be ready to handle errors on write.
3) Directory access and batch operations
You can ask the user to pick a directory and then enumerate its contents. This enables building sync tools, importers, or file explorers.
async function readDirectory(dirHandle) {
for await (const [name, handle] of dirHandle.entries()) {
if (handle.kind === 'file') {
console.log('File:', name);
} else if (handle.kind === 'directory') {
console.log('Directory:', name);
}
}
}
// Usage (must be a user gesture):
const dirHandle = await window.showDirectoryPicker();
await readDirectory(dirHandle);You can recursively walk directories by calling entries() on subdirectory handles. When creating files in a directory, use dirHandle.getFileHandle(name, { create: true }).
// Create or get a file in the selected directory
const fileHandle = await dirHandle.getFileHandle('notes.txt', { create: true });Handling permissions: query, request, and check
There are two methods for permissions on a handle:
- handle.queryPermission({ mode }) - check current permission state
- handle.requestPermission({ mode }) - prompt the user and return the new state
async function ensureWritePermission(handle) {
const query = await handle.queryPermission({ mode: 'readwrite' });
if (query === 'granted') return true;
const request = await handle.requestPermission({ mode: 'readwrite' });
return request === 'granted';
}Best practice: request permission only when needed and always inform the user why you need it.
Persisting handles across sessions (IndexedDB)
File and directory handles are supported by the structured clone algorithm in browsers that implement the API, which means you can persist handles in IndexedDB and retrieve them later (so users don’t have to re-open the picker every time).
High-level pattern:
- After the user grants a handle, store it in IndexedDB.
- On app startup, read the handle from IndexedDB and use queryPermission() to see if access still exists.
Example with a small helper using idb-keyval (convenience library):
// Store handle
await idbKeyval.set('my-file-handle', fileHandle);
// Retrieve later
const savedHandle = await idbKeyval.get('my-file-handle');
if (savedHandle) {
const perm = await savedHandle.queryPermission({ mode: 'readwrite' });
if (perm === 'granted') {
// You can write/read as before
}
}Reference: note that not all browsers support cloning handles into IndexedDB - test and provide a fallback.
Stream-based reading for large files
Avoid loading huge files into memory. Use the stream API.
async function streamRead(file) {
const stream = file.stream();
const reader = stream.getReader();
const decoder = new TextDecoder();
let result = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
result += decoder.decode(value, { stream: true });
// you could process the chunk instead of concatenating
}
result += decoder.decode(); // flush
return result;
}For very large files, process chunks in place (e.g., parse CSV rows as they arrive) instead of building a giant string.
Common pitfalls and how to avoid them
- Browser compatibility: Not supported everywhere (notably older Safari/Firefox builds). Always feature-detect and provide an input-file fallback. See https://caniuse.com/file-system-access
- User gesture requirement: Pickers must be triggered by a user action (click, keypress). Don’t call them in background initialization.
- Permissions can change: Users can revoke permissions or move/delete files; catch errors from read/write operations and prompt the user to re-select.
- Large files: avoid reading entire files into memory - use streaming.
- Race conditions: when multiple tabs/apps write to the same file, last write wins. Consider locking strategies or ask users to avoid concurrent edits.
- Not available in cross-origin iframes: pickers are generally blocked unless the embedding context allows it.
Best practices and UX recommendations
- Ask for the least privilege you need (read vs readwrite).
- Request permission at the point of intent, with a clear message: “We need write access to save your changes”.
- Provide clear fallbacks when the API isn’t available.
- Use IndexedDB to persist handles but verify permissions on load.
- Make saves atomic from the user’s perspective: show progress, and handle failures gracefully.
- Keep the UX predictable: if possible, maintain a local copy (in-memory or IndexedDB) and save periodically so accidental external changes are recoverable.
Real-world examples and integration ideas
- In-browser code editor that saves files directly to the user’s project directory.
- Photo uploader that batches images from a chosen directory and uploads them with progress feedback.
- CSV editor that can open huge CSVs via streaming and write the cleaned result back to disk.
- Backup utility that reads directories and writes packed archives back to a chosen destination.
The repository examples and tutorials from browser vendors are useful starting points: see the Google sample guides at https://web.dev/file-system-access/.
Security and privacy considerations
- The browser mediates access; your app does not get arbitrary file system rights without user consent.
- Be explicit about why you request access and what you’ll do with the data. Users are more likely to grant permission if they know the purpose.
- Never store sensitive credentials or secrets in plain text files without clear user consent.
Troubleshooting quick reference
- showOpenFilePicker throws: check that it’s invoked by a user gesture and your site is secure (HTTPS).
- getFile or createWritable fails: check permission state with queryPermission() and handle errors.
- Persisted handle in IndexedDB no longer works: check queryPermission() - users may have revoked access.
Further reading and references
- File System Access API overview and tutorial: https://web.dev/file-system-access/
- MDN Web Docs - File System Access API: https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API
- Compatibility table: https://caniuse.com/file-system-access



