Dart File System Watchers
last modified May 29, 2026
Introduction
A file system watcher lets a program monitor a directory (or
directories) for changes: file creation, modification, deletion, and moves.
Dart provides this capability through the Directory.watch()
method, which returns a Stream<FileSystemEvent>. The
watcher is non-blocking and works by asking the operating system to notify
us when something happens inside the watched directory.
Watchers are essential for any tool that must react to filesystem changes in real time. Common examples include auto-reloading development servers that restart when source code changes, log monitors that tail growing files, and build tools that recompile assets on save. Without a watcher, such programs would have to poll the filesystem repeatedly, wasting CPU and battery.
import 'dart:io';
void main() {
// Watchers rely on OS-specific native APIs. The Dart runtime will
// throw a FileSystemException if watching is not supported (rare).
final dir = Directory('.');
try {
dir.watch(); // just a syntax check - returns a Stream
print('Directory watching is available on this platform.');
} on FileSystemException catch (e) {
print('Watching is not supported: $e');
}
}
The Directory.watch() method is available on all major desktop
platforms (Linux, macOS, Windows). It uses the most efficient native API
under the hood and provides a consistent Dart-side interface.
How Directory.watch() works
Calling watch() on a Directory object returns a
lazy Stream<FileSystemEvent>. You listen to this stream
with .listen() or an await for loop. Each emitted
event is one of four concrete types:
| Event type | Trigger | Example |
|---|---|---|
FileSystemCreateEvent | File or directory created | touch new.txt |
FileSystemModifyEvent | File content or metadata changed | Editor saves a file |
FileSystemDeleteEvent | File or directory deleted | rm old.txt |
FileSystemMoveEvent | File/directory renamed or moved (also includes the destination) | mv a.txt b.txt |
Every event carries a path property (an absolute path to the
affected file or directory). FileSystemMoveEvent additionally
holds destination (the new path). The stream never closes on
its own — it emits events until you cancel the subscription or the program
exits.
import 'dart:io';
void main() {
final dir = Directory('watched_folder');
if (!dir.existsSync()) dir.createSync();
print('Watching ${dir.path} …');
final stream = dir.watch(recursive: false);
final subscription = stream.listen((FileSystemEvent event) {
print('${event.runtimeType} → ${event.path}');
});
// Keep the script alive (press Enter to quit)
stdin.listen((_) => subscription.cancel());
}
The recursive parameter (default false) controls
whether subdirectories are also monitored. When true, the
stream yields events for any file system object inside the entire tree.
Filtering events
Raw event streams can be noisy — a single save often triggers several modify events. Use Dart's stream methods to filter and transform the stream before acting on it.
import 'dart:io';
void main() {
final dir = Directory('watched_folder')
..createSync(recursive: true);
dir
.watch(recursive: true)
.where((event) => !event.path.endsWith('.swp')) // ignore vim temp files
.where((event) => event is FileSystemModifyEvent)
.map((event) => 'Modified: ${event.path}')
.distinct()
.listen(print);
print('Watching. Press Enter to exit.');
stdin.listen((_) => exit(0));
}
Here where drops unwanted files, map converts the
event into a readable string, and distinct suppresses consecutive
duplicate lines. These operators keep the handling logic clean and let you
focus on what actually matters.
Real-world use cases
Auto-reloading HTTP server
A typical Dart development server can watch its own source directory and
restart itself whenever a .dart file changes. A simple
implementation listens for modify events, kills the current isolate, and
spawns a new one — a poor man's hot-reload.
Log file monitor
If an application writes to a log file, a watcher on the log directory can detect when the file is rotated (delete + create) or appended to (modify), and a separate process can tail the new content in real time.
Build tool trigger
A lightweight build script may watch lib/ for any change and
automatically re-run the Dart compiler or a test suite. Combined with a
debouncer, it ensures compilation only happens once after a burst of saves.
import 'dart:io';
import 'dart:async';
void main() {
final dir = Directory('lib');
if (!dir.existsSync()) {
print('No lib/ directory found.');
return;
}
Timer? debounce;
dir.watch(recursive: true).listen((event) {
if (event is FileSystemModifyEvent && event.path.endsWith('.dart')) {
// Debounce: wait 300ms of silence before acting.
debounce?.cancel();
debounce = Timer(Duration(milliseconds: 300), () {
print('Source changed. Running dart analyze …');
Process.run('dart', ['analyze', '.']).then((result) {
stdout.write(result.stdout);
stderr.write(result.stderr);
});
});
}
});
print('Auto-reloader active. Watching lib/ …');
}
The debounce timer prevents multiple rapid rebuilds when several files are saved in quick succession (e.g., a “Save All” in an IDE). This pattern is the cornerstone of most Dart-based build watchers.
Platform differences
Under the hood, Directory.watch() uses the best available OS
API. The Dart runtime abstracts away most platform details, but a few
behavioural differences remain:
| Platform | API used | Notes |
|---|---|---|
| Linux | inotify | Very efficient. Supports recursive watches natively. Watches an inode, so renames of watched directories can lose the watch. |
| macOS | FSEvents | Excellent performance. Reports coarse-grained events (a whole directory tree can be reported with one event). The Dart stream still yields individual events per path. |
| Windows | ReadDirectoryChangesW | Works well for local NTFS drives. Network drives and some virtual filesystems may not send events reliably. Buffer overflows can occur under extreme load (Dart handles this gracefully). |
Symlinks are generally followed on Linux and macOS when using
recursive: true. On Windows, symlink support depends on the
filesystem and Dart version. Network mounts and fuse-based filesystems may
not generate events at all or may produce duplicate events. Always test on
the target deployment platform.
Another subtle difference: FileSystemModifyEvent may fire more
than once for a single logical change, especially on macOS where the save
lifecycle often involves a temporary file and an atomic rename. The Dart
stream does not coalesce these automatically — your code should be prepared
to handle multiple modify events per “save”.
import 'dart:io';
void main() {
print('Running on: ${Platform.operatingSystem}');
final dir = Directory('.');
// Some paths may not be watchable.
try {
dir.watch();
print('Watching is supported on current directory.');
} catch (e) {
print('Watch not supported: $e');
}
}
Always wrap the initial watch() call in a try-catch block
when the path may point to a network location or a special filesystem, to
avoid crashing the process.
Common pitfalls
- Duplicate events. An editor that writes a temporary
file and then renames it can generate a create, several modify events,
and a delete/move. Always use
distinct()or deduplicate based on path and a short time window. - Debouncing is a must. Without a debounce, a single
keystroke save may trigger multiple rebuilds. The
Timerpattern shown earlier is lightweight and effective. - Watching individual files.
Directory.watch()watches directories, not files. To monitor a single file, watch its parent directory and filter by path. - Recursive watching and resource leaks. When manually
creating recursive watchers (without the
recursive: trueparameter), remember to cancel subscriptions for directories that are removed. Dart's built-in recursive mode handles this automatically. - Stream subscription management. An un-cancelled
subscription keeps the event loop active and may prevent the program
from exiting. Always store the subscription and call
.cancel()when done, or use anawait forinside an asynchronous context that eventually breaks. - Symlinks and mounted volumes. A recursive watch on a
directory tree that contains a symlink to a large external volume may
cause thousands of unintended events. Use the
recursiveflag cautiously and consider avoiding symlink traversal by walking the directory tree yourself withFileSystemEntity.isLinkSync()checks.
import 'dart:io';
import 'dart:async';
void main() {
final dir = Directory('watched')..createSync();
// Simple deduplication: ignore events for the same path
// that occur within 200ms of each other.
final lastSeen = <String, DateTime>{};
dir.watch(recursive: true).listen((event) {
final now = DateTime.now();
final prev = lastSeen[event.path];
if (prev != null && now.difference(prev) < Duration(milliseconds: 200)) {
return; // skip duplicate
}
lastSeen[event.path] = now;
print('[${now.hour}:${now.minute}:${now.second}.${now.millisecond}] '
'${event.runtimeType} ${event.path}');
});
print('Watching. Press Enter to stop.');
stdin.listen((_) => exit(0));
}
The map-based deduplication above is a lightweight alternative to full debouncing. It works well when you want immediate feedback for the first event but wish to suppress subsequent noise for the same path.
Complete runnable example — recursive watcher with timestamps
The program below watches a directory tree recursively, debounces rapid events, and prints every change together with a high-precision timestamp. It handles both local paths and command-line arguments, making it a useful standalone diagnostic tool.
import 'dart:io';
import 'dart:async';
/// Prints the time, event kind, and path with ANSI colours for clarity.
void logEvent(String kind, String path) {
final now = DateTime.now();
final ts = '${now.hour.toString().padLeft(2,'0')}:'
'${now.minute.toString().padLeft(2,'0')}:'
'${now.second.toString().padLeft(2,'0')}.'
'${now.millisecond.toString().padLeft(3,'0')}';
// Simple colour mapping (works on most terminals).
final colours = {
'CREATE': '\x1B[32m', // green
'MODIFY': '\x1B[33m', // yellow
'DELETE': '\x1B[31m', // red
'MOVE' : '\x1B[36m', // cyan
};
final reset = '\x1B[0m';
final colour = colours[kind] ?? '';
stdout.writeln('$ts $colour$kind$reset $path');
}
Future<void> main(List<String> args) async {
final target = args.isNotEmpty ? args.first : 'watched_dir';
final dir = Directory(target);
if (!dir.existsSync()) {
stderr.writeln('Directory "$target" does not exist.');
exit(1);
}
print('Watching "${dir.path}" recursively …\n');
// Map of active timers for debouncing (one per path).
final debounceTimers = <String, Timer>{};
final stream = dir.watch(recursive: true);
final subscription = stream.listen(
(FileSystemEvent event) {
// Cancel any pending timer for this path.
debounceTimers[event.path]?.cancel();
// Set a new timer: only report the event after 250ms of quiet.
debounceTimers[event.path] = Timer(
const Duration(milliseconds: 250),
() {
String kind;
String path = event.path;
if (event is FileSystemCreateEvent) {
kind = 'CREATE';
} else if (event is FileSystemModifyEvent) {
kind = 'MODIFY';
} else if (event is FileSystemDeleteEvent) {
kind = 'DELETE';
} else if (event is FileSystemMoveEvent) {
kind = 'MOVE';
path = '${event.path} → ${event.destination}';
} else {
kind = 'UNKNOWN';
}
logEvent(kind, path);
},
);
},
onError: (error) {
stderr.writeln('Watch error: $error');
},
);
// Keep running until the user presses Enter.
stdin.listen((_) {
print('\nShutting down watcher…');
subscription.cancel();
for (final timer in debounceTimers.values) {
timer.cancel();
}
exit(0);
});
}
The script creates a debounce timer per file path. When a new event arrives, any pending timer for that path is cancelled and a fresh 250 ms timer is started. This means a burst of three modify events in 100 ms produces only one log line. The programme distinguishes all four event types and uses ANSI escape codes to highlight them in different colours (green for create, yellow for modify, red for delete, cyan for move).
To test the watcher, run the script and create, edit, rename, and delete files inside the watched directory. You will see clean, colour-coded output with precise timestamps. Press Enter to stop the watcher gracefully.
Source
Directory.watch() documentation, Dart server & CLI tutorial, watcher package on pub.dev
In this tutorial we covered Dart's file system watcher: how
Directory.watch() returns a stream of
FileSystemEvent objects, how to filter and debounce events,
platform-specific behaviour, and a fully worked example that logs changes
with timestamps.
Author
List all Dart tutorials.