File System Access API: Read & Write Workflows
This guide is part of Native Device Integration Patterns, the reference for giving web applications direct access to hardware, sensors, and OS-level file capabilities.
The File System Access API lets web applications read and write local files with explicit, per-handle user consent. Without it, every “save” operation forces you to synthesise a Blob, create a temporary anchor element, and trigger a download — the user picks a new file every time and never edits the original. With the API, a web text editor or data tool can open a file, stream updates into it in place, and release the OS lock cleanly — matching what a native desktop application does, without leaving the browser.
What breaks without this pattern
Absent the File System Access API, web apps are limited to one-way data flow: read via <input type="file">, write via <a download>. That creates three practical problems:
- No in-place editing. Each save creates a new file in the Downloads folder. Users accumulate dozens of
document-1.txt,document-2.txtfiles. - No streaming for large assets.
FileReaderandBlob.text()pull the entire file into memory before the application can act on it. - No directory traversal. There is no way to enumerate a folder’s contents or walk a project tree without the user selecting files one by one.
This guide closes all three gaps with a dual-path implementation: Chromium-based browsers use the native API; Firefox and Safari fall back gracefully.
Prerequisites
Before writing a single line of file I/O code, confirm these conditions:
Browser support
| Browser | Read (showOpenFilePicker) |
Write (createWritable) |
Minimum version |
|---|---|---|---|
| Chrome (desktop) | Yes | Yes | 86 |
| Edge (desktop) | Yes | Yes | 86 |
| Opera | Yes | Yes | 72 |
| Chrome for Android | Yes | Partial — no createWritable on Android |
86 |
| Safari | No | No | — |
| Firefox | No | No | — |
| Samsung Internet | No | No | — |
The write path (createWritable) is not available on Android even in Chrome. On mobile Chromium, fall back to <a download> for writes and use the read path only for file inspection.
Architecture overview
The diagram below shows the decision tree from a user gesture through to a completed read/write cycle, including the permission cache check and both fallback branches.
Step-by-step implementation
Step 1 — Feature detection and secure context validation
Evaluate environment constraints before touching any file API. Both conditions must pass or the modern path must not execute.
export function isFileSystemAccessSupported() {
if (!window.isSecureContext) {
console.warn('File System Access API requires HTTPS or localhost.');
return false;
}
return 'showOpenFilePicker' in window && 'createWritable' in FileSystemFileHandle.prototype;
}
createWritable is separately probed because Chrome on Android ships showOpenFilePicker but omits createWritable. Checking both avoids a partial-support trap that would surface as a runtime error deep in a write path.
Step 2 — Bind picker calls to user gestures
Every picker method (showOpenFilePicker, showSaveFilePicker, showDirectoryPicker) throws SecurityError when called outside a trusted event handler. Wire them directly to click or pointerdown — never call them from a setTimeout, a Promise chain that crossed a tick boundary without a user gesture, or a service worker.
export function attachOpenButton(buttonElement, onFileHandleReady) {
buttonElement.addEventListener('click', async () => {
if (!isFileSystemAccessSupported()) {
return initLegacyReadFallback(onFileHandleReady);
}
try {
const [fileHandle] = await window.showOpenFilePicker({
types: [
{ description: 'Text files', accept: { 'text/plain': ['.txt', '.md'] } },
{ description: 'JSON files', accept: { 'application/json': ['.json'] } }
],
multiple: false,
excludeAcceptAllOption: false
});
onFileHandleReady(fileHandle);
} catch (err) {
if (err.name === 'AbortError') return; // User dismissed — normal control flow
throw err;
}
});
}
Step 3 — Cache and re-validate permission across sessions
FileSystemHandle objects can be serialised to IndexedDB and retrieved across page reloads, enabling a “reopen last file” workflow. Before any I/O, always re-check permission — the browser may have revoked it.
export async function getOrRequestPermission(fileHandle, mode = 'readwrite') {
const current = await fileHandle.queryPermission({ mode });
if (current === 'granted') return true;
const requested = await fileHandle.requestPermission({ mode });
return requested === 'granted';
}
export async function reopenLastHandle(storedHandle) {
if (!storedHandle) return null;
const hasPermission = await getOrRequestPermission(storedHandle, 'readwrite');
if (!hasPermission) return null;
return storedHandle;
}
For the broader pattern of surfacing permission states in your UI and recovering from denials, see handling permission denials gracefully.
Step 4 — Read file content
FileSystemFileHandle.getFile() returns a File object (a Blob subclass). For small files under roughly 5 MB, file.text() is fine. For anything larger, use file.stream() to avoid materialising the entire content in memory. Streaming large assets is covered in detail in handling large file uploads with File System Access API.
export async function readFileHandle(fileHandle) {
const file = await fileHandle.getFile();
if (file.size > 5 * 1024 * 1024) {
// Stream large files rather than loading into memory
const stream = file.stream();
const reader = stream.getReader();
const chunks = [];
let done = false;
while (!done) {
const { value, done: streamDone } = await reader.read();
if (value) chunks.push(value);
done = streamDone;
}
return new TextDecoder().decode(
chunks.reduce((acc, chunk) => {
const merged = new Uint8Array(acc.byteLength + chunk.byteLength);
merged.set(acc, 0);
merged.set(chunk, acc.byteLength);
return merged;
}, new Uint8Array(0))
);
}
return file.text();
}
Step 5 — Write content via FileSystemWritableFileStream
createWritable() opens an OS-level write stream. The stream buffers writes to a temporary swap location and atomically replaces the target file only when close() succeeds — protecting the original if a write is interrupted.
write() accepts a string, ArrayBuffer, ArrayBufferView, Blob, or a WriteParams object. Use WriteParams with seek and truncate for partial updates without rewriting the full file.
export async function writeFileHandle(fileHandle, content) {
let writable = null;
try {
writable = await fileHandle.createWritable();
await writable.write(content);
await writable.close();
writable = null; // Mark closed so finally block skips redundant close
} catch (err) {
if (err.name === 'QuotaExceededError') {
throw new Error('Not enough disk space to save this file.');
}
if (err.name === 'SecurityError') {
throw new Error('Write permission was revoked. Please re-open the file.');
}
throw err;
} finally {
if (writable) {
await writable.close().catch(() => {}); // Best-effort close on error path
}
}
}
Step 6 — Partial writes with seek and truncate
For append-only logs or editors that update a section of a large file, avoid rewriting everything:
export async function appendToFileHandle(fileHandle, newContent) {
const file = await fileHandle.getFile();
let writable = null;
try {
writable = await fileHandle.createWritable({ keepExistingData: true });
await writable.seek(file.size); // Position cursor at end of existing content
await writable.write(newContent);
await writable.close();
writable = null;
} finally {
if (writable) await writable.close().catch(() => {});
}
}
export async function overwriteSection(fileHandle, offset, replacement) {
let writable = null;
try {
writable = await fileHandle.createWritable({ keepExistingData: true });
await writable.seek(offset);
await writable.write(replacement);
// Truncate removes any trailing bytes if the replacement is shorter
await writable.truncate(offset + replacement.length);
await writable.close();
writable = null;
} finally {
if (writable) await writable.close().catch(() => {});
}
}
Error handling reference
All File System Access API errors surface as DOMException. Identify them by err.name, not err.message, which varies between browsers.
| Error name | Trigger | Recovery |
|---|---|---|
AbortError |
User dismissed the picker dialog | Treat as normal cancellation; do not surface an error UI |
SecurityError |
Called outside a user gesture, non-secure context, or after permission revocation | Re-prompt via requestPermission() or activate the legacy path |
NotAllowedError |
User denied the permission prompt | Show denial recovery UI; offer QR code sharing or clipboard as alternatives |
QuotaExceededError |
Disk quota exhausted during a write | Surface a storage warning; write in smaller chunks; offer a download fallback |
NotFoundError |
The file was moved or deleted since the handle was acquired | Re-open via showOpenFilePicker |
TypeMismatchError |
Tried to call a directory method on a file handle, or vice versa | Validate handle type before operations |
Wrap the full read/write cycle in a single try/catch/finally. Close the writable stream in finally regardless of outcome — an unclosed stream holds an OS file lock that persists until the page unloads.
Payload validation and serialisation
When writing structured data (JSON, CSV, binary), validate and serialise before passing the payload to write(). Passing a raw object reference silently produces [object Object] in the file.
export async function writeJsonToHandle(fileHandle, data) {
let serialised;
try {
serialised = JSON.stringify(data, null, 2);
} catch (err) {
throw new TypeError(`Cannot serialise data to JSON: ${err.message}`);
}
await writeFileHandle(fileHandle, serialised);
}
When the file is intended to hold rich text alongside file data, you may want to bridge to the Clipboard API for rich text — for example, reading HTML from the clipboard and writing it as a sanitised file. The async payload formatting guide covers the serialisation pipeline for mixed-type payloads in detail.
Platform gotchas
Chrome for Android — no createWritable.
The write path is unavailable on Android regardless of Chrome version. Feature-detect createWritable separately (see Step 1) and fall back to <a download> on mobile Chromium.
iOS and Safari — complete absence.
Safari does not implement showOpenFilePicker or any part of the File System Access API as of mid-2026. The legacy fallback (Step 7) is the only viable path for all Safari users.
Iframe restrictions.
The API requires a top-level browsing context. Calling any picker from within an <iframe> without allow="file-system-access" throws SecurityError. Add the permission policy header or attribute if you embed the feature in an iframe.
Persistent handles and incognito mode.
Serialising a FileSystemHandle to IndexedDB fails silently in incognito/private browsing in some Chromium versions. Wrap idb.set() in a try/catch and treat the serialisation failure as a non-fatal cache miss — the user will simply re-select the file on next visit.
File lock duration.
On Windows, the OS lock held by an open FileSystemWritableFileStream prevents other processes from writing to the same file. Minimise the window between createWritable() and close(). Do not hold the stream open across idle user time.
Graceful degradation and legacy fallback
Step 7 — Legacy read and write paths
For browsers that fail the feature detection check, implement a two-function fallback. The read path uses a dynamically created <input type="file"> element; the write path synthesises a Blob and triggers a download.
export function initLegacyReadFallback(onContentReady) {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'text/plain,application/json,.md';
input.onchange = async (event) => {
const file = event.target.files?.[0];
if (!file) return;
try {
const text = await file.text();
onContentReady(text, file.name);
} catch (err) {
console.error('Legacy file read failed:', err);
}
};
input.click();
}
export function downloadViaBlob(content, filename, mimeType = 'text/plain') {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Revoke after a tick to ensure the browser has started the download
setTimeout(() => URL.revokeObjectURL(url), 100);
}
When offline or cross-device continuity matters — for example, a user on Safari who wants to exchange a file with another device — consider routing through Web NFC tag reading and writing for proximity-based transfer, or the offline share queue for deferred delivery.
Testing and verification
DevTools — Application panel.
Open DevTools → Application → File System to inspect active file handles and their permission states in supported Chromium versions.
Manual device checklist.
- Chrome desktop (Windows/macOS): test the full read/write cycle including
keepExistingData: trueappends. - Chrome for Android: confirm
createWritableis absent and the Blob download fallback activates. - Safari (desktop and iOS): confirm the legacy
<input>path loads without errors. - Firefox: same as Safari.
- Private/incognito mode in Chrome: verify the IndexedDB serialisation failure is caught and does not crash the application.
Quota testing.
Use the Storage Manager API to inspect available quota before large writes:
export async function hasStorageQuota(requiredBytes) {
if (!('storage' in navigator && 'estimate' in navigator.storage)) return true;
const { quota, usage } = await navigator.storage.estimate();
return (quota - usage) >= requiredBytes;
}
Call this before opening a write stream for files above a few megabytes, and surface a user-facing warning if space is insufficient.
Common pitfalls
- Missing user gesture. Calling picker APIs outside a direct
clickortaphandler triggers an immediateSecurityError. Async code that awaits a network call or timer before invoking the picker loses the gesture context. - Unclosed streams. Failing to call
close()onFileSystemWritableFileStreamholds an OS file lock and corrupts subsequent writes. Always use afinallyblock. - Main thread blocking. Using
Blob.arrayBuffer()on multi-gigabyte files freezes the UI. UseFile.stream()and process chunks in a Web Worker. - Ignoring
AbortError. Users frequently dismiss native picker dialogs. TreatAbortErroras normal control flow — catch it separately and return silently. - Quota blindness. Large stream writes can exhaust available storage quota without warning. Catch
QuotaExceededErrorand implement chunked writes with progress feedback. - Passing objects to
write(). Thewrite()method does not serialise JavaScript objects. Always convert to a string,ArrayBuffer, orBlobbefore calling it.
FAQ
Why does showOpenFilePicker throw a SecurityError?
The API requires execution within a secure context (HTTPS or localhost) and must be triggered by a direct, trusted user gesture. Background timers or programmatic calls are blocked by the browser’s security model. If your code crosses an await boundary (e.g., awaiting a network fetch) before calling the picker, the gesture context is lost.
How do I handle large files without blocking the main thread?
Use file.stream() to get a ReadableStream, then pipe it to a TransformStream or WritableStream. For CPU-heavy parsing (CSV, JSON), pass chunks to a Web Worker via postMessage with transferable ArrayBuffer ownership to avoid copying.
What is the recommended fallback for browsers without File System Access API support?
Use <input type="file"> for reading and <a download> with a dynamically generated Blob URL for writing. Firefox and Safari users will follow this path. Always detect support with 'showOpenFilePicker' in window before branching; do not use user-agent sniffing.
Can I persist a FileSystemFileHandle across page reloads?
Yes. Serialise the handle to IndexedDB (using idb-keyval or a similar library). On reload, deserialise and call requestPermission({ mode: 'readwrite' }) before any file operation — the browser will re-prompt if permission has lapsed. This does not work reliably in private browsing mode; wrap the serialisation in a try/catch.
Related
- Handling large file uploads with File System Access API — streaming strategies, chunked writes, and Web Worker offloading for multi-gigabyte assets
- Native Device Integration Patterns — parent guide covering the full browser native API surface
- Mastering the Clipboard API for rich text — bridging clipboard reads into file writes
- Handling permission denials gracefully — UI patterns for
NotAllowedErrorand denial recovery flows - Web NFC: Reading and Writing Tags in the Browser — proximity-based file exchange when the File System API is unavailable