FileSystemWatcher looks simple and has more ways to silently fail than almost any other class in .NET. Events do not fire because the watcher was garbage-collected or never enabled; they fire twice because of how editors save; they stop because the internal buffer overflowed; and on network shares and Linux they behave differently again. Each has a specific check, and a small pattern at the end handles the lot.
Cause 1: it went out of scope#
void StartWatching()
{
var watcher = new FileSystemWatcher(@"C:\data");
watcher.Created += (s, e) => Console.WriteLine(e.FullPath);
watcher.EnableRaisingEvents = true;
} // watcher is now unreachable; the garbage collector disposes it, events stop
The watcher is a local variable. When the method returns, nothing references it, and at the next collection it is gone. Keep it in a field, or in a long-lived object, for as long as you need events:
private readonly FileSystemWatcher _watcher;
public Monitor()
{
_watcher = new FileSystemWatcher(@"C:\data");
_watcher.Created += OnCreated;
_watcher.EnableRaisingEvents = true;
}
Cause 2: EnableRaisingEvents is false#
It defaults to false. Nothing happens until you set it to true, after the handlers are attached. Forgetting this line is the second most common cause and produces no error at all.
Cause 3: the filter or the path#
_watcher.Filter = "*.txt"; // only .txt files; "" or "*.*" for all
_watcher.IncludeSubdirectories = true; // default false: only the top folder
_watcher.NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.Size;
The Filter is a single pattern; "*.txt|*.csv" does not work. For several patterns use the Filters collection (.NET 5 and later). IncludeSubdirectories is off by default, so files in subfolders are ignored. And NotifyFilter decides which kinds of change raise events: if you are waiting for Changed and the filter does not include LastWrite, you will wait forever.
Check the path is a directory that exists. The constructor throws for a missing path, but a path to a file rather than a folder is a subtler mistake.
Cause 4: events fire twice, or fire before the file is complete#
Saving a file in most editors is not one write. Word, Visual Studio and many others write to a temporary file, delete the original and rename — producing a Created, several Changed, a Deleted and a Renamed event for one save. Even a simple copy raises Created when the file appears and Changed as data arrives, and the file is locked until the copy finishes.
Two techniques:
// 1. Debounce: act only after events for a path go quiet
private readonly ConcurrentDictionary<string, Timer> _pending = new();
private void OnChanged(object sender, FileSystemEventArgs e)
{
var timer = _pending.GetOrAdd(e.FullPath, _ => new Timer(_ => Process(e.FullPath), null, Timeout.Infinite, Timeout.Infinite));
timer.Change(500, Timeout.Infinite); // restart the half-second wait
}
// 2. Wait until the file can be opened exclusively
static bool IsReady(string path)
{
try
{
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.None);
return true;
}
catch (IOException) { return false; }
}
Retry IsReady with a short delay a few times before processing. Together, debouncing and the readiness check turn a burst of noisy events into one action on a complete file.
Cause 5: the buffer overflowed#
Events are queued in a fixed buffer, 8 KB by default. A burst of thousands of changes — unzipping an archive, a build writing output — fills it, the watcher drops events, and raises Error with an InternalBufferOverflowException. If you do not handle Error, you never find out.
_watcher.InternalBufferSize = 64 * 1024; // up to 64 KB
_watcher.Error += (s, e) => logger.LogWarning(e.GetException(), "Watcher error; rescanning");
Always handle Error. When it fires, the correct response is to rescan the directory, because you have missed something. Keep handlers fast — do the real work on another thread — so the buffer drains quickly.
Cause 6: network shares#
Watching a UNC path or a mapped drive depends on the server sending change notifications, and many do not reliably: NAS devices, some Samba configurations, and cloud-synced folders. Events arrive late, partially, or never. If you must watch a share, combine the watcher with a periodic scan that compares timestamps, and treat the watcher as an optimisation rather than the source of truth.
Cause 7: Linux, macOS and Docker#
On Linux the implementation uses inotify, which has a per-user limit on watched directories. Watching a large tree with subdirectories can exceed it, after which new watches fail:
cat /proc/sys/fs/inotify/max_user_watches
sudo sysctl fs.inotify.max_user_watches=524288
Inside Docker, a bind mount from the host does not always forward change events into the container, particularly on Windows and macOS hosts. The DOTNET_USE_POLLING_FILE_WATCHER=1 environment variable switches the hosting file provider used by ASP.NET Core to polling, and for your own watcher the equivalent is a timer-based scan.
A pattern that handles all of it#
- Hold the watcher in a field; set the filter, subdirectory flag and notify filter explicitly; enable last.
- Handle Created, Changed, Renamed, Deleted and Error.
- Debounce per path; check the file is ready before processing; process off the event thread.
- Rescan on Error, and periodically anyway if the source is a share or a container mount.
Questions people ask#
Why no events for a file created by my own program?
Events are raised for any change, including your own. If you see none, the watcher is disposed, disabled, or filtered. If you see too many, that is your program’s own writes coming back.
Can I watch a single file?
Watch its directory with Filter set to the file name.
Is there an async or IObservable version?
Not built in. Wrapping the events in a Channel or an observable sequence is common and gives a clean way to debounce.
Why does Changed fire when only the timestamp changed?
Because LastWrite is in the notify filter. Remove it if you only care about size or name changes, or compare content hashes before acting.
Where to go next#
- File I/O and serialisation in C# — reading the files once they are ready.
- Asynchronous programming with C# — processing off the event thread.
- C# exception not showing a line number — for when the Error event does fire.