How to Use FileSystemWatcher Safely - Lost Events, Duplicate Notifications, and the Traps Around Completion Detection
1. Short version
- FileSystemWatcher events are not “completion notifications” - they are “hints that something changed”
- Created/Changed/Renamed can be duplicated, arrive out of order, or be lost on overflow
- don’t do heavy work in the event handler - just enqueue a rescan request and you’ll be much more stable
- don’t infer completion - declare it explicitly with
temp -> close -> renameor adone/manifestfile - if you have multiple workers, claim files atomically before reading
- tuning
InternalBufferSizeis only a band-aid - what really saves you is full rescan + idempotency
2. Common misconceptions
2.1 Treating Created as a completion signal
With copies and transfers, Created fires the moment the file appears, and several Changed events follow as the bytes are written.
sender: creates orders.csv
FileSystemWatcher: Created -> receiver starts reading (still mid-copy!)
sender: writes the rest -> Changed -> Changed
result: missing rows / broken JSON / corrupt ZIP
Created only means “a name became visible” - it does not mean “safe to read.”
2.2 Trusting the count and order of Changed events
- Changed is not guaranteed to fire exactly once - it can fire many times
- antivirus and indexers can trigger Changed too
- a Changed can even arrive after a Renamed
2.3 Losing changes to internal buffer overflow
When a lot of changes happen in a short window, the internal buffer fills up and individual notifications get dropped. If the Error event fires, don’t trust the per-event stream - do a full rescan.
3. Anti-patterns
3.1 Doing the work directly in the event handler
// bad
watcher.Created += (_, e) =>
{
using var stream = File.OpenRead(e.FullPath);
Import(stream); // might still be copying
};
What’s wrong:
- the file may not be fully written when Created fires
- there’s no recovery for failures or overflow
- the right pattern is: enqueue a rescan request and return immediately
3.2 Trying to reconstruct state from the event stream
A design that says “add on Created, update on Changed, drop on Deleted” falls apart under duplicates, splits, and overflow. Looking at what’s actually on disk each time is far more robust.
3.3 “It’s done if Changed has stopped”
A rule like “if the file size hasn’t changed for 10 seconds, it’s done” breaks in all of these cases:
- a long copy that pauses mid-way
- a sender that saves in multiple stages
- notifications delayed over a network share
- don’t infer completion - declare it
3.4 Logging Error and ignoring it
The Error event tells you the buffer overflowed or the watcher itself is in trouble. At minimum:
- request a full rescan
- recreate the watcher if monitoring looks unhealthy
- design every step to be idempotent so you can safely reprocess
4. Best practices
4.1 Collapse all notifications into “please rescan”
Fold every event (Created/Changed/Deleted/Renamed/Error) into a single signal that just means “go look.”
void OnAnyChange(object? sender, FileSystemEventArgs e)
{
RequestScan(full: false);
}
void OnError(object? sender, ErrorEventArgs e)
{
Log(e.GetException());
RequestScan(full: true); // full rescan on error
}
4.2 Make the sender declare completion
The textbook pattern:
- write everything to
data.tmp(a temp name) - flush and close
- rename / replace to
data.csvon the same filesystem - optionally drop a
data.done/manifest.jsonlast
FileSystemWatcher is not a tool for deciding when something is done - it’s a tool for noticing quickly when someone else has declared it done.
4.3 The receiver claims atomically
With multiple workers, take ownership by renaming incoming -> processing/<worker>/.
incoming/
order-123/
payload.csv
manifest.json
A single rename of the bundle directory transfers ownership atomically.
4.4 Always full rescan on startup / overflow / reconnect
Make sure a full rescan runs at all of these moments:
- application startup
- when an
Errorevent arrives - right after recreating the watcher
- on a regular schedule (as a safety net)
The mental model is “the watcher is a hint about diffs - the rescan is what restores consistency.”
4.5 Build on top of idempotency
Accept that you will look at the same item more than once - that’s a feature, not a bug.
- put an
IdempotencyKeyin the manifest - skip side effects if the key has already been processed
- aim for at-least-once + idempotency, not exactly-once
5. A correct implementation (excerpt)
private readonly SemaphoreSlim _scanSignal = new(0, int.MaxValue);
private int _scanRequested = 0;
private int _fullRescanRequested = 0;
void RequestScan(bool full)
{
if (full) Interlocked.Exchange(ref _fullRescanRequested, 1);
if (Interlocked.Exchange(ref _scanRequested, 1) == 0)
_scanSignal.Release();
}
async Task ScannerLoopAsync(CancellationToken ct)
{
RequestScan(full: true); // initial scan on startup
while (!ct.IsCancellationRequested)
{
await _scanSignal.WaitAsync(ct);
await Task.Delay(200, ct); // coalesce notification bursts
Interlocked.Exchange(ref _scanRequested, 0);
bool full = Interlocked.Exchange(ref _fullRescanRequested, 0) == 1;
foreach (var bundle in EnumerateReadyBundles(incomingDir, full))
{
if (!TryClaimByRename(bundle.Path, processingPath)) continue;
var manifest = ReadManifest(manifestPath);
if (AlreadyProcessed(manifest.IdempotencyKey))
{
MoveToArchive(processingPath, archiveDir);
continue;
}
ProcessBundle(processingPath);
RecordProcessed(manifest.IdempotencyKey);
MoveToArchive(processingPath, archiveDir);
}
if (Volatile.Read(ref _scanRequested) == 1)
_scanSignal.Release(); // don't drop notifications that arrived mid-scan
}
}
6. Wrap-up
- FileSystemWatcher is not a substitute for a completion signal
- the truth is not the event stream - it’s what’s actually on disk right now
- declare completion explicitly with
temp -> close -> renameordone/manifest - take ownership atomically with a claim rename
- collapse notifications into rescan requests, and full-rescan on startup / overflow / reconnect
- let idempotency absorb the duplicates and the re-runs
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
Why Bring Generic Host / BackgroundService into a Desktop App - Startup, Lifetime, and Graceful Shutdown Get Much Easier to Reason About
If startup, shutdown, exception handling, and periodic work are starting to bleed into the UI of your WPF or WinForms resident app, this ...
Where Should Unit Tests End and Integration Tests Begin - Drawing the Boundary and a Practical Decision Table
A practical guide for engineers on how to split responsibilities between unit and integration tests, organized around judgment vs. connec...
Serial Communication App Pitfalls - Sort Out 1-Byte Reads, Timeouts, Flow Control, Reconnects, USB Adapters, and UI Freezes Up Front
A practitioner-oriented guide to the points serial communication apps trip on — framing, multiple kinds of timeout, RTS/CTS and DTR, reco...
What to Check Before Migrating .NET Framework to .NET — A Practical Premigration Checklist
A practical checklist for what to clean up, what to peel off, and what to drop before you start a .NET Framework to .NET migration — cove...
Choosing Between PeriodicTimer, System.Threading.Timer, and DispatcherTimer - Sorting Out Periodic Work in .NET
A practical intro to periodic work in .NET: how PeriodicTimer, System.Threading.Timer, and DispatcherTimer differ, and how to choose betw...
Related Topics
These topic pages place the article in a broader service and decision context.
Windows Technical Topics
Topic hub for KomuraSoft LLC's Windows development, investigation, and legacy-asset articles.
Where This Topic Connects
This article connects naturally to the following service pages.
Windows App Development
We support Windows desktop applications that involve resident processing, device integration, operational logging, and maintainable structure.
Technical Consulting & Design Review
We help clarify design direction, architectural boundaries, lifetime ownership, and how to handle legacy Windows assets.