The file system rabbit hole: Building FSWatcher in Go


A deep dive into how file watchers work across macOS, Linux, and Windows, and how FSWatcher unifies them under one simple library.

Some time ago, I built a small internal Golang tool, a simple hot-reload system in Go that automatically rebuilt and restarted the app whenever a file changed. It was a quick, practical solution powered by fsnotify, and it worked well for what I needed at the time.

When I revisited it later, I realized I didn’t just want to use a watcher; I wanted to understand it at a lower level. How do macOS, Linux, and Windows actually detect file changes? What are the native mechanisms behind them? And how much control could I gain by going a level deeper?

That’s where the current project began, the journey that became FSWatcher: a cross-platform, concurrent, and dependency-free file system watcher for Go, built from the ground up to learn, optimize, and unify.



Building a cross-platform watcher

What looks like a simple “file change detector” is, in reality, a jungle of different APIs, system calls, and assumptions.
Each operating system handles file monitoring in its own way, reflecting its unique design philosophy, and that’s what makes building a truly cross-platform watcher both fascinating and challenging.

On macOS, the FSEvents API doesn’t track single files but directory trees. It’s efficient, event-driven, and low on CPU, but it often floods you with redundant data you need to filter.

On Linux, inotify is more granular but also more fragile, limited by file descriptors and prone to flooding when large repositories change. Handling that safely meant designing a dynamic, memory-efficient system to track and coalesce thousands of low-level events.

On Windows, ReadDirectoryChangesW offers asynchronous I/O and overlapping reads. It’s fast, but requires careful coordination of buffers and synchronization primitives to avoid blocking or lost signals.

The first iterations of FSWatcher were messy — just me experimenting with wrappers, bridges, and goroutines everywhere. But over time, the architecture became clear:
I needed a consistent pipeline that could normalize all these OS-level differences into a single, predictable flow of clean events.



The challenges behind the scenes

Even small differences in how events are triggered or grouped can break assumptions at scale.

For example, saving a file on macOS triggers a directory-level update. On Linux, it might emit three file-specific events. On Windows, it could delay and batch them.
Aligning those requires constant testing, fine-tuning, and iteration.

To handle this, I implemented a debounce system that merges rapid bursts of identical events into a single coherent update — keeping it responsive without spamming the consumer.
Then came batching, which groups multiple related updates into one message, so file trees with thousands of edits can be handled efficiently.

And because manual testing wasn’t sustainable, I built a GitHub Actions pipeline to run automated tests across macOS, Linux, and Windows on every commit.

Each CI run validates:

  • Native backend consistency (FSEvents, inotify, ReadDirectoryChangesW)

  • Concurrency safety under heavy parallel load

  • Event accuracy and ordering

  • Performance under large-scale file updates

That process caught subtle race conditions, misordered events, and edge cases that would have been nearly impossible to spot manually.
It turned FSWatcher into something much more robust than an experiment.



Features are due to tests

None of FSWatcher’s features were planned from the start — they all came from real issues encountered while testing.

  • Debouncing was born from noisy save operations in editors like VSCode or GoLand. The watcher now merges repetitive events during a configurable cooldown period.

  • Batching came after testing bulk operations like git checkout or npm install, where thousands of events could arrive within seconds. The watcher can hold and release them in grouped, meaningful batches.

  • Regex filtering emerged from the need to ignore unwanted paths like build artifacts, logs, or .git directories. With include/exclude regex, it’s easy to decide exactly what matters.

  • Structured logging became essential for debugging — understanding what happened, when, and why, across multiple concurrent watchers.

Each feature reflects a lesson learned, an actual bottleneck solved. FSWatcher evolved not from design documents but from friction, real, hands-on use, and the drive to make it simpler and smarter with each iteration.



 How to use it

It’s a simple library that can be used with a few lines of code, but under the hood, a complex system of concurrent routines manages and filters os events.

go get github.com/sgtdi/fswatcher
Enter fullscreen mode

Exit fullscreen mode

package main

import (
    "context"
    "fmt"

    "github.com/sgtdi/fswatcher"
)

func main() {
    fsw, _ := fswatcher.New()
    ctx, cancel := context.WithCancel(context.Background())
    go fsw.Watch(ctx)
    // Watch for events
    for e := range fsw.Events() {
        fmt.Println(e.String())
    }
    // Wait to press Ctrl+C
    cancel()
}
Enter fullscreen mode

Exit fullscreen mode



Random thoughts

What began as a curiosity turned into one of the most rewarding technical deep dives I’ve done in Go. By stripping away abstractions and rebuilding something that “already existed,” I gained a real appreciation for the complexity of operating systems, and for how Go’s concurrency model makes bridging them possible.

FSWatcher isn’t just another library. It’s a study in how simplicity on the surface often hides a lot of engineering underneath; sometimes, the best way to understand how something works is to build it yourself, from scratch.



Source link