go 141 lines · 3 tabs

Debounce Filesystem Change Events Before Triggering a Rebuild in Go

Shared by codesnips Jul 2026
3 tabs
package watch

import (
	"sync"
	"time"
)

type Debouncer struct {
	delay time.Duration
	fn    func()

	mu    sync.Mutex
	timer *time.Timer
}

func NewDebouncer(delay time.Duration, fn func()) *Debouncer {
	return &Debouncer{delay: delay, fn: fn}
}

// Trigger schedules fn to run after the quiet period. Each call pushes
// the deadline out, collapsing a burst of events into a single run.
func (d *Debouncer) Trigger() {
	d.mu.Lock()
	defer d.mu.Unlock()

	if d.timer == nil {
		d.timer = time.AfterFunc(d.delay, d.fn)
		return
	}
	d.timer.Reset(d.delay)
}

func (d *Debouncer) Stop() {
	d.mu.Lock()
	defer d.mu.Unlock()

	if d.timer != nil {
		d.timer.Stop()
		d.timer = nil
	}
}
3 files · go Explain with highlit

A file watcher that triggers a rebuild on every raw filesystem event is a common source of wasted work: editors emit multiple WRITE, CREATE, and CHMOD events for a single save, and tools like gofmt-on-save or bulk git checkout can fire hundreds of events in a burst. This snippet shows a small, self-contained debouncer that collapses those bursts into a single rebuild, using fsnotify for the raw events and a resettable timer for the quiet-period logic.

In debouncer.go, Debouncer wraps a time.Timer and a sync.Mutex. The core idea is the trailing debounce: Trigger is called on every incoming event, and each call either creates the timer or calls timer.Reset(d) to push the fire time further into the future. The callback only runs once no new event has arrived for the whole delay window, so a rapid burst produces exactly one invocation. The mutex guards the timer field because Trigger may be called concurrently from the watcher goroutine while Stop runs from shutdown. Stop drains a pending timer so a rebuild does not fire after the process is being torn down.

watcher.go ties the debouncer to real events. Watch opens an fsnotify.Watcher, adds the target directory, and runs an event loop in a goroutine. Every relevant event calls deb.Trigger, which is where the batching happens. isRelevant filters out noise: CHMOD-only events are ignored because editors emit them spuriously, and a helper skips hidden dotfiles and temp files so swap files do not spin the loop. The Errors channel is logged rather than fatal, since transient watcher errors should not kill the build loop.

main.go wires it together: it constructs a Debouncer whose callback runs runBuild, starts the watcher, and blocks on a signal channel for clean shutdown. The chosen 500ms window is a trade-off — long enough to absorb an editor's multi-event save, short enough to feel instant. One pitfall worth noting: this is a trailing-only debounce, so the very first change also waits the full delay; a leading-edge variant would fire immediately then suppress. Recursive directory watching is also intentionally out of scope, since fsnotify watches a single directory per Add call.


Related snips

Share this code

Here's the card — post it anywhere.

Debounce Filesystem Change Events Before Triggering a Rebuild in Go — share card
Link copied