Skip to content
Select theme

How to watch files for changes

Short answer: The notify crate watches a path and sends events over a channel. Expect a burst per save, not one event: editors that save atomically write a temp file and rename it over the target, and the kernel splits even a plain overwrite into several events. Wait for the burst to go quiet, then act once.

Start from a fresh project — cargo new watch-demo, put the crate in Cargo.toml:

Cargo.toml
[dependencies]
notify = "8.2.0"

Replace the contents of src/main.rs with this and cargo run. It watches a file in your temp directory, overwrites it once, and prints every event that arrives within a second of the write:

use notify::{RecursiveMode, Watcher};
use std::sync::mpsc;
use std::time::Duration;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let path = std::env::temp_dir().join("watched-notes.txt");
let _ = std::fs::remove_file(&path); // so a rerun starts clean
std::fs::write(&path, b"start")?; // the path must exist before watch()
std::thread::sleep(Duration::from_millis(300)); // let the write settle
let (tx, rx) = mpsc::channel();
let mut watcher = notify::recommended_watcher(tx)?;
watcher.watch(&path, RecursiveMode::NonRecursive)?;
std::thread::sleep(Duration::from_millis(300));
std::fs::write(&path, b"changed")?; // one write
while let Ok(res) = rx.recv_timeout(Duration::from_secs(1)) {
println!("{:?}", res?.kind);
}
Ok(())
}
Create(File)
Modify(Metadata(Any))
Modify(Metadata(Extended))
Modify(Data(Content))

Two things bite everyone on the first run. watch is a method on the Watcher trait, so without use notify::Watcher; the compiler suggests unwatch instead. And the path has to exist — watch a missing file and watch() fails with Error { kind: PathNotFound, paths: ["/var/folders/…/watched-notes.txt"] }, it doesn’t wait for the file to appear.

Then the surprise the output is showing: one fs::write produced several events, and the list isn’t a contract. On this macOS machine recommended_watcher is FSEvents (Linux gets inotify, Windows ReadDirectoryChangesW), and it aggregates — run the program again and the same write can arrive as fewer events, with or without the Create. What is reliable is that a save is never just one event. If you match on event.paths, FSEvents also hands you the resolved path: /private/var/folders/… for a file you watched as /var/folders/….

An editor that writes the target in place has a problem: crash mid-write and the file is gone. So many editors write a sibling temp file and rename it over the target — two filesystem operations, each of which the kernel reports. Watch it happen:

use notify::{RecursiveMode, Watcher};
use std::path::Path;
use std::sync::mpsc;
use std::time::Duration;
fn editor_save(target: &Path) -> std::io::Result<()> {
let tmp = target.with_extension("txt.tmp");
std::fs::write(&tmp, b"saved by editor")?; // write a temp file beside it...
std::fs::rename(&tmp, target) // ...and rename it over the target
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let path = std::env::temp_dir().join("saved-notes.txt");
let _ = std::fs::remove_file(&path);
std::fs::write(&path, b"start")?;
std::thread::sleep(Duration::from_millis(300));
let (tx, rx) = mpsc::channel();
let mut watcher = notify::recommended_watcher(tx)?;
watcher.watch(&path, RecursiveMode::NonRecursive)?;
std::thread::sleep(Duration::from_millis(300));
println!("-- one fs::write --");
std::fs::write(&path, b"changed")?;
std::thread::sleep(Duration::from_millis(600));
while let Ok(res) = rx.try_recv() {
println!("{:?}", res?.kind);
}
println!("-- one editor save --");
editor_save(&path)?;
std::thread::sleep(Duration::from_millis(600));
while let Ok(res) = rx.try_recv() {
println!("{:?}", res?.kind);
}
Ok(())
}
-- one fs::write --
Create(File)
Modify(Metadata(Any))
Modify(Metadata(Extended))
Modify(Data(Content))
-- one editor save --
Modify(Name(Any))
Create(File)
Modify(Name(Any))
Modify(Metadata(Any))
Modify(Metadata(Extended))
Modify(Data(Content))

The watcher survives the rename. It watches the path, not the open file, so the next save arrives too — this is why the problem repeats on every save rather than going quiet after the first one.

The fix is to stop treating events as commands. Collect them until the stream has been quiet for a moment, then act once — rebuild, re-upload, re-index, whatever the save was for:

use notify::{RecursiveMode, Watcher};
use std::path::Path;
use std::sync::mpsc::{self, Receiver};
use std::time::Duration;
fn editor_save(target: &Path) -> std::io::Result<()> {
let tmp = target.with_extension("txt.tmp");
std::fs::write(&tmp, b"saved by editor")?;
std::fs::rename(&tmp, target)
}
/// Count events until `quiet` passes with nothing new; the burst is over.
fn wait_for_quiet(
rx: &Receiver<Result<notify::Event, notify::Error>>,
quiet: Duration,
) -> Result<usize, Box<dyn std::error::Error>> {
let mut count = 0;
loop {
match rx.recv_timeout(quiet) {
Ok(_) => count += 1,
Err(_) => return Ok(count),
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let path = std::env::temp_dir().join("rebuilt-notes.txt");
let _ = std::fs::remove_file(&path);
std::fs::write(&path, b"start")?;
std::thread::sleep(Duration::from_millis(300));
let (tx, rx) = mpsc::channel();
let mut watcher = notify::recommended_watcher(tx)?;
watcher.watch(&path, RecursiveMode::NonRecursive)?;
std::thread::sleep(Duration::from_millis(300));
for save in 1..=2 {
editor_save(&path)?;
let raw = wait_for_quiet(&rx, Duration::from_millis(500))?;
println!("save {save}: {raw} raw events -> one rebuild");
std::thread::sleep(Duration::from_millis(400));
}
Ok(())
}
save 1: 5 raw events -> one rebuild
save 2: 2 raw events -> one rebuild

The counts are whatever the kernel handed over on this run — the first save creates the temp file, the second only overwrites it, so the bursts differ. The fixed point is on the right of the arrow: one action per save, however many events it took. How long to wait is a trade-off; 500 ms suits a demo, a file sync might want seconds. If you’d rather not hand-roll it, notify-debouncer-full (checked against 0.7.0) wraps the same watcher: new_debouncer(Duration::from_millis(200), None, tx)?, then debouncer.watch(...) like any other Watcher, and each quiet window arrives as one Vec<DebouncedEvent>.