File Watching in Rust with notify-rs — Hot Folders for a Sync App

Published: (May 13, 2026 at 11:13 PM EDT)
2 min read
Source: Dev.to

Source: Dev.to

Basic setup

[dependencies]
notify = "6"
use notify::{RecommendedWatcher, RecursiveMode, Watcher, Config};
use std::sync::mpsc;

fn watch_directory(path: &str) -> Result {
    let (tx, rx) = mpsc::channel();

    let mut watcher = RecommendedWatcher::new(tx, Config::default())?;
    watcher.watch(path.as_ref(), RecursiveMode::Recursive)?;

    for res in rx {
        match res {
            Ok(event) => handle_event(event),
            Err(e) => log::error!("Watch error: {:?}", e),
        }
    }
    Ok(())
}

RecommendedWatcher uses FSEvents on macOS – the native file‑system event API. It has low overhead and fast notification.

The double‑fire problem

File‑save operations often trigger multiple events (e.g., Modify, Modify, Create, Modify). You typically want a single sync trigger, not four.

Debounce

use std::time::{Duration, Instant};
use std::collections::HashMap;
use std::path::PathBuf;

struct Debouncer {
    last_seen: HashMap,
    delay: Duration,
}

impl Debouncer {
    fn should_process(&mut self, path: &PathBuf) -> bool {
        let now = Instant::now();
        let last = self
            .last_seen
            .entry(path.clone())
            .or_insert(Instant::now() - self.delay * 2);

        if now.duration_since(*last) >= self.delay {
            *last = now;
            true
        } else {
            false
        }
    }
}

A 300–500 ms debounce window covers most editor save behaviors without feeling slow.

Filtering what to watch

Not every file change should trigger a sync. Example filter:

fn should_ignore(path: &Path) -> bool {
    let name = path
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("");

    // Hidden files
    name.starts_with('.')
    // Temp files
    || name.ends_with(".tmp")
    || name.ends_with('~')
    // macOS metadata
    || name == ".DS_Store"
    // Files already being synced
    || name.ends_with(".sync")
}

Running the watcher in Tauri

The watcher must run in a background thread so it doesn’t block the Tokio runtime:

std::thread::spawn(move || {
    if let Err(e) = watch_directory(&path) {
        log::error!("Watcher failed: {:?}", e);
    }
});

Communicate back to Tauri via channels or the app handle’s emit system.

The verdict

notify‑rs with FSEvents on macOS is solid. The double‑fire problem requires debouncing—build it in from the start. Filter aggressively to avoid triggering on irrelevant changes.

If this was useful, a ❤️ helps more than you’d think — thanks!

Hiyoko PDF Vault
X

0 views
Back to Blog

Related posts

Read more »