Ever wondered how hot-reload tools actually detect when you save a file? I did too. So instead of just using an existing library, I spent weeks building one from scratch.
This is the story of FSWatcher, a cross-platform file system watcher for Go that taught me more about operating systems and concurrency than any tutorial ever could.
The problem that started everything
A while ago, I had built a simple hot-reload tool for a Go project, it worked great using fsnotify, but I had no idea what was happening under the hood. When I save a file in VSCode, how does my computer actually know that something changed?
That curiosity led me down a rabbit hole that became FSWatcher.
What makes file watching so complex?
Here's the thing: each operating system handles file monitoring completely differently.
macOS: FSEvents
On macOS, you don't watch individual files, you watch entire directory trees. Apple's FSEvents API is efficient and event-driven, but it floods you with information you don't always need.
// macOS watches directories, not files
// On file change return an event like:
"something changed in /Users/you/project/file.txt"
// But return also events about temporary files:
"something changed in /Users/you/project/file.txt~"
// And also many events for each action on the file, like:
"something changed in /Users/you/project/file.txt~" CHMOD
"something changed in /Users/you/project/file.txt~" WRITE
//and so on
Linux: inotify
Linux's inotify is more granular; you can watch specific files, but it's also more fragile. Each watch consumes a file descriptor, and on a large project, you can easily hit system limits.
// Linux is precise but limited
// Each file watch = one file descriptor
// Large projects = potential resource exhaustion due to max limits
Windows: ReadDirectoryChangesW
Windows uses asynchronous I/O with overlapping reads. It's fast, but requires careful coordination of buffers and synchronization to avoid losing events.
// Windows is async-first
// Requires buffer management and careful synchronization
The architecture challenge
The first version of FSWatcher was a mess. I had goroutines everywhere, different code paths for each OS, and events that didn't match across platforms.
The breakthrough came when I realized I needed a unified pipeline:
OS Events → Normalization → Debouncing → Batching → Clean Events
Normalization
Convert platform-specific events into a consistent format
Debouncing
When you save a file in VSCode or another editor, it might trigger 3-5 events in quick succession. Debouncing merges them into one:
// Instead of:
// Event: file.go changed
// Event: file.go changed
// Event: file.go changed
// You get a single event:
// Event: file.go changed
Batching
When there are many changes with different operations like WRITE, CREATED, CHMOD, and so on, the batch is released as a single WatchEvent.
// Instead of many individual events
// You get a single event with the multiple operations
Real problems, real solutions
None of these features was planned, they all came from actual pain points:
Problem: VSCode saves trigger 5 events per file
Solution: Debouncing with configurable cooldown
Problem: npm install creates 10,000+ events
Solution: Batching system to group related changes
Problem: I don't care about .git or node_modules changes
Solution: Regex filtering (include/exclude patterns)
Testing across platforms
After many tests, I also realized that manual testing wasn't sustainable. So i built a GitHub Actions pipeline that runs automated tests on macOS, Linux, and Windows on every commit.
yamlstrategy:
matrix:
os: [macos-latest, ubuntu-latest, windows-latest]
This caught race conditions and edge cases I would have never found manually or with a Docker emulation of the os
How to use FSWatcher
After all that complexity, the API is intentionally simple:
package main
import (
"context"
"fmt"
"github.com/sgtdi/fswatcher"
)
func main() {
// Create watcher for current directory
fsw, err := fswatcher.New()
if err != nil {
panic(err)
}
// Start watching
ctx := context.Background()
go fsw.Watch(ctx)
// Handle events
for event := range fsw.Events() {
fmt.Printf("File changed: %s\n", event.Path)
}
}
That's it. No dependencies, no complex configuration. Just clean events. Obviously, there are a lot of additional options to personalize the workflow, but by default, it can be used with very few lines of code.
What I learned
Simplicity is hard: making something simple to use often means handling massive complexity internally
Test everything: Cross-platform code needs automated testing across all platforms
Real problems > Planned features
Every feature in FSWatcher came from actual friction. Go's concurrency model is powerful, and Goroutines made managing multiple OS backends possible
Try it yourself
FSWatcher is open source and ready to use:
go get github.com/sgtdi/fswatcher
GitHub Repository: github.com/sgtdi/fswatcher
I also wrote a deep dive article on Medium explaining the OS-level details and implementation challenges: Read the full story
What's Next?
Right now I'm actively working on:
- Performance benchmarks vs other watchers
- More examples and use cases
- Additional filtering options
If you find it useful, star the repo! And if you find bugs or have ideas, issues and PRs are welcome.
Top comments (0)