
FileSystemObserver Is the End of the File-Polling Hack
Stop wasting CPU cycles: the new FileSystemObserver API finally gives the web a native way to react to disk changes in real-time without the battery-draining overhead of manual polling.
How many CPU cycles have we collectively set on fire just trying to tell if a local file changed?
For years, if you were building a web-based code editor, a local-first markdown tool, or a video sequencer that relied on the File System Access API, you were stuck in a dark age of performance. To detect if a user edited a file outside your browser tab, you had to resort to the "Polling Hack": a relentless, battery-draining loop of setInterval that checked the lastModified timestamp of a file handle over and over again.
It was inefficient, it was laggy, and honestly, it felt like a betrayal of the modern web’s potential. But the FileSystemObserver API is finally hitting the stage, and it marks the end of the "is it changed yet?" era.
The Cost of the Polling Hack
To understand why FileSystemObserver is such a big deal, we have to look at the mess we're leaving behind.
Before this API, if you wanted to know when a file changed, your code probably looked something like this:
async function watchFilePolling(fileHandle) {
let lastModified = (await fileHandle.getFile()).lastModified;
setInterval(async () => {
const file = await fileHandle.getFile();
if (file.lastModified !== lastModified) {
lastModified = file.lastModified;
console.log("File changed! Reloading...");
// Trigger your logic here
}
}, 1000); // Check every second
}This looks innocent enough, but it's a disaster for several reasons:
1. Latency: If the user saves a file, your app might not notice for up to a second. If you lower the interval to 100ms to feel "snappy," you’re now hammering the main thread and the disk.
2. Battery Drain: Even when the user isn't doing anything, your app is constantly waking up the CPU to ask "Are we there yet?"
3. Directory Hell: Polling a single file is bad. Polling an entire directory of 500 files for changes is a recipe for a frozen UI and a cooling fan that sounds like a jet engine.
Enter FileSystemObserver
The FileSystemObserver API follows the same design pattern as MutationObserver, IntersectionObserver, and ResizeObserver. Instead of you asking the system for updates, the system pushes updates to you when—and only when—something actually happens.
It hooks into the operating system’s native file system notification events (like inotify on Linux, FSEvents on macOS, or ReadDirectoryChangesW on Windows). This is "zero-cost" in terms of idle CPU usage.
The Basic Setup
Here is how you initialize a basic observer. Note the familiar callback structure:
const observer = new FileSystemObserver((records) => {
for (const record of records) {
console.log(`Change detected in ${record.root.name}:`, record.type);
}
});
// Assuming you already have a FileSystemHandle from window.showOpenFilePicker()
async function startWatching(handle) {
try {
await observer.observe(handle);
console.log("Watching for changes...");
} catch (err) {
console.error("Failed to observe:", err);
}
}The records argument is an array of FileSystemChangeRecord objects. Much like MutationObserver, changes are often batched to prevent the main thread from being overwhelmed during rapid-fire disk operations.
Taking it Further: Recursive Watching
The real power move of this API isn't just watching a single file; it's watching an entire directory tree. If you're building a project explorer for a web IDE, you need to know if a user added a new folder, deleted a .env file, or moved a .png from one sub-directory to another.
The observe method accepts an options object where you can specify recursive: true.
async function watchProjectFolder(directoryHandle) {
const observer = new FileSystemObserver((records) => {
records.forEach(record => {
switch (record.type) {
case 'appeared':
console.log(`File created: ${record.changedHandle.name}`);
break;
case 'disappeared':
console.log(`File deleted: ${record.changedHandle.name}`);
break;
case 'modified':
console.log(`File edited: ${record.changedHandle.name}`);
break;
case 'moved':
console.log(`File moved from ${record.relativePathFrom} to ${record.relativePathTo}`);
break;
}
});
});
await observer.observe(directoryHandle, { recursive: true });
}Understanding Change Types
The API provides specific types of changes that tell you exactly what happened to the file system:
* `appeared`: A file or directory was created or moved into the observed scope.
* `disappeared`: A file or directory was deleted or moved out of the observed scope.
* `modified`: The content of a file changed or metadata was updated.
* `moved`: This is the "gold standard" event. In older systems, a move looked like a "delete" followed by a "create." FileSystemObserver attempts to link these together so you can track a file's identity even if its path changes.
Why "Moved" is Harder Than it Looks
Detecting a "move" event is notoriously difficult for operating systems and browsers. If you move a file from root/docs/notes.txt to root/archive/notes.txt, the API tries to provide both the relativePathFrom and relativePathTo.
However, there's a catch: Atomicity. If a user moves a file across different partitions or if the OS reports it as two distinct events, you might still see a disappeared followed by an appeared. You should always write your UI logic to be resilient enough to handle both the moved event and the disappeared/appeared pair.
Practical Use Case: A Real-Time Markdown Previewer
Let's look at a practical example. Imagine a tool like Obsidian or VS Code, but running entirely in the browser. You want to render a Markdown preview that updates the moment you save the file in your favorite desktop text editor.
let fileHandle;
const previewElement = document.getElementById('preview');
const observer = new FileSystemObserver(async (records) => {
// We only care about the latest 'modified' event
const isModified = records.some(r => r.type === 'modified');
if (isModified && fileHandle) {
const file = await fileHandle.getFile();
const content = await file.text();
previewElement.innerHTML = parseMarkdown(content); // Using your favorite MD parser
}
});
async function openFile() {
[fileHandle] = await window.showOpenFilePicker();
// Initial render
const file = await fileHandle.getFile();
previewElement.innerHTML = parseMarkdown(await file.text());
// Start the observer
await observer.observe(fileHandle);
}In the "polling" version of this app, you’d be reading that file once a second regardless of whether the user touched it. In this version, the browser sits perfectly still until the OS signals that the file's bits have flipped. It's cleaner, faster, and respects the user's hardware.
Handling Permissions and Security
Because the File System Access API deals with the user's actual files, security is tight. FileSystemObserver doesn't bypass this.
1. Transient Activation: You can't just start observing files the moment a page loads. The user must interact with the page (like a click) to trigger the file picker.
2. Permission Persistence: If the user reloads the page, you’ll usually need to prompt them for permission again to "see" the handles you stored in IndexedDB, and subsequently, to re-observe them.
3. Scoped Observation: You can only observe handles you have been explicitly granted access to. You can't observe C:\ (unless the user is crazy enough to pick it in the directory picker).
The Gotchas: What to Watch Out For
While this is a massive step forward, it isn't magic. Here are a few things that tripped me up when I first started playing with it:
1. Debouncing is Still Your Friend
If a build tool (like Vite or Webpack) is writing to a directory, it might trigger 50 modified and appeared events in a single millisecond. Even though the browser batches records, you might want to debounce your UI updates. You don't want to re-render your entire file tree 50 times in a single frame.
2. The "Ghost" Handles
When a file disappears, the record.changedHandle might refer to a file that no longer exists on disk. If you try to call handle.getFile() on a disappeared handle, expect it to throw an error. Always check the type before trying to perform IO on the handle.
3. Support and Polyfills
As of late 2024, FileSystemObserver is still moving through the implementation phases (it's been in Origin Trials in Chrome). It's not in Safari or Firefox yet. You should always use feature detection:
if ('FileSystemObserver' in window) {
// Use the shiny new API
} else {
// Fall back to the "polling hack" if you must
}How This Changes the "Web vs. Desktop" Debate
For a long time, the argument against web apps was that they were "sandboxed toys." They couldn't talk to the OS, they couldn't handle large files, and they definitely couldn't react to the system in real-time.
The File System Access API broke the first two barriers. FileSystemObserver is breaking the third.
We’re seeing a shift where the line between a "website" and "software" is effectively gone. When a web app can watch a node_modules folder for changes with the same efficiency as a native C++ application, we've reached a new level of capability.
If you’re still using setInterval to check for file changes, it’s time to refactor. Your users' CPUs (and their battery lives) will thank you. Stop polling. Start observing.


