Building an Offline File Editor with the File System Access API

True local-first web applications require breaking out of the download folder loop and writing bytes directly to the host operating system disk.

VW
Viery Wahyu•Author
•7 min read

For decades, web browsers treated the local file system like a hazardous quarantine zone. Whenever a user wanted to edit a configuration file, a markdown document, or a local database dump inside a web application, we had to put them through a clumsy three-step choreography. The user selected a file through a legacy input element, our JavaScript code read a transient copy of those bytes into memory, and pressing save triggered a synthetic browser download that littered their downloads folder with numbered duplicates like config (3).json. When we set out to engineer a true offline-capable file editor for our local diagnostic and developer workflows, we knew that faking file persistence inside localStorage or forcing constant file downloads would ruin the user experience. We needed our web application to behave like a native desktop binary that opens, mutates, and saves files directly in place on the physical disk.

Replacing Synthetic Downloads With Atomic File Streams

Moving from legacy file inputs to the modern File System Access API changes the fundamental primitive from a static blob snapshot to a persistent FileSystemFileHandle. When you invoke window.showOpenFilePicker(), the browser prompts the user to select a target file on their machine and returns a live reference pointer to that exact inode on the host operating system. Reading the file requires calling getFile() on the handle, while saving modifications back to disk happens through an asynchronous writable stream created via createWritable().

typescript
1export interface EditorFileSession {
2 handle: FileSystemFileHandle
3 content: string
4 lastSavedAt: number
5}
6
7export async function openLocalFileSession(): Promise<EditorFileSession> {
8 const [fileHandle] = await window.showOpenFilePicker({
9 types: [
10 {
11 description: "Configuration and Markdown Files",
12 accept: {
13 "application/json": [".json"],
14 "text/markdown": [".md", ".mdx"],
15 },
16 },
17 ],
18 multiple: false,
19 })
20
21 const fileData = await fileHandle.getFile()
22 const textContent = await fileData.text()
23
24 return {
25 handle: fileHandle,
26 content: textContent,
27 lastSavedAt: Date.now(),
28 }
29}
30
31export async function saveFileAtomically(
32 handle: FileSystemFileHandle,
33 updatedContent: string
34): Promise<void> {
35 const writableStream = await handle.createWritable({
36 keepExistingData: false,
37 })
38
39 try {
40 await writableStream.write(updatedContent)
41 } finally {
42 // Changes are only flushed to the real file when close() resolves
43 await writableStream.close()
44 }
45}

What makes createWritable() such a brilliant piece of browser storage engineering is how it protects user data against partial write corruption. Under the hood, calling writableStream.write() does not immediately overwrite the target file on the user's hard drive. Instead, the browser creates a hidden temporary swap file on the operating system, streams your new bytes into that staging file, and runs internal security checks. If the user's laptop battery dies or the browser tab crashes mid-write, the original file remains completely untouched. The operating system only performs an atomic rename over the original file at the exact millisecond writableStream.close() finishes executing.

Persisting File Handles Across Page Reloads via IndexedDB

Opening and saving a file during a single browser session is straightforward, but real offline editors fail if the user accidentally refreshes the tab and loses their active workspace connection. Because a FileSystemFileHandle represents a privileged operating system pointer rather than a plain JSON string, you cannot serialize it into localStorage. However, the browser implements the structured clone algorithm on file and directory handles, which means you can store the raw FileSystemFileHandle object directly inside an IndexedDB object store.

When the user reopens the offline editor the next morning, you pull the stored handle back out of IndexedDB and inspect its security state before attempting to read or write. To prevent malicious websites from silently scraping a user's hard drive days after a one-time interaction, browsers reset write permissions when all tabs for an origin are closed. Restoring a seamless editing session simply requires checking queryPermission and triggering a single user-gesture confirmation via requestPermission before resuming normal autosave operations.

typescript
1export async function verifyAndRestoreWriteAccess(
2 handle: FileSystemFileHandle
3): Promise<boolean> {
4 const options: FileSystemHandlePermissionDescriptor = { mode: "readwrite" }
5
6 if ((await handle.queryPermission(options)) === "granted") {
7 return true
8 }
9
10 // Must be triggered inside a user click or keydown handler
11 if ((await handle.requestPermission(options)) === "granted") {
12 return true
13 }
14
15 return false
16}

Pairing IndexedDB handle persistence with that twelve-line permission check gives web applications the exact "Recent Files" and instant workspace recovery workflow that users expect from desktop IDEs like VS Code. Instead of navigating through nested operating system folder pickers every time they open the app, the user clicks their recent project card once, hits allow on a lightweight browser prompt, and immediately resumes editing their local repository offline.

Bypassing Async Overhead With the Origin Private File System

While user-visible file handles are ideal for documents the user wants to organize in Finder or Windows Explorer, they carry deliberate security overhead. Every write stream on a user-visible file invokes operating system malware scanners and safe-browsing checks, which creates noticeable latency if your offline editor needs to maintain a high-frequency undo log, a local search index, or an embedded SQLite scratchpad alongside the main document.

To solve that performance bottleneck without compromising system security, we paired the standard file picker with the Origin Private File System, commonly known as OPFS. Accessed through navigator.storage.getDirectory(), OPFS provides an isolated, origin-scoped virtual directory on the disk that is hidden from the user's regular file explorer. Because external applications cannot tamper with OPFS files and the user never executes them directly, the browser drops its heavy security wrappers and exposes a high-speed synchronous API called createSyncAccessHandle() exclusively inside Web Workers.

typescript
1// Inside a dedicated background storage Web Worker (storage.worker.ts)
2self.onmessage = async (event: MessageEvent<{ buffer: ArrayBuffer; offset: number }>) => {
3 const rootDirectory = await navigator.storage.getDirectory()
4 const draftHandle = await rootDirectory.getFileHandle("editor-undo-journal.bin", {
5 create: true,
6 })
7
8 // Acquire an exclusive, synchronous POSIX-like file lock inside the worker
9 const syncAccess = await draftHandle.createSyncAccessHandle()
10
11 try {
12 const bytesWritten = syncAccess.write(event.data.buffer, {
13 at: event.data.offset,
14 })
15 syncAccess.flush()
16 self.postMessage({ status: "flushed", bytesWritten })
17 } finally {
18 syncAccess.close()
19 }
20}

Look closely at the API inside that Web Worker. Unlike every other storage mechanism in the browser, syncAccess.write() and syncAccess.flush() are completely synchronous calls that bypass JavaScript Promise microtask queues and write raw binary ArrayBuffer slices straight to the underlying disk offset. By streaming every keystroke delta into an OPFS binary journal inside a background worker and periodically flushing consolidated snapshots out to the user-visible FileSystemFileHandle via createWritable(), our editor achieved zero-latency crash recovery even when editing fifty-megabyte log and data files offline.

Architecting Respectfully Around Browser Capabilities

Engineering production storage layers around the File System Access API ultimately teaches you to design progressive, layered persistence. While Chromium-based browsers support both user-visible showOpenFilePicker() handles and background OPFS synchronous locks natively, Safari and Firefox enforce stricter boundaries around direct user-visible folder mutation while fully supporting the high-speed Origin Private File System. By using OPFS as our universal high-speed staging engine across every browser and layering direct disk write-back via FileSystemFileHandle wherever the host supports it, we delivered an offline editing experience that feels indistinguishable from a native desktop application while running entirely inside a browser tab.

VW

Written by

Viery Wahyu

📍 Bali, Indonesia

Software Engineer

Software engineer based in Bali with over a decade of experience building fast web applications

The Journal

View all →