last modified May 29, 2026
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.
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.
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.
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.
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.
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.
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.
distinct() or deduplicate
based on path and a short time window.Timer
pattern shown earlier is lightweight and effective.Directory.watch()
watches directories, not files. To monitor a single file, watch its
parent directory and filter by path.recursive: true
parameter), remember to cancel subscriptions for directories that are
removed. Dart's built-in recursive mode handles this automatically..cancel() when done, or use an await for
inside an asynchronous context that eventually breaks.recursive
flag cautiously and consider avoiding symlink traversal by walking
the directory tree yourself with FileSystemEntity.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.
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.
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.
My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.
List all Dart tutorials.