The restart tax
You deploy a web server. You change the log level in config.toml. You expect the change to take effect immediately. It does not. You have to kill the process and start it again. That downtime adds up. In Rust, you can eliminate the restart tax by watching the file system and reloading the configuration in memory. Your application stays alive while the data underneath it quietly updates.
How the operating system tells you about changes
File watching is not magic. It is an operating system feature that replaces polling with event streams. If you poll, your program checks the file modification timestamp every second. That wastes CPU cycles and introduces delay. Modern kernels track file descriptor changes and push notifications directly to your process. Linux uses inotify. macOS uses FSEvents. Windows uses ReadDirectoryChangesW. The APIs differ wildly in their quirks and memory layouts.
The notify crate abstracts those differences into a single Rust interface. You hand it a file path and a callback. The crate spawns a background thread, registers with the OS, and forwards events to your handler. Think of it like a building manager who texts you only when someone touches the front door. You do not need to stare at the hallway. The manager handles the waiting.
A minimal watcher loop
Here is a complete, runnable pattern that watches a JSON file and signals a reload when the OS reports a change.
use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher};
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
/// Represents the application configuration
#[derive(Debug, Clone)]
struct Config {
log_level: String,
port: u16,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let config_path = "config.json";
// AtomicBool acts as a lightweight signal between threads
let should_reload = Arc::new(AtomicBool::new(false));
// Mutex protects the config during the brief swap window
let config_lock = Arc::new(std::sync::Mutex::new(load_config(config_path)?));
// Clone Arc handles for the watcher closure
// Convention: use Arc::clone explicitly to avoid deep-copy confusion
let should_reload_clone = Arc::clone(&should_reload);
let config_path_clone = config_path.to_string();
let config_lock_clone = Arc::clone(&config_lock);
// RecommendedWatcher picks the best OS backend automatically
let mut watcher = RecommendedWatcher::new(move |res| {
if let Ok(event) = res {
// Only react to modifications or new file creation
if event.kind.is_modify() || event.kind.is_create() {
println!("Config changed! Reloading...");
// Signal the main loop without blocking the watcher thread
should_reload_clone.store(true, Ordering::SeqCst);
}
}
}, Default::default())?;
// Start watching the specific file path
watcher.watch(Path::new(&config_path_clone), RecursiveMode::NonRecursive)?;
// Main loop checks the signal and applies updates
loop {
if should_reload.load(Ordering::SeqCst) {
// Reset the signal before attempting reload
should_reload.store(false, Ordering::SeqCst);
match load_config(&config_path_clone) {
Ok(new_config) => {
// Lock only for the duration of the swap
let mut cfg = config_lock_clone.lock().unwrap();
*cfg = new_config;
println!("Configuration reloaded successfully.");
}
Err(e) => eprintln!("Failed to reload config: {}", e),
}
}
// Yield CPU to avoid busy-waiting
thread::sleep(Duration::from_millis(100));
}
}
/// Parses the configuration file from disk
fn load_config(path: &str) -> Result<Config, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)?;
// In production, replace this with serde_json::from_str(&content)
Ok(Config {
log_level: "info".to_string(),
port: 8080,
})
}
The watcher runs on a separate thread. The main loop sleeps briefly, checks the atomic flag, and reloads if needed. This keeps the file system event handler fast and prevents blocking the OS notification queue.
What happens under the hood
When you call watcher.watch, the notify crate registers a file descriptor with the kernel. The kernel monitors the inode. When your editor saves the file, the kernel sees the metadata change and queues an event. The background thread wakes up, reads the queue, and invokes your closure. The closure sets should_reload to true using Ordering::SeqCst. That memory ordering guarantees the write is visible to the main thread immediately.
The main loop detects the flag. It locks the Mutex, replaces the old Config with the new one, and drops the lock. Readers that hold a reference to the Arc see the updated data on their next access. The Mutex only protects the pointer swap, not the config data itself. This keeps contention low. The lock is held for microseconds.
Convention aside: the community prefers Arc::clone(&handle) over handle.clone() for smart pointers. Both compile. Both work. The explicit form signals to readers that you are cloning the reference count, not the underlying data. It prevents the "wait, did they deep copy the whole config?" question during code review.
Production reload logic
The minimal example works, but production environments demand more discipline. Text editors and IDEs do not write files atomically. They often create a temporary file, write chunks, rename, or trigger multiple modify events in rapid succession. If you reload on every event, you will parse invalid JSON mid-save, crash your parser, or thrash your CPU.
You need debouncing. Debouncing waits for a quiet period before executing the reload. A simple approach uses a timer inside the handler. A more robust approach batches events and processes them once per second. The notify crate provides a Config builder that includes a debounce_interval option. You can also implement it manually by tracking the last event timestamp and ignoring anything that arrives too quickly.
Error handling requires a rollback strategy. If the new configuration contains a typo, your parser will return an Err. You must catch that error, log it, and keep the old configuration running. Never let a bad config file kill a running service. The old data stays in memory until a valid replacement arrives.
Atomic swaps are another production requirement. If your application reads the configuration thousands of times per second, a Mutex on every read will become a bottleneck. Replace Arc<Mutex<Config>> with Arc<Config>. Use Arc::swap to replace the entire pointer in one step. Readers always get a consistent snapshot. Writers replace the whole object. No locking on the read path.
Common pitfalls and compiler friction
Rust will stop you from making subtle mistakes, but the error messages can feel abrupt if you are not familiar with the ownership rules. The most common friction point is moving data into the watcher closure. If you try to capture config_lock directly without cloning the Arc, the compiler rejects you with E0373 (closure may outlive the current function) or E0507 (cannot move out of borrowed content). The fix is always the same: clone the Arc before passing it into the closure. The closure takes ownership of the clone. The original stays in main.
Another trap is holding the Mutex across the file read. If you lock before calling load_config, you block every other thread while the disk I/O happens. Disk reads are slow. Network reads are slower. Keep the lock window tight. Lock only when you are ready to swap the pointer. Unlock immediately after.
Race conditions can still occur if you are not careful with the reload flag. If two modify events fire before the main loop checks the flag, the second event will overwrite true with true. The reload still happens exactly once. That is safe. If you use a counter instead of a boolean, you might reload multiple times unnecessarily. Stick to the boolean flag. It is idempotent.
Convention aside: notify events are coalesced by the OS. You will rarely get a perfectly ordered stream. Treat events as hints, not guarantees. Always verify the file state when you reload. Read the file, parse it, validate it, then swap. Do not trust the event metadata alone.
Choosing your reload strategy
Use notify with a background thread when your application runs continuously and needs immediate reaction to configuration edits. Use periodic polling with std::fs::metadata when you are building a lightweight utility that cannot depend on external crates or when you target embedded systems without file system event APIs. Use a dedicated configuration service like etcd or Consul when your deployment spans multiple machines and requires distributed consistency. Use Arc::swap with Arc<Config> when read performance matters and you want to eliminate lock contention on the hot path. Use Arc<Mutex<Config>> when your configuration contains complex nested structures that are expensive to clone and you only reload occasionally.