File System Access API: Read and Write Local Files
The File System Access API gives web applications a way to interact with files on the user’s local device. Before this API, web apps could only read files through <input type="file"> dialogs, and writing required downloading files to the browser’s download folder. Now you can open a file, make changes, and save them directly when the user grants permission.
The API is built around handles. A handle is a reference to a file or directory that the user has granted you access to. The browser manages the actual file access, so malicious scripts cannot read arbitrary files.
Opening files with a picker
The showOpenFilePicker() method shows the system’s native file picker and returns handles to the selected files:
const [fileHandle] = await window.showOpenFilePicker();
The result is always an array, so the destructuring assignment pulls out the first handle. Without any options, the picker accepts all file types. In most real applications you will want to restrict what the user can pick by specifying accepted MIME types and file extensions. The types option is a filter that the browser enforces before returning handles:
const pickerOpts = {
types: [
{
description: "Images",
accept: {
"image/*": [".png", ".gif", ".jpeg", ".jpg"]
}
}
],
excludeAcceptAllOption: true,
multiple: false
};
const [fileHandle] = await window.showOpenFilePicker(pickerOpts);
With excludeAcceptAllOption: true, the user sees only the file types you specified. Setting multiple: false (the default) returns a single handle. When you need to process several files at once, flip that flag:
Setting multiple: false (or omitting it) returns a single handle. Setting multiple: true lets the user pick several files at once:
const handles = await window.showOpenFilePicker({ multiple: true });
When multiple is true, the return value is an array of handles, one per selected file. Each handle gives you access to that file’s metadata and contents. Once you have a handle, the next step is usually to read the file. Call getFile() to create a File object:
Once you have a handle, call getFile() to read its contents:
const fileHandle = await window.showOpenFilePicker()[0];
const file = await fileHandle.getFile();
console.log(file.name, file.size, file.lastModified);
const contents = await file.text();
getFile() returns a File object, which is the same type you’d get from an <input type="file">. You can use .text(), .arrayBuffer(), or .stream() on it. Reading files is half the story, though. The API also lets you write files back to disk, which is what the save picker is for.
Saving files with a picker
showSaveFilePicker() opens a save dialog and returns a handle you can write to:
const handle = await window.showSaveFilePicker();
const writable = await handle.createWritable();
await writable.write("Hello, file system!");
await writable.close();
The createWritable() method returns a FileSystemWritableFileStream, which extends the standard WritableStream with convenience methods for file-specific operations. For simple writes, you can pass a string, Blob, or ArrayBuffer directly to write(). For more control over where data lands in the file, use the positioned write variants:
createWritable() returns a FileSystemWritableFileStream, which is a WritableStream with extra convenience methods. You can write plain data directly:
await writable.write("Plain text content");
await writable.write(myBlob);
await writable.write(myArrayBuffer);
The plain write() calls append data at the current cursor position, which starts at the beginning of the file. For more precise control over where writes land, you can pass an options object instead of raw data. This is useful for updating a specific byte range without rewriting the entire file:
You can also do positioned operations:
// Write at a specific byte offset
await writable.write({ type: "write", position: 0, data: updatedContent });
// Move the cursor to a position
await writable.write({ type: "seek", position: 10 });
// Truncate the file to a specific size
await writable.write({ type: "truncate", size: 100 });
Each positioned operation specifies its type explicitly. The "seek" type moves the write cursor, "truncate" cuts the file to a given size, and "write" places data at an exact byte offset. None of these changes hit the disk until you call close(), which commits the writable stream and makes the data persistent. The API also supports working with entire directory trees, not just individual files.
The file is only written to disk when you call close().
Opening a Directory
showDirectoryPicker() gives you a FileSystemDirectoryHandle for navigating a directory tree:
const dirHandle = await window.showDirectoryPicker();
From a directory handle, you can:
getFileHandle(name): get a file handle for an existing filegetDirectoryHandle(name, { create: true }): create or open a subdirectoryremoveEntry(name, { recursive: true }): delete a file or directoryvalues(): iterate over all entries (files and subdirectories)
const dirHandle = await window.showDirectoryPicker();
// List all files in the directory
for await (const entry of dirHandle.values()) {
if (entry.kind === "file") {
console.log("File:", entry.name);
} else {
console.log("Directory:", entry.name);
}
}
// Create a subdirectory
const subDir = await dirHandle.getDirectoryHandle("assets", { create: true });
// Get a file handle inside the directory
const fileHandle = await subDir.getFileHandle("config.json");
Once you can iterate over a directory, you can build tools that scan, search, or back up entire project trees. For deeply nested directories, a recursive walker is the natural next step. Here is a generator-based approach that yields every file path under a directory:
Working with directories recursively
Here is a pattern for recursively listing all files in a directory tree:
async function* walkDir(dirHandle, path = "") {
for await (const entry of dirHandle.values()) {
const entryPath = path ? `${path}/${entry.name}` : entry.name;
if (entry.kind === "directory") {
const subDir = await dirHandle.getDirectoryHandle(entry.name);
yield* walkDir(subDir, entryPath);
} else {
yield entryPath;
}
}
}
The generator pattern keeps the walker lazy; it yields paths one at a time rather than collecting everything into an array. For flat or moderately deep trees this distinction does not matter much, but for large directory structures the memory savings can be significant. If you do not need the file-picker workflow at all, the Origin Private File System offers a completely separate storage model that is invisible to the user.
Origin private file system
The Origin Private File System (OPFS) is a separate storage endpoint that is private to your origin; it is invisible to the user and not accessible through the file picker. It is optimized for performance and supports in-place writes:
const root = await navigator.storage.getDirectory();
navigator.storage.getDirectory() returns the root of the origin’s private file system. From there, you work with the same FileSystemDirectoryHandle interface you already know. The key difference is that OPFS never prompts the user. Everything lives inside a sandboxed storage bucket tied to your origin, so data persists across sessions but is not visible in the system file explorer. Here is a complete write example:
OPFS is useful when you want persistent local storage without requiring the user to pick a directory. You get a FileSystemDirectoryHandle just like with showDirectoryPicker(), but the directory is managed entirely by the browser:
const root = await navigator.storage.getDirectory();
const fileHandle = await root.getFileHandle("cache.json", { create: true });
const writable = await fileHandle.createWritable();
await writable.write(JSON.stringify({ cached: true }));
await writable.close();
OPFS gives you a full directory tree without any user-facing picker dialogs. Everything lives inside a sandboxed storage bucket tied to your origin, so data persists across sessions but is not visible in the system file explorer. This makes OPFS a strong alternative to IndexedDB for structured file storage, especially when your app needs to manage hierarchical data. For latency-sensitive workloads inside a worker, OPFS exposes a synchronous access mode.
Synchronous access in web workers
OPFS supports a synchronous access handle for use inside Web Workers. This is faster than the async API for frequent reads and writes, which matters when performance is critical (for example, when dealing with large files or real-time processing):
// Inside a Web Worker
const root = await navigator.storage.getDirectory();
const fileHandle = await root.getFileHandle("data.bin", { create: true });
const accessHandle = await fileHandle.createSyncAccessHandle();
// Synchronous read
const size = accessHandle.getSize();
const buffer = new DataView(new ArrayBuffer(size));
accessHandle.read(buffer, { at: 0 });
// Synchronous write
const encoder = new TextEncoder();
const encoded = encoder.encode("new content");
accessHandle.write(encoded, { at: size });
accessHandle.flush();
accessHandle.close();
createSyncAccessHandle() is only available inside a dedicated Web Worker and only works within OPFS, not with handles from file pickers. The synchronous API avoids the overhead of promise resolution for every read and write, which makes a noticeable difference when processing large binary files or running frequent incremental updates. For some applications, watching a directory for changes is more useful than periodically polling it.
FileSystemObserver: watching for changes
The FileSystemObserver lets you watch a directory for changes without polling:
const dirHandle = await navigator.storage.getDirectory();
const observer = new FileSystemObserver((records, observer) => {
for (const record of records) {
console.log("Changed:", record.changeRecord.path);
}
});
observer.observe(dirHandle);
Each FileSystemChangeRecord tells you the path that changed and what kind of change it was (created, modified, deleted, or moved). The observer fires a callback whenever any change occurs inside the watched directory, so you do not need to poll or set up individual watchers per file. Handles can also come from outside the picker API entirely. When a user drags a file onto your page, you can request a file system handle directly from the drop event.
Drag and drop integration
You can also get file handles from drag-and-drop operations. When a user drags a file onto your page, getAsFileSystemHandle() gives you a handle (with the user’s permission):
elem.addEventListener("drop", async (e) => {
e.preventDefault();
for (const item of e.dataTransfer.items) {
if (item.kind === "file") {
const handle = await item.getAsFileSystemHandle();
if (handle.kind === "file") {
const file = await handle.getFile();
console.log("Dropped file:", file.name);
}
}
}
});
The getAsFileSystemHandle() method converts a DataTransferItem into a FileSystemHandle, which you can then use just like a handle from the file picker. If the dragged item is a directory, the handle will have kind === "directory" and you can traverse it the same way. This integration makes the API a natural fit for editors, file managers, and any app built around local file workflows. Browser support is not universal yet, so a feature check is essential before relying on any of these APIs.
Browser support and security
The File System Access API is Chromium-only. It works in Chrome, Edge, and Opera, but not in Firefox or Safari as of early 2026. Always check for support before using it:
if ("showOpenFilePicker" in window) {
// Use the API
} else {
// Fall back to <input type="file">
}
The API requires a secure context (HTTPS) or localhost. Permission to access files persists across sessions until the user revokes it through the browser’s site settings.
Permission Flow
File access should feel deliberate. Ask for a picker only when the user is ready to open or save something, and keep the permission request tied to that action. That makes the browser prompt easier to understand and reduces the chance that your app feels intrusive. A clear interaction also makes it easier to explain why the site needs access.
Edit and save carefully
The safest pattern is to read the file, let the user make a change, then write the whole result back through a fresh writable stream. That gives you a clear checkpoint before anything is saved to disk. If the change is small, the full rewrite still tends to be simple and predictable. For larger documents, track the edit state so you only ask the browser to write when the user is finished.
Directory trees and indexing
Once you can walk a directory, you can build tools that scan, search, or reorganize entire project trees. Keep the traversal logic separate from the action you perform on each file, because that makes it easier to reuse the same walker for audits, backups, or import jobs. The directory handle becomes a source of truth for the file system subtree the user chose.
Choosing OPFS vs picked files
Use OPFS when the data belongs to your app and does not need to be visible in the system file picker. Use user-picked handles when the user expects to work with real files on disk. That distinction matters because it shapes backup behavior, sharing, and how much trust the user is giving the application. Picking the right store keeps the feature aligned with user intent.
Permission Timing
Ask for file access in response to a clear user action. That makes the browser prompt feel expected and helps users understand why the site needs it. If the request happens too early, it can feel like the page is asking for more trust than it has earned. A simple save or open button keeps the interaction honest.
Browser managed paths
Handles are better than raw paths because the browser stays in control of access. That means your code can focus on the file experience instead of trying to manage permissions itself. It also makes directory work safer, because the page only sees the tree the user chose. That separation matters when you build importers, editors, or local project tools.
Think about recovery
File workflows should include a fallback if the browser refuses access or the user cancels the picker. Keep a simple message and a retry path ready so the app can continue without drama. That makes the feature feel resilient instead of fragile, which matters a lot when the user is working with real files.
Separate reading from writing
Reading and writing are different jobs, even when they touch the same handle. Read first, make the edit in memory, and only then open a writer. That pattern gives the app a checkpoint before anything changes on disk and keeps the file flow easier to explain when something goes wrong.
See Also
- javascript-streams-api: the Streams API, which
Fileobjects use for reading data - javascript-indexeddb: another browser storage option, useful for structured data
- browser-storage: overview of all browser storage options including OPFS, IndexedDB, and Cache API
- javascript-web-workers: dedicated workers are required for OPFS synchronous access handles