Add Debouncing and Coalescing for Filesystem Watcher fs-change Events
Summary
The local filesystem watcher (backend/src/watcher.rs) emits a "fs-change" Tauri event for every create/modify/delete notification that the notify crate delivers.
On directories with high churn (downloads folder, build output directories, log directories, node_modules during installs, etc.), this can generate hundreds of events per second.
The frontend receives all of them over IPC and has to decide whether to ignore them. This wastes CPU, IPC bandwidth, and battery on the user's machine, and makes the code in FileContext more complicated than it needs to be.
Current Behavior (the problem)
- User (or external program) creates/modifies/deletes many files in a watched local directory.
notify::RecommendedWatcher calls the closure in watch_directory for each OS event.
- The closure immediately does:
let _ = app_clone.emit("fs-change", &payload);
- Every single event crosses the Tauri IPC boundary into the SolidJS frontend.
FileContext listens and must implement its own "is this path relevant to me?" + staleness logic.
There is zero debouncing or coalescing on the Rust side today.
Why this matters
- Performance: A recursive
npm install or a git checkout of a medium repo can easily produce 500–2000+ filesystem events in a few seconds. Each one becomes a full IPC round-trip.
- Battery & CPU waste on laptops.
- Frontend complexity: The smart auto-refresh logic in
FileContext has to be defensive against event storms.
- Correctness risk: Under very high event rates, we could theoretically overwhelm the event channel or cause UI jank.
- Inconsistent with the rest of the architecture: The job system and search indexer are both very careful about not doing unnecessary work. The watcher should be too.
Goal of this issue
Move the rate-limiting / coalescing logic into the backend (where it belongs), so the frontend receives at most one meaningful "something changed in this watched directory" signal per debounce window.
How to implement (junior developer friendly)
Recommended Approach: Per-Watch-ID Debounced Emitter
We will keep one small debouncer state per active watch (keyed by the watch_id we already generate).
When any event arrives for a watch:
- Record that "this watch has pending changes".
- If a debounce timer is not already running for this watch, start one (e.g., 150–300 ms).
- When the timer fires, emit one
"fs-change" event with the watch information, then clear the pending flag.
This is classic "debounce" + "coalesce" (multiple events → single notification).
Concrete Steps
-
Study the existing watcher code
- Read the entire
backend/src/watcher.rs
- Understand
WatcherRegistry, FsChangeEvent, and how the closure captures disk_id_clone + path_clone.
- Look at how the frontend consumes the event (search
fs-change in frontend/src).
-
Add a debounce mechanism
You have two good options in Rust/Tokio:
Option A (simpler for junior): Use tokio::time::sleep + a DashMap or Mutex<HashMap> of (watch_id → JoinHandle).
Option B (more elegant): Use the tokio-util crate's Debounce or implement a tiny manual version with tokio::sync::mpsc + select!.
For the first implementation, Option A with a small helper struct is perfectly acceptable and easier to review.
-
Design the state you need
You will probably need something like:
struct PendingWatch {
disk_id: String,
path: String,
timer: Option<tokio::task::JoinHandle<()>>,
}
// stored inside WatcherRegistry or a new small struct next to it
pending: RwLock<HashMap<String, PendingWatch>>,
Or even simpler: just a RwLock<HashMap<String, Instant>> of "last emitted time per watch" and skip emission if within the window.
-
Choose a debounce duration
Suggested starting value: 200 milliseconds.
This is long enough to coalesce bursts (file saves, build steps) but short enough that the UI still feels live.
-
Modify the event closure
Instead of immediately emitting, call a new method on the registry or a helper:
watchers.note_change(&watch_id, &disk_id, &path, &app).await;
-
Emit exactly one event when the timer fires
The emitted payload can stay exactly the same (FsChangeEvent). The frontend does not need to change at all for this improvement.
-
Handle watcher removal cleanly
When unwatch_directory is called, you must cancel any pending timer task for that watch ID so we don't leak tasks.
-
Add a test
- You can test this with the in-memory behavior or by using
tempfile + notify in a controlled way.
- At minimum, write a unit test that shows that N rapid simulated changes only produce 1 (or a bounded number) of actual emissions after the debounce window.
Acceptance Criteria
Learning Goals
- Event-driven architecture and rate limiting
- Managing background timer tasks in async Rust (
JoinHandle, cancellation)
- Understanding the difference between "debounce" and "throttle"
- Making performance improvements that are invisible to calling code (good API design)
- Writing tests for timing-sensitive asynchronous behavior
Files You Will Modify
backend/src/watcher.rs (primary)
- Possibly
backend/src/state.rs if you decide to store pending state in AppState
Out of Scope (future improvements)
- Different debounce windows per directory type (e.g., longer for Downloads)
- Sending the set of changed paths instead of just "something happened here"
- Making the debounce duration configurable by the user
This is a classic "make the system robust under load" issue. It looks small but teaches real production engineering thinking. A junior who ships this will have made the app noticeably lighter when users have busy directories watched.
Add Debouncing and Coalescing for Filesystem Watcher
fs-changeEventsSummary
The local filesystem watcher (
backend/src/watcher.rs) emits a"fs-change"Tauri event for every create/modify/delete notification that thenotifycrate delivers.On directories with high churn (downloads folder, build output directories, log directories, node_modules during installs, etc.), this can generate hundreds of events per second.
The frontend receives all of them over IPC and has to decide whether to ignore them. This wastes CPU, IPC bandwidth, and battery on the user's machine, and makes the code in
FileContextmore complicated than it needs to be.Current Behavior (the problem)
notify::RecommendedWatchercalls the closure inwatch_directoryfor each OS event.FileContextlistens and must implement its own "is this path relevant to me?" + staleness logic.There is zero debouncing or coalescing on the Rust side today.
Why this matters
npm installor agit checkoutof a medium repo can easily produce 500–2000+ filesystem events in a few seconds. Each one becomes a full IPC round-trip.FileContexthas to be defensive against event storms.Goal of this issue
Move the rate-limiting / coalescing logic into the backend (where it belongs), so the frontend receives at most one meaningful "something changed in this watched directory" signal per debounce window.
How to implement (junior developer friendly)
Recommended Approach: Per-Watch-ID Debounced Emitter
We will keep one small debouncer state per active watch (keyed by the
watch_idwe already generate).When any event arrives for a watch:
"fs-change"event with the watch information, then clear the pending flag.This is classic "debounce" + "coalesce" (multiple events → single notification).
Concrete Steps
Study the existing watcher code
backend/src/watcher.rsWatcherRegistry,FsChangeEvent, and how the closure capturesdisk_id_clone+path_clone.fs-changeinfrontend/src).Add a debounce mechanism
You have two good options in Rust/Tokio:
Option A (simpler for junior): Use
tokio::time::sleep+ aDashMaporMutex<HashMap>of(watch_id → JoinHandle).Option B (more elegant): Use the
tokio-utilcrate'sDebounceor implement a tiny manual version withtokio::sync::mpsc+select!.For the first implementation, Option A with a small helper struct is perfectly acceptable and easier to review.
Design the state you need
You will probably need something like:
Or even simpler: just a
RwLock<HashMap<String, Instant>>of "last emitted time per watch" and skip emission if within the window.Choose a debounce duration
Suggested starting value: 200 milliseconds.
This is long enough to coalesce bursts (file saves, build steps) but short enough that the UI still feels live.
Modify the event closure
Instead of immediately emitting, call a new method on the registry or a helper:
Emit exactly one event when the timer fires
The emitted payload can stay exactly the same (
FsChangeEvent). The frontend does not need to change at all for this improvement.Handle watcher removal cleanly
When
unwatch_directoryis called, you must cancel any pending timer task for that watch ID so we don't leak tasks.Add a test
tempfile+notifyin a controlled way.Acceptance Criteria
"fs-change"event per debounce window (200 ms recommended)FsChangeEvent) and the frontend listener do not need to changeunwatch_directory), any pending debounce timer is cancelled (no leaked tasks)cargo clippy -- -D warningsis still cleantokio::time::sleep) that proves multiple rapid changes result in fewer emitted eventsLearning Goals
JoinHandle, cancellation)Files You Will Modify
backend/src/watcher.rs(primary)backend/src/state.rsif you decide to store pending state inAppStateOut of Scope (future improvements)
This is a classic "make the system robust under load" issue. It looks small but teaches real production engineering thinking. A junior who ships this will have made the app noticeably lighter when users have busy directories watched.