The file system speaks, but Rust doesn't listen by default
You are building a development server. You edit a configuration file, but the server keeps running with the old settings until you kill and restart it. Or you are writing a backup script that should trigger the moment a file lands in a specific folder. Polling std::fs::metadata in a tight loop works, but it burns CPU cycles and introduces latency. You need the operating system to tell you when something changes.
Rust's standard library does not include a file watcher. File system monitoring is deeply tied to the kernel. Linux uses inotify. macOS uses FSEvents. Windows uses ReadDirectoryChangesW. These APIs have different limits, different event structures, and different quirks. Wrapping them safely requires platform-specific code.
The notify crate solves this. It provides a unified API that picks the right backend for your OS. You write Rust code once, and notify handles the kernel details.
How file watching works under the hood
Think of a file watcher like a bouncer at a club. You don't stand at the door checking every person who walks by. You hire a bouncer who taps you on the shoulder when someone matching your description arrives. The bouncer works in the background. You focus on your drink, and the bouncer interrupts you only when necessary.
notify follows this pattern. When you create a watcher, it spawns a background thread. That thread registers your directory with the OS. The OS queues events into a kernel buffer. The background thread reads from that buffer and calls your callback function. Your main thread stays free to do other work.
The callback runs on the watcher thread. If you do heavy work inside the callback, you block the bouncer. The bouncer can't tap your shoulder for the next event until the current task finishes. This is the most common mistake. The callback should be fast. Send events to another thread via a channel if you need to process them.
Minimal example: watching a directory
Start with notify version 6. Add it to your Cargo.toml.
[dependencies]
notify = "6.0"
This example watches the src directory for any changes. It uses RecommendedWatcher, which selects the best backend for the current platform.
use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher};
use std::io;
use std::path::Path;
fn main() -> io::Result<()> {
// RecommendedWatcher abstracts away OS-specific backends like inotify or FSEvents.
let mut watcher = RecommendedWatcher::new(
|res| match res {
Ok(event) => println!("Event: {:?}", event),
Err(e) => eprintln!("Watch error: {:?}", e),
},
Config::default(),
)?;
// Watch the directory and all subdirectories.
watcher.watch(Path::new("src"), RecursiveMode::Recursive)?;
// Keep the main thread alive. If main exits, the watcher drops and monitoring stops.
loop {
std::thread::sleep(std::time::Duration::from_secs(1));
}
}
The RecommendedWatcher::new call takes a closure and a Config. The closure receives Result<Event, Error>. The watch method registers the path. RecursiveMode::Recursive tells the watcher to monitor subdirectories. RecursiveMode::NonRecursive watches only the top level.
The loop at the end keeps the program running. The watcher variable holds the handle to the background thread. If watcher goes out of scope, the handle drops, the background thread stops, and the watch ends.
Keep the watcher alive. If the variable drops, the watch ends.
Realistic example: processing events safely
The minimal example prints events. Real code needs to process them. Processing files takes time. If you process files inside the callback, you block the watcher thread. Use a channel to send events to the main thread.
use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher};
use std::sync::mpsc;
use std::path::Path;
fn main() {
// Create a channel to send events from the watcher thread to the main thread.
let (tx, rx) = mpsc::channel();
// RecommendedWatcher picks the optimal backend for the current OS.
let mut watcher = RecommendedWatcher::new(
move |res| {
// Send the result through the channel.
// The move captures the sender by value so it can be used in the closure.
if let Err(e) = tx.send(res) {
eprintln!("Failed to send event: {}", e);
}
},
Config::default(),
).expect("Failed to create watcher");
// Watch the uploads directory without recursing into subdirectories.
watcher.watch(Path::new("uploads"), RecursiveMode::NonRecursive)
.expect("Failed to watch directory");
// Process events on the main thread.
for result in rx {
match result {
Ok(event) => {
// Filter for modifications. Other events like metadata changes can be noise.
if event.kind.is_modify() {
for path in event.paths {
println!("Processing change: {}", path.display());
// process_file(&path);
}
}
}
Err(e) => eprintln!("Watch error: {}", e),
}
}
}
The mpsc::channel creates a sender tx and a receiver rx. The closure captures tx by value using move. This is required because the closure runs on a different thread. The closure sends results to the channel. The main thread iterates over rx and processes events.
The event.kind.is_modify() check filters for file modifications. File systems emit many event types. Access time updates, permission changes, and metadata updates can trigger events. Filtering reduces noise.
Convention note: notify events can be noisy on some platforms. macOS FSEvents, for example, often reports a directory event when a file inside changes, without listing the file path. The event.paths list can be empty. Always check event.paths.is_empty() before assuming you have a file path. If the paths are empty, you may need to scan the directory yourself.
Send events, don't process them. The watcher thread is for listening, not for lifting heavy loads.
Pitfalls and compiler errors
File watching has quirks. The OS behaves differently than you expect. The compiler catches some mistakes, but runtime issues require care.
Dropping the watcher too early. The Watcher instance owns the background thread. If you create a watcher inside a function and return, the watcher drops at the end of the function. The watch stops immediately. Keep the watcher alive for as long as you need monitoring. Store it in a struct or a global variable if necessary.
Blocking the callback. The callback runs on the watcher thread. If you sleep, read a large file, or make a network request inside the callback, you block the thread. The OS events pile up in the kernel buffer. If the buffer overflows, you lose events. The callback should only send data to another thread.
Event coalescing. If you modify a file ten times in one millisecond, the OS might send a single event. This is called coalescing. The OS optimizes by merging rapid changes. Your code should not assume one event equals one modification. If you need precise tracking, check the file state after receiving an event.
Empty paths. As mentioned, some backends report events without file paths. macOS FSEvents is the usual culprit. An event might indicate a directory changed, but event.paths is empty. Your handler must handle this case. If you need the specific file, you may need to poll the directory or maintain your own state.
Compiler error E0597. If you try to borrow a local variable inside the watcher closure without move, the compiler rejects the code. The closure runs on a background thread that may outlive the main function. The borrow does not live long enough.
error[E0597]: `my_data` does not live long enough
--> src/main.rs:10:9
|
10 | |res| { println!("{:?}", my_data); }
| ^^^^^^^ borrowed value does not live long enough
|
note: borrowed value must be valid for the anonymous lifetime defined here
--> src/main.rs:8:22
|
8 | let mut watcher = RecommendedWatcher::new(
| ^^^^^^^^^^^^^^^^^^^^^^^^^
Fix this by using move to capture the variable by value, or by cloning data into the closure. If the data is shared, wrap it in Arc.
Recursive overhead. Watching a large directory tree recursively consumes resources. Each directory entry requires kernel state. If you watch a node_modules folder with thousands of files, you hit limits. Use RecursiveMode::NonRecursive when you only care about a specific level.
Trust the OS to batch events. Your code should handle "something changed", not "exactly what changed".
Decision matrix
File watching has several approaches. Pick the right tool for the job.
Use notify with RecommendedWatcher when you need cross-platform file monitoring in a Rust application. It handles Linux, macOS, and Windows backends automatically and provides a stable API.
Use notify with PollWatcher when you are on a platform without native file events, such as WSL1 or some embedded systems, and you can tolerate a small delay and higher CPU usage. Polling checks file metadata at a fixed interval.
Use RecursiveMode::NonRecursive when you only care about a specific directory and want to avoid the overhead of watching thousands of subdirectories. This reduces memory usage and prevents hitting kernel limits.
Reach for inotifywait or the watch command when you need a quick one-off script in the shell and do not want to write a Rust binary. These tools are perfect for debugging or simple automation.
Pick a polling loop with std::fs::metadata when you are building a minimal tool that must run everywhere, including restricted environments where notify dependencies are forbidden, and you can accept a fixed latency. This approach has no external dependencies but requires careful tuning of the poll interval.
Let notify handle the OS differences. You write Rust; the crate handles the kernel.