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
}
}
package watch
import (
"log"
"path/filepath"
"strings"
"github.com/fsnotify/fsnotify"
)
func Watch(dir string, deb *Debouncer) (*fsnotify.Watcher, error) {
w, err := fsnotify.NewWatcher()
if err != nil {
return nil, err
}
if err := w.Add(dir); err != nil {
w.Close()
return nil, err
}
go func() {
for {
select {
case ev, ok := <-w.Events:
if !ok {
return
}
if isRelevant(ev) {
deb.Trigger()
}
case err, ok := <-w.Errors:
if !ok {
return
}
log.Printf("watch error: %v", err)
}
}
}()
return w, nil
}
func isRelevant(ev fsnotify.Event) bool {
// Ignore pure permission-change noise from editors.
if ev.Op == fsnotify.Chmod {
return false
}
base := filepath.Base(ev.Name)
if strings.HasPrefix(base, ".") || strings.HasSuffix(base, "~") {
return false
}
return true
}
package main
import (
"log"
"os"
"os/exec"
"os/signal"
"syscall"
"time"
"example.com/reload/watch"
)
func runBuild() {
log.Println("change detected, rebuilding...")
cmd := exec.Command("go", "build", "./...")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
log.Printf("build failed: %v", err)
return
}
log.Println("build ok")
}
func main() {
dir := "."
if len(os.Args) > 1 {
dir = os.Args[1]
}
deb := watch.NewDebouncer(500*time.Millisecond, runBuild)
w, err := watch.Watch(dir, deb)
if err != nil {
log.Fatalf("cannot watch %s: %v", dir, err)
}
log.Printf("watching %s (debounced)", dir)
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
deb.Stop()
w.Close()
log.Println("shutting down")
}
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
use crossbeam::channel::unbounded;
use std::thread;
fn main() {
let (tx, rx) = unbounded();
Crossbeam for advanced concurrent data structures
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous JavaScript
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
Channels (mpsc) for message passing between threads
export type Settled<R> =
| { status: 'fulfilled'; value: R }
| { status: 'rejected'; reason: unknown };
export interface ConcurrencyOptions {
limit: number;
Simple concurrency limiter for batch operations
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form"]
static values = { delay: { type: Number, default: 250 } }
Debounced live search with Stimulus + Turbo Streams
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
url: String,
delay: { type: Number, default: 800 },
Stimulus: autosave draft with Turbo-friendly requests
Share this code
Here's the card — post it anywhere.