← Back to list

File Watchers Lie: Debounce, Throttle, and Coalescing in Build Loops

How Panex tames editor-triggered event storms without missing changes.

Naniwet M · 2026-03-08 21:30 · 0 claps · 10.6 min read
#debounce #throttle #coalescing #software-engineering #golang
Open on Medium ↗

File Watchers Lie: Debounce, Throttle, and Coalescing in the Real World

How Panex turns noisy editor save bursts into one correct rebuild and reload.

Panex is a local dev runtime for Chrome extensions: it runs an agent alongside your extension, streams events over a typed protocol, and powers an Inspector UI for debugging and time travel.

Hit save in your editor. One keystroke. How many filesystem events does that produce?

If you said one, you are wrong, and that assumption might cost you redundant builds, phantom reloads, and the kind of flaky behavior that makes developers distrust their own tools.

We hit this problem early in Panex. The daemon watches your extension source directory and triggers an esbuild bundle on every change. Without intervention, a single Ctrl+S in VS Code triggered between 4 and 12 fsnotify events in our measurements - writes, renames, temporary files, metadata flushes - all within a 20ms window. Each one would kick off a full rebuild. On a project with 30 source files, that meant up to 12 redundant esbuild invocations before the first one even finished writing output.

I am writing this to share the invariants we needed to protect, the options we considered, and the debounce design we landed on. The constraints are specific to Panex, but the problem is universal. If you consume filesystem events in any build tool, dev server, or test runner, you’re dealing with this whether you’ve noticed it or not.

The problem, precisely

File watcher libraries like fsnotify (Go), chokidar (Node), and inotify (Linux) report low-level filesystem operations. They do not report developer intent. When a developer saves a file, the editor might:

  1. Write to a temporary file in the same directory
  2. Rename the temporary file over the original (atomic save)
  3. Update file metadata (permissions, timestamps)
  4. Trigger an .swp or backup file write
  5. Emit a directory modification event for the parent

Panex’s watcher filters this down -isRelevantFileEvent only passes CREATE, WRITE, REMOVE, and RENAME. CHMOD and other metadata-only operations are dropped at the boundary. But that still leaves 3-4 events per save that need coalescing.

VS Code’s atomic save path alone produces a *CREATE for the temp file, a `RENAME*to swap it in, and often a follow-upWRITE` , those are three events minimum for one logical save. Vim with *backupcopy=auto* can produce five. JetBrains IDEs emit a safe-write sequence that generates yet another pattern.

None of these are bugs. The editors are doing exactly what they should for data safety. But every downstream consumer of these events has to answer the same question: what constitutes a single “change” that I should react to?

Before discussing strategies like debounce or throttle involved in file mutations, it helps to look at what actually happens on disk when a developer presses save in Panex.

Fig 1. A single Ctrl+S does not arrive as a single event, it appears as a short burst of raw filesystem operations that the watcher must interpret as one developer-intended save. Illustration by Naniwet M

Fig 1. A single Ctrl+S does not arrive as a single event, it appears as a short burst of raw filesystem operations that the watcher must interpret as one developer-intended save. Illustration by Naniwet M

What we needed to protect

Before reaching for a solution, we wrote down what must remain true regardless of implementation:

Invariant 1: No change is silently dropped. If a developer saves a file and the watcher is running, that file must appear in the next change batch. Missing a real change is worse than processing a duplicate.

Invariant 2: Downstream consumers receive one batch per logical change. The build loop should fire once per save, not once per filesystem event. Redundant builds waste time and, worse, can produce overlapping output writes.

Invariant 3: Paths are platform-stable. The watcher runs on macOS, Linux, and Windows. The paths it emits flow into protocol messages (build.complete payloads carry changed_files). If those paths carry OS-specific separators or absolute prefixes, every downstream consumer inherits a platform coupling.

Invariant 4: New directories are watched without restart. Chrome extension projects create directories at runtime - build caches, generated code, new feature folders. If a developer creates src/components/ and immediately adds a file inside it, the watcher must see that file.

Three strategies, one constraint

There are three common approaches to taming event noise. Each makes a different tradeoff, and the right choice depends on which invariant you prioritize.

Throttle (fixed-rate sampling). Emit at most one batch every *n* milliseconds, regardless of event volume. Simple, but it introduces mandatory latency even when there’s only one event. In Panex, a dev tool where perceived responsiveness matters, adding 200ms of dead time to every single-file save felt wrong.

Debounce (wait for silence). Collect events, and only emit once *n*milliseconds pass with no new events. This naturally groups burst writes into a single batch. The risk with this is that, if events never stop arriving, lets say a large git checkout, a running code generator), the batch never fires, so for that, you need a ceiling.

Coalesce (semantic grouping). Track file identity and only emit when a file reaches a “stable” state, for example, wait until a file’s size stops changing. This is the most correct approach in theory, but it requires per-file state machines, platform-specific heuristics for what “stable” means, and it’s dramatically harder to test. watchman from Meta does this. It's a 50,000-line C++ project.

Fig 2 (GIF). Throttle emits on a fixed schedule, debounce emits after a quiet period, and coalescing waits for file stability. Illustration by Naniwet M

Fig 2 (GIF). Throttle emits on a fixed schedule, debounce emits after a quiet period, and coalescing waits for file stability. Illustration by Naniwet M

We chose debounce. The reasoning was that our primary consumer is an esbuild invocation that takes 10–50ms. A 50ms debounce window adds negligible latency to the save-to-reload loop while collapsing the vast majority of editor write patterns into a single batch. The failure mode (a never-ending stream delays the batch) is rare in practice and bounded by the fact that esbuild builds are fast enough that even a slightly late batch is still responsive.

The design

Here’s the Panex state machine. It has two states and three triggers: a filesystem event, the debounce timer firing, and context cancellation.

Fig 3 (GIF). The debounce state machine inside Panex’s FileWatcher. Two states (IDLE, COLLECTING), four transitions, one non-negotiable guarantee: pending changes are flushed on shutdown, never discarded. The timer resets on every incoming fsnotify event, so burst writes collapse into a single batch. When the 50ms silence window completes, the watcher emits a sorted, deduplicated path set to the build loop. Implementation: internal/daemon/file_watcher.go:63‑149. Illustration by Naniwet M (Correction: The subline under header says three states, that should be two states)

Fig 3 (GIF). The debounce state machine inside Panex’s FileWatcher. Two states (IDLE, COLLECTING), four transitions, one non-negotiable guarantee: pending changes are flushed on shutdown, never discarded. The timer resets on every incoming fsnotify event, so burst writes collapse into a single batch. When the 50ms silence window completes, the watcher emits a sorted, deduplicated path set to the build loop. Implementation: internal/daemon/file_watcher.go:63‑149. Illustration by Naniwet M (Correction: The subline under header says three states, that should be two states)

The pending set is a map[string]struct{}-paths are deduplicated by identity, so 12 events for src/index.ts produce one entry. On flush, we sort the paths for deterministic output. Determinism matters because these paths end up in protocol messages and test assertions; non-deterministic ordering makes both harder.

const DefaultWatchDebounce = 50 * time.Millisecond
type FileChangeEvent struct {
    Paths      []string
    OccurredAt time.Time
}

The FileChangeEvent carries the coalesced paths and a timestamp. The timestamp is assigned at flush time, not at individual event time - because the consumer cares about when the batch was ready, not when the first filesystem event arrived.

Path normalization

Every path that enters the pending set goes through normalization:

func (w *FileWatcher) normalizePath(path string) (string, error) {
    absPath, err := filepath.Abs(path)
    if err != nil {
        return "", err
    }

    relPath, err := filepath.Rel(w.root, absPath)
    if err != nil {
        return "", err
    }
    if relPath == "." {
        return "", errors.New("root-level directory event")
    }
    if strings.HasPrefix(relPath, ".."+string(filepath.Separator)) || relPath == ".." {
        return "", errors.New("event path is outside watch root")
    }

    return filepath.ToSlash(filepath.Clean(relPath)), nil
}

This does four things:

  1. Converts to an absolute path (handles relative paths from different working directories).
  2. Makes it relative to the watch root (so src/index.ts stays src/index.ts regardless of where the daemon runs from).
  3. Rejects paths outside the watch root (guards against symlink traversal or confused watcher state).
  4. Normalizes to forward slashes (filepath.ToSlash) so protocol payloads are identical on Windows and Linux.

That last point is easy to miss. Without it, a build.complete event on Windows carries src\components\Button.tsx while the same event on macOS carries src/components/Button.tsx. Any client that does string matching on changed files - the Inspector's filter, for instance - breaks silently.

Recursive directory watching

fsnotify does not recurse into subdirectories. You register each directory individually. When the watcher starts, we walk the entire source tree:

func (w *FileWatcher) addDirectoryTree(watcher *fsnotify.Watcher, root string) error {
    return filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
        if err != nil {
            return err
        }
        if !entry.IsDir() {
            return nil
        }
        return watcher.Add(path)
    })
}

But that only covers directories that exist at startup. When a CREATE event fires and the target is a directory, we recursively add it:

if event.Op&fsnotify.Create != 0 {
    if info, statErr := os.Stat(event.Name); statErr == nil && info.IsDir() {
        if err := w.addDirectoryTree(watcher, event.Name); err != nil {
            return err
        }
    }
}

There’s a race here that’s worth acknowledging, that is, between the CREATE event and the Stat call, the directory could be deleted. We handle this by checking the Stat error. In practice, this race matters for tools that create and immediately delete temporary directories. There are some build tools that do this. For Panex's use case - watching developer-authored source trees - it hasn't been a problem so far.

The flush-on-cancel contract

When the context is canceled (daemon shutting down), we flush any pending paths immediately rather than discarding them:

case <-ctx.Done():
    if timer != nil && !timer.Stop() {
        select {
        case <-timer.C:
        default:
        }
    }
    flush()
    return nil

This protects Invariant 1 (Invariant 1: No change is silently dropped). Without it, a developer who saves a file and immediately hits Ctrl+C on the daemon could lose that change. The build loop won’t run (the context is canceled), but the event is emitted so the event store captures what happened. That matters for debugging: “did the watcher see my change?” is a question the Inspector timeline should always be able to answer.

How it connects to the build loop

The watcher emits FileChangeEvent values into a buffered channel. The build loop in the daemon's main goroutine consumes them:

changeEvents := make(chan daemon.FileChangeEvent, 64)
watcher, err := daemon.NewFileWatcher(
    cfg.Extension.SourceDir,
    daemon.DefaultWatchDebounce,
    func(event daemon.FileChangeEvent) {
        select {
        case changeEvents <- event:
        default:
        }
    },
)

The default case on the channel send is intentional. If the build loop falls behind, say, a build is taking unusually long and the channel fills, we drop the event rather than blocking the watcher. This is safe because the next filesystem event will still arrive and trigger a fresh batch. The alternative which is blocking the watcher goroutine would cause fsnotify to drop events at the OS level, which is much harder to reason about.

The buffer size of 64 is generous for a human typing. If a code generator is producing changes faster than the build loop can consume them, the dropped events just mean we skip intermediate states - which is exactly what you want.

Fig 4 (GIF). How Panex turns a single Ctrl+S into an extension reload. The pipeline spans four goroutines and three data transformations: raw OS events (CREATE, WRITE, REMOVE, RENAME) enter fsnotify, where CHMOD is filtered out by isRelevantFileEvent. The FileWatcher normalizes paths to relative forward-slash form, deduplicates them in a map[string]struct{}, and collapses burst writes through a 50ms debounce window before emitting a single FileChangeEvent into a cap-64 buffered channel. The build loop consumes that event, runs esbuild in-process, and broadcasts build.complete to all connected clients - including on failure, so the Inspector timeline always reflects what happened. Only on success does Panex emit command.reload, which the Dev Agent interprets as a strong signal that new artifacts are safe to load. Each boundary absorbs a distinct failure mode: the watcher absorbs OS noise, the channel absorbs build-loop backpressure (drop rather than block), and the broadcast path absorbs disconnected clients (non-fatal logging, no daemon crash). Illustration by Naniwet M

Fig 4 (GIF). How Panex turns a single Ctrl+S into an extension reload. The pipeline spans four goroutines and three data transformations: raw OS events (CREATE, WRITE, REMOVE, RENAME) enter fsnotify, where CHMOD is filtered out by isRelevantFileEvent. The FileWatcher normalizes paths to relative forward-slash form, deduplicates them in a map[string]struct{}, and collapses burst writes through a 50ms debounce window before emitting a single FileChangeEvent into a cap-64 buffered channel. The build loop consumes that event, runs esbuild in-process, and broadcasts build.complete to all connected clients - including on failure, so the Inspector timeline always reflects what happened. Only on success does Panex emit command.reload, which the Dev Agent interprets as a strong signal that new artifacts are safe to load. Each boundary absorbs a distinct failure mode: the watcher absorbs OS noise, the channel absorbs build-loop backpressure (drop rather than block), and the broadcast path absorbs disconnected clients (non-fatal logging, no daemon crash). Illustration by Naniwet M

Tests that encode the invariants

The test suite validates the four invariants directly.

Debounce batching (Invariant 2): Write the same file twice within the debounce window, assert exactly one batch:

func TestFileWatcherDebouncesRapidFileChanges(t *testing.T) {
    // Write v2, wait 10ms, write v3
    // Assert: one event with one path ("app.js")
    // Assert: no second event within 200ms
}

New directory watching (Invariant 4): Create a subdirectory, then write a file inside it, assert the file appears:

func TestFileWatcherWatchesNewDirectories(t *testing.T) {
    // Create "nested/" directory
    // Write "nested/entry.js"
    // Assert: event.Paths contains "nested/entry.js"
}

Flush on cancel (Invariant 1): Write a file during a long debounce window, cancel the context before the timer fires, assert the path still emits:

func TestFileWatcherFlushesPendingChangesOnCancel(t *testing.T) {
    // Debounce set to 500ms
    // Write file, wait 20ms, cancel context
    // Assert: event.Paths contains "index.ts"
}

One testing decision worth calling out is that we don’t mock fsnotify. The tests write real files to a real temporary directory and rely on the OS event subsystem. This makes the tests slower (~200ms each due to sleep-based synchronization) but catches platform-specific behavior that mocks would hide. On macOS, kqueue sometimes delivers two events for a single write. On Linux, inotify can coalesce rapid writes differently depending on the filesystem. Our dedup logic needs to handle both, and it can only prove that with real I/O.

What we’d change

The debounce window is hardcoded at 50ms. It works for human typing, but a code generator that writes 200 files over 2 seconds keeps resetting the decounce timer indefinitely thus producing zero batches until the stream stops, then one batch at -2050ms. A smarter design would use an adaptive window, which would like this: start at 50ms, extend to 200ms if event density exceeds a threshold, and reset after a period of silence. We haven’t needed it yet, but it’s the most likely future change.

We don’t filter by file extension. The watcher emits events for any file in the source tree — README.md, .DS_Store, editor backup files. The esbuild builder discovers entry points independently, so these extras don't cause build failures, but they do trigger unnecessary build cycles. Adding an ignore-pattern list (similar to .gitignore) would reduce noise. We deferred this because the cost of a redundant 20ms esbuild invocation is low, and premature ignore rules tend to cause issues like “why isn't my change showing up?" debugging sessions.

The channel-drop strategy is correct but invisible. When the build loop falls behind and events are dropped from the buffered channel, nothing logs it. Adding a dropped-event counter that surfaces in the Inspector would make the system more observable without changing behavior.

Takeaways

If you’re building anything that reacts to filesystem changes:

  1. Measure your editor’s event pattern before choosing a strategy. Run fsnotify (or your platform's equivalent) raw, save a file in your editor of choice, and count the events. The number will surprise you.
  2. Debounce is a correctness primitive, not just a performance optimization. Without it, you’re not just wasting CPU where you’re producing overlapping outputs, racy state, and non-deterministic behavior that’s hard to reproduce.
  3. Normalize paths at the watcher boundary. Don’t let platform-specific path separators leak into your protocol, your tests, or your logs. Do it once, at the source, and everything downstream becomes simpler.
  4. Test with real I/O. File watcher behavior varies by OS, filesystem, and editor. Mocked tests will give you false confidence. Accept the slower test and test against the real thing.
  5. Decide what happens to pending state on shutdown. This is subjective, after working with many watcher tools and building them, consider this: If your tool captures any kind of event history, flushing pending changes on shutdown is the difference between a trustworthy timeline and a timeline with gaps.

Deep Cut: Choosing debounce vs. throttle vs. coalescing by invariants

The right batching strategy depends on which failure mode is more expensive for your system:

If a missed change is catastrophic (file sync, backup tool): coalescing. You need per-file state tracking and stable-state detection. The implementation cost is high, but the correctness guarantee is strongest.

If latency must be bounded (game engine hot reload, media preview): throttle with a fixed ceiling. You accept some duplicate processing in exchange for a guaranteed maximum delay. Set the window to your build time, there’s no point reacting faster than you can rebuild.

If burst events are the primary noise source and latency is soft (build tools, test runners, dev servers): debounce. You trade variable latency (proportional to burst duration) for a clean single-batch guarantee.

The hybrid approach - debounce with a maximum wait ceiling - covers the widest range. Start the debounce timer on the first event, reset it on each subsequent event, but force a flush if the total wait exceeds a ceiling (e.g., 500ms). This prevents the “infinite stream” failure mode of pure debounce while keeping burst behavior clean.

debounce(50ms) + ceiling(500ms):
Events:  |.|.|.|.|.|................|
Timer:    ↻ ↻ ↻ ↻ ↻  → flush at 50ms after last
Total:                  ~70ms
Events:  |.|.|.|.|.|.|.|.|.|.|.|.|.|.|.|.|.   (continuous)
Ceiling:  |------ 500ms ------| → forced flush
                                ↻ new window starts

We haven’t implemented the ceiling in Panex yet because human typing patterns naturally produce gaps. But if you’re watching a directory where code generation, git operations, or CI artifacts land, the ceiling is worth adding from day one.

Fig 5. Pure debounce fails on continuous event streams: the timer resets indefinitely, producing zero batches until the stream stops. A 500ms ceiling forces periodic flushes while preserving burst-optimal behavior for normal saves. Panex uses pure debounce today (file_watcher.go:19); the ceiling is a proposed improvement for code-generator and CI-artifact workloads where event gaps never naturally occur. Illustration by Naniwet M

Fig 5. Pure debounce fails on continuous event streams: the timer resets indefinitely, producing zero batches until the stream stops. A 500ms ceiling forces periodic flushes while preserving burst-optimal behavior for normal saves. Panex uses pure debounce today (file_watcher.go:19); the ceiling is a proposed improvement for code-generator and CI-artifact workloads where event gaps never naturally occur. Illustration by Naniwet M


메타데이터
post_id
8d91cb29f712
slug
file-watchers-lie-debounce-throttle-and-coalescing-in-build-loops-8d91cb29f712
url
https://medium.com/@impactarchitecture/file-watchers-lie-debounce-throttle-and-coalescing-in-build-loops-8d91cb29f712
canonical_url
https://medium.com/@impactarchitecture/file-watchers-lie-debounce-throttle-and-coalescing-in-build-loops-8d91cb29f712
author_url
https://medium.com/@impactarchitecture
status
ok
fetched_at
2026-07-15 01:33:30