DEV Community

Cover image for From chaos to signal: Taming high-frequency OS events
Asoseil
Asoseil

Posted on

From chaos to signal: Taming high-frequency OS events

If you have ever built a hot-reloader, a build tool, or a file sync agent in Go using raw fsnotify or syscall events, you have definitely encountered the "double-fire" problem.

You save a file once.
Your terminal goes wild:

EVENT: "/src/main.go" [CHMOD]
EVENT: "/src/main.go" [RENAME]
EVENT: "/src/main.go" [WRITE]
EVENT: "/src/main.go" [WRITE]
Enter fullscreen mode Exit fullscreen mode

Your build triggers four times, your CPU spikes, logs scroll too fast to be read, and it's really frustrating.

This isn't a bug in the code; it isn't even a bug in the library; it is fundamentally how operating systems work. But for a developer experience tool, "technically correct" feels broken.

In this article, I will show you how I fixed this behaviour in sgtdi/fswatcher using Debouncing and Smart filtering, so you can build tools that feel instant and solid.

Raw events vs. User intent

Operating systems don't care about your "Save" intent. They report atomic file system operations, and when you hit Ctrl+S in your IDE, the editor doesn't just open the file and write bytes, cause to ensure data safety, it often performs a "Safe Write" dance:

  1. Create a temporary file
  2. Write content to the temporary file
  3. Delete the original file
  4. Rename the temp file to the original name
  5. Chmod to restore permissions

To the OS, this looks like a flurry of creation, deletion, renaming, and attribute modification events, but to the final user, it's just one "Save".

If your application reacts to every single one of these events, you are wasting resources. Worse, you might try to read the file in the nanoseconds between the "Delete" and the "Rename", causing your build tool to crash with a file not found error.

Event Merging and Throttling

I want my tools to be snappy and waiting for a "quiet period" (trailing edge debouncing) introduces lag. Instead, sgtdi/fswatcher uses a Leading edge approach with event merging.

The debouncer acts as a gatekeeper; it groups events by Path and ensures that it can act immediately on the first sign of activity, suppressing the subsequent "echoes" caused by the OS.

The engine room

Here is how I implemented this in sgtdi/fswatcher. The secret sauce is the debouncer struct, which maintains a short-term memory of file activity.

I use a map called lastSeen to track the most recent event for every file path. This allows me to throttle events on a per-file basis—so a busy log file doesn't block the rest of the project. A sync.Mutex is essential here because file system events arrive concurrently from multiple goroutines.

type debouncer struct {
    lastSeen map[string]WatchEvent
    cooldown time.Duration
    mu       sync.Mutex
}
Enter fullscreen mode Exit fullscreen mode

The logic for deciding whether to emit an event lives in ShouldProcess. When a new event arrives, I lock the mutex to ensure safety.

I check lastSeen to see if this file has been active recently. If I find an existing entry and the new event arrived within the cooldown window, I treat it as "noise" or an OS echo. Instead of emitting it, I merge it into the previous event (combining flags like Write + Chmod) and suppress it.

If the file is new or the cooldown has expired, I update the record and let the event pass through immediately. This ensures the user sees the result of their action instantly.

func (d *debouncer) ShouldProcess(ev WatchEvent) (WatchEvent, bool) {
    d.mu.Lock()
    defer d.mu.Unlock()

    last, exists := d.lastSeen[ev.Path]
    if exists {
        if time.Since(last.Time) < d.cooldown {
            merged := mergeEvents(last, ev)
            d.lastSeen[ev.Path] = merged
            return merged, false 
        }
    }

    d.lastSeen[ev.Path] = ev
    return ev, true
}
Enter fullscreen mode Exit fullscreen mode

This simple pattern is incredibly powerful cause It converts the chaotic stream of OS noise into a singular, meaningful signal.

Using it in FSWatcher

When you initialize the library, you just pass the WithCooldown option and the library handles the concurrency complexity for you.

w, _ := fswatcher.New(
    fswatcher.WithPath("./src"),
    fswatcher.WithCooldown(100 * time.Millisecond), // The magic number
)
Enter fullscreen mode Exit fullscreen mode

Now, that flurry of 4-5 events becomes exactly one EventMod (Modify) event.

Ignoring the noise with smart filtering

Debouncing handles duplicate events, but what about useless ones?

If you are watching a project root, you rarely want to trigger a rebuild when:

  • .git/ internal files change (index, HEAD updates).
  • node_modules gets updated by npm install.
  • OS junk files like .DS_Store or thumbs.db appear.
  • Your own build output directory (e.g., dist/ or bin/) changes.

A naive watcher forces you to write a giant if statement in your main event loop:

// The "Naive" Approach
for event := range watcher.Events {
    if strings.Contains(event.Name, ".git") { continue }
    if strings.Contains(event.Name, "node_modules") { continue }
    if strings.HasSuffix(event.Name, ".DS_Store") { continue }

    // finally process...
}
Enter fullscreen mode Exit fullscreen mode

This is tedious and error-prone.

Platform-wware filtering

To solve this elegantly, I need to respect platform conventions.

First, let's handle System Artifacts. Your OS leaves breadcrumbs everywhere—files like .DS_Store on macOS, or .swp files from text editors. By maintaining a list of these known prefixes and suffixes, I can identify and discard them before they ever reach your logic.

// platform_darwin.go
var osPrefixes = []string{"~", "._"}
var osSuffixes = []string{".DS_Store", ".swp"}

func isSystemFile(path string) bool {
    base := filepath.Base(path)

    for _, p := range osPrefixes {
        if strings.HasPrefix(base, p) { return true }
    }
    for _, s := range osSuffixes {
        if strings.HasSuffix(base, s) { return true }
    }
    return false
}
Enter fullscreen mode Exit fullscreen mode

Next, I handle Hidden Files. In the Unix tradition, any file or directory starting with a dot . is considered hidden. These often contain configuration or version control data (like .git) that you typically want to ignore by default.

func isHidden(path string) bool {
    return strings.HasPrefix(filepath.Base(path), ".")
}
Enter fullscreen mode Exit fullscreen mode

The fswatcher approach

sgtdi/fswatcher splits the responsibility to keep configuration clean:

  1. System Filters: The library automatically handles the OS noise using the logic above. You don't need to configure it; it just works.
  2. Regex Filters: I leave the project-specific choices to you. Since generic hidden files (like .git) are not auto-excluded (you might want to watch them!), you use WithExcRegex to define what matters for your specific project.
w, _ := fswatcher.New(
    fswatcher.WithPath("."),
    // Exclude .git (hidden files aren't auto-ignored) and build artifacts
    fswatcher.WithExcRegex(".*\\.git.*", ".*node_modules.*", ".*dist.*"),
    // Only explicitly include source files
    fswatcher.WithIncRegex(".*\\.go$", ".*\\.html$"), 
)
Enter fullscreen mode Exit fullscreen mode

This approach gives you a clean stream of events that actually represent user changes, not system noise.

Handling raw file system events is a trap. It leads to buggy, resource-intensive tools that frustrate users. By decoupling the OS event (what happened technically) from the User intent (what changed meaningfully), we can build tools that feel snappy, responsive, and polished.

The combination of smart event throttling and robust regex filtering transforms a chaotic stream of system calls into a clean stream of actionable events.

Check out the full implementation and try it in your next Go project: sgtdi/fswatcher

Top comments (0)