rust 114 lines · 3 tabs

Debouncing Rapid Filesystem Events in a Rust Directory Watcher

Shared by codesnips Jul 2026
3 tabs
use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher};
use std::path::{Path, PathBuf};
use std::sync::mpsc;

pub fn spawn_watcher(root: &Path) -> notify::Result<(RecommendedWatcher, flume::Receiver<PathBuf>)> {
    let (raw_tx, raw_rx) = mpsc::channel::<notify::Result<Event>>();
    let (path_tx, path_rx) = flume::unbounded::<PathBuf>();

    let mut watcher = notify::recommended_watcher(move |res| {
        let _ = raw_tx.send(res);
    })?;
    watcher.watch(root, RecursiveMode::Recursive)?;

    std::thread::spawn(move || {
        for res in raw_rx {
            let event = match res {
                Ok(ev) => ev,
                Err(err) => {
                    eprintln!("watch error: {err}");
                    continue;
                }
            };
            for path in event.paths {
                if path_tx.send(path).is_err() {
                    return; // consumer dropped
                }
            }
        }
    });

    Ok((watcher, path_rx))
}
3 files · rust Explain with highlit

A naive directory watcher fires an event for every low-level filesystem operation, and a single editor save can produce a burst of Create, Modify, and Rename events within milliseconds. Rebuilding or reloading on each raw event wastes work and can trip over half-written files. The pattern shown here collapses these bursts into one settled event per path using a trailing debounce: after activity on a path stops for a quiet window, the path is reported once.

In watcher.rs, spawn_watcher wires the notify crate to a std::sync::mpsc channel and forwards raw events into an application-level flume channel of PathBuf values. The notify recommended watcher runs on its own thread, so the raw sender is simply cloned into the closure. Extracting a PathBuf from each Event keeps the downstream stage path-oriented rather than event-oriented, which is what most reload logic actually cares about.

The heart of the technique lives in debouncer.rs. Debouncer::run keeps a HashMap<PathBuf, Instant> mapping each dirty path to the last time it changed. It uses recv_timeout so the loop wakes either when a new event arrives or when the quiet window may have elapsed. On each new path the deadline is refreshed to now + quiet_window, which is what makes this a trailing debounce: a path that keeps changing is never emitted until it goes quiet. flush_ready then drains any paths whose deadline has passed and forwards them to the settled channel.

A subtle detail is next_wakeup: rather than polling at a fixed interval, it computes the soonest pending deadline so the thread sleeps exactly as long as needed, staying responsive without busy-waiting. When no paths are pending it blocks indefinitely until the next event.

main.rs composes the two stages and consumes settled paths. The trade-off is added latency equal to the quiet window, and coalescing means intermediate states are lost — acceptable for reload/rebuild triggers but wrong if every discrete change must be observed. Choosing quiet_window balances responsiveness against how aggressively bursts are merged.


Related snips

Share this code

Here's the card — post it anywhere.

Debouncing Rapid Filesystem Events in a Rust Directory Watcher — share card
Link copied