A Practical Guide to FileSystemWatcher - Handling Missed and Duplicate Events
· Updated: · Go Komura · FileSystemWatcher, C#, .NET, Windows Development, File Integration, Design
Revision history (1 updates, last updated Sep 1, 2026)
A log of the changes made to this article. Where a pre-update version was archived, it stays readable at a permanent DOI link.
- Retranslated as a full translation of the Japanese original. The previous English version was an abridgement that carried only part of the source, so sections, tables, Mermaid diagrams, figure captions and FAQ entries were missing. All of them have been restored to match the Japanese original, and the technical claims are the same as in the Japanese version. Read the version before this update (DOI: 10.5281/zenodo.21614465)
- First published
Cite this article(DOI: 10.5281/zenodo.21614464)
This article is archived on Zenodo. Below are both the DOI that always resolves to the latest version and the DOI pinned to the version you are reading.
Go Komura (2026). A Practical Guide to FileSystemWatcher - Handling Missed and Duplicate Events. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21614464 https://comcomponent.com/en/blog/2026/03/10/000-filesystemwatcher-safe-basics/
- DOI (latest version)
- 10.5281/zenodo.21614464
- DOI (this version)
- 10.5281/zenodo.22217124
FileSystemWatcher is the first API that comes up when monitoring file changes in .NET on Windows. It conveniently delivers file and directory creations, changes, deletions, and renames as events - but if you treat Created or Changed as completion notifications, you will quite routinely get burned by missed events, duplicate notifications, and reading half-written files.
In this article, we organize how to use FileSystemWatcher and its pitfalls, assuming mainly file-based integration with .NET on Windows. For the underlying mutual-exclusion concepts, see also Mutual Exclusion Fundamentals for File-Based Integration - Best Practices for File Locks and Atomic Claims.
In reality, Created can fire while a file is still being copied, and Changed is by no means guaranteed to fire only once. When changes concentrate in a short window, the internal buffer can overflow and individual changes get dropped.
So the core of the design is this.
- Notifications are triggers
- The truth lives in a directory rescan
- Ownership comes from an atomic claim
- Idempotency catches whatever is left
In the body of the article, we walk through the traps of wiring FileSystemWatcher into file integration with this mindset.
The code in this article is published on GitHub as a complete buildable and runnable sample set (a library, a console demo that runs against a temporary directory, and unit tests that actually create and modify files to verify the events).
filesystemwatcher-safe-basics - komurasoft-blog-samples (GitHub)
Target Readers and Prerequisites
This article is written for developers who write code on .NET for Windows that watches an incoming directory and ingests files. The code samples assume C# / .NET 8 or later, but the reasoning itself is language-independent.
It reuses the same vocabulary as the previous article linked above (mutual exclusion in file-based integration). Terms like claim, idempotency, manifest, and bundle appear from chapter 4 onward without further explanation, so here is a one-line summary of each so you can follow along even without having read the previous article.
Terms to Know Up Front
| Term | Meaning |
|---|---|
| claim | Taking ownership of a file - “I am the one processing this” - in a way no other worker can cut into. The implementation is a rename from incoming/ to processing/<worker>/, and only the single process whose rename succeeds becomes the owner (4.3) |
| idempotency | The property that processing the same target two or more times produces the same result as processing it once. Since duplicate notifications and rescans are a given, this is where everything is ultimately absorbed (4.5) |
| manifest | A small file placed alongside the payload that describes its contents. Recording the record count, a hash, an IdempotencyKey, and similar lets the receiver decide whether this has already been processed |
| bundle | One unit of transfer packaged together. Putting the payload, the manifest, and any auxiliary files in a single directory lets you claim that whole directory with one rename (4.3) |
| full rescan | Ignoring events entirely and re-enumerating the watched directory from scratch to identify what may be processed (4.4) |
| overflow | The internal buffer of FileSystemWatcher filling up and losing individual notifications. It is reported through the Error event (2.3) |
| ready | The state in which a file can be judged safe to read. Determined by the presence of a final name or a done / manifest file, not by guesswork (4.2) |
Table of Contents
- The Conclusion First (In One Line)
- 1.1. A Minimal Working Example
- Common Misconception Patterns With
FileSystemWatcher(Diagrams)- 2.1. Treating
Createdas a Completion Notification - 2.2. Trusting the Count and Order of
Changed - 2.3. Losing Changes to Internal Buffer Overflow
- 2.1. Treating
- Anti-Patterns
- 3.1. Processing Directly Inside the Event Handler
- 3.2. Trying to Reconstruct the True State From the Event Stream
- 3.3. Treating “No More
Changed” as Completion - 3.4. Believing a Bigger
InternalBufferSizeSolves It - 3.5. Logging
Errorand Ignoring It
- Best Practices
- 4.1. Fold Notifications Into “Rescan Requests”
- 4.2. Make Completion Explicit on the Sender Side
- 4.3. The Receiver Takes a Claim Atomically
- 4.4. Full Rescan on Startup / Overflow / Reconnection
- 4.5. Assume Idempotency
- Pseudocode (Excerpts)
- 5.1. The Typical Failure Pattern
- 5.2. An Example in the Right Direction (Roughly Sketched)
- A Rough Guide to Choosing
- Conclusion
- References
Knowledge map for this article
This article proposes a design that avoids treating the Created and Changed events of FileSystemWatcher as completion notifications, avoids losing notifications to internal buffer overflow, and avoids any design that tries to reconstruct state from the event stream, and instead collapses every notification into a single kind of rescan request and confirms the actual files with a full rescan. The sender states completion explicitly with temp-to-rename plus a done file or a manifest, and the receiver takes an atomic claim on each ready candidate found by the rescan and absorbs repeated visits with idempotency. For requirements where the process cannot stay running at all times, or where missing an event is not acceptable, it positions the USN change journal as a further option.
flowchart LR
accTitle: Practical guide to FileSystemWatcher
accDescr: Diagram showing that a FileSystemWatcher notification is only a hint of change rather than a completion signal, that notifications should be collapsed into rescan requests combined with a full rescan and a claim, and how notifications missed through internal buffer overflow relate to the USN change journal as an alternative
filesystemwatcher["FileSystemWatcher"]
full_rescan["Full Rescan (Directory Re-enumeration)"]
buffer_overflow_event_loss["Event Loss from Buffer Overflow"]
periodic_directory_listing["Periodic Directory Enumeration"]
change_notification_loss["Missed Change Notifications"]
internal_buffer_size_tuning["InternalBufferSize Tuning"]
error_event_ignored_antipattern["Ignored Error Event Anti-Pattern"]
created_event_misinterpreted_as_complete["Mistaking Created for completion"]
partial_write_read["Reading a Partially Written File"]
sender_side_completion_signaling["Sender-Side Completion Signaling"]
temp_then_rename_publish["Temp-Then-Rename Publish Pattern"]
done_manifest_file["done/manifest File"]
scan_request_coalescing["Coalescing Notifications into Rescan Requests"]
changed_event_order_assumption["Trusting Changed Event Count and Order"]
event_log_state_reconstruction_antipattern["State Reconstruction from Events Anti-Pattern"]
duplicate_processing["Duplicate Processing and Lost Updates"]
atomic_claim["Atomic Claim"]
bundle["Bundle (Exchange Unit Directory)"]
idempotent_processing["Idempotent Processing Design"]
watcher_downtime_gap["Missed Changes During Watcher Downtime"]
usn_journal["USN Change Journal"]
ntfs["NTFS"]
admin_rights["Administrator Privileges"]
filesystemwatcher -->|"may cause"| buffer_overflow_event_loss
full_rescan -.->|"uses"| periodic_directory_listing
full_rescan -->|"recommended for"| change_notification_loss
full_rescan -->|"recommended for"| buffer_overflow_event_loss
internal_buffer_size_tuning -->|"not recommended for"| buffer_overflow_event_loss
error_event_ignored_antipattern -.->|"may cause"| change_notification_loss
filesystemwatcher -.->|"may cause"| created_event_misinterpreted_as_complete
created_event_misinterpreted_as_complete -->|"may cause"| partial_write_read
sender_side_completion_signaling -->|"recommended for"| created_event_misinterpreted_as_complete
sender_side_completion_signaling -->|"uses"| temp_then_rename_publish
sender_side_completion_signaling -->|"uses"| done_manifest_file
scan_request_coalescing -->|"recommended for"| changed_event_order_assumption
scan_request_coalescing -->|"recommended for"| event_log_state_reconstruction_antipattern
full_rescan -->|"recommended for"| event_log_state_reconstruction_antipattern
changed_event_order_assumption -->|"may cause"| duplicate_processing
atomic_claim -->|"prevents"| duplicate_processing
bundle -->|"uses"| atomic_claim
bundle -->|"uses"| done_manifest_file
idempotent_processing -->|"recommended for"| duplicate_processing
full_rescan -->|"should come before"| atomic_claim
scan_request_coalescing -->|"should come before"| full_rescan
filesystemwatcher -->|"may cause"| watcher_downtime_gap
full_rescan -->|"mitigates"| watcher_downtime_gap
usn_journal -->|"recommended for"| watcher_downtime_gap
usn_journal -->|"requires"| ntfs
usn_journal -.->|"requires"| admin_rights
In the diagram a solid line marks a relation that always holds and a dashed line marks a conditional one (the conditions are given per relation on the detail page). The full list of relations (26 in total, with evidence and certainty) and the definitions of the main concepts are collected on the knowledge map detail page (in Japanese). Data: JSON-LD / Turtle
1. The Conclusion First (In One Line)
FileSystemWatcherevents are not completion notifications - they are hints that something changedCreated/Changed/Renamedcan duplicate, arrive in an order you did not expect, and get dropped on overflow- Event handlers are more stable when they do no heavy work and only enqueue a rescan request
- Completion detection should be made explicit via
temp -> close -> rename / replaceordonefiles / manifests - With multiple workers, you must take a claim atomically before reading
- Tuning
InternalBufferSizeis an aid. In the end, full rescans and idempotency are what work
In short: do not treat FileSystemWatcher as “a truthful history stream.”
Things break far less if notifications remain nothing more than a signal of “time to go look.”
flowchart TB
accTitle: The core of the design in this article
accDescr: Shows the core of the design in this article - keep notifications as nothing more than a trigger, confirm the truth with a directory rescan, take ownership through an atomic claim, and absorb duplicates with idempotency at the end.
notif["Notifications are triggers"] --> rescan["The truth lives in a directory rescan"]
rescan --> claim["Ownership comes from an atomic claim"]
claim --> idem["Idempotency catches whatever is left"]
Figure 1: The core of the design. Do not treat events as a truthful history; keep them as a signal that says time to go look.
1.1. A Minimal Working Example
For anyone who has never touched FileSystemWatcher, here is the smallest form that covers the happy path only. Everything from the next chapter onward is about the traps that start right where these ten lines “happen to work.”
// C# / .NET 8 console app. The minimal form that only confirms notifications arrive
using System.IO;
using var watcher = new FileSystemWatcher(@"C:\incoming")
{
Filter = "*.csv",
NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite,
};
watcher.Created += (_, e) => Console.WriteLine($"Created: {e.FullPath}");
watcher.Changed += (_, e) => Console.WriteLine($"Changed: {e.FullPath}");
watcher.Renamed += (_, e) => Console.WriteLine($"Renamed: {e.OldFullPath} -> {e.FullPath}");
watcher.Error += (_, e) => Console.WriteLine($"Error: {e.GetException().Message}");
watcher.EnableRaisingEvents = true; // Watching starts here
Console.WriteLine("Press Enter to exit");
Console.ReadLine();
Even in this minimal form, three things are worth fixing in your head from the start.
- Not a single event arrives until
EnableRaisingEvents = true. Registering handlers alone does nothing - The lifetime of the
watcheris the lifetime of the app. Once a local variable goes out of scope and the object is disposed, notifications stop there. For a long-running process, hold it somewhere that stays alive, such as a field - The default
NotifyFilteris the combinationLastWrite | FileName | DirectoryName(see FileSystemWatcher.NotifyFilter Property in chapter 8, References). Stating explicitly what you want to pick up saves confusion when you reread the code later
And the important part: this code only confirms that events arrive. It tells you nothing about whether the file may be read at the time of Created, or whether notifications were dropped. That is where the real subject begins.
2. Common Misconception Patterns With FileSystemWatcher (Diagrams)
2.1. Treating Created as a Completion Notification
This is the most obvious landmine.
During copies and transfers, Created fires the moment the file is created, and one or more Changed events may follow afterwards.
sequenceDiagram
participant 送信 as Sender
participant 共有 as watched dir
participant W as FileSystemWatcher
participant 受信 as Receiver
送信->>共有: Creates orders.csv
共有-->>W: Created
W-->>受信: OnCreated
受信->>共有: Opens and reads orders.csv
Note over 受信: Copy still in progress
送信->>共有: Writes the rest
共有-->>W: Changed
共有-->>W: Changed
Note over 受信: Missing rows / corrupt JSON / corrupt ZIP
Figure 2: Created fires even mid-copy. Reading as soon as it arrives means grabbing broken data.
Created may mean “the name became visible,” but it does not guarantee “it is now safe to read.”
If you equate the two, you step on the same mine as section 2.1 of the previous article, just by a different route.
2.2. Trusting the Count and Order of Changed
Changed is not guaranteed to fire exactly once.
Even ordinary operations like moving or saving can show up as multiple events. On top of that, you can also pick up the touches of antivirus software and indexers.
sequenceDiagram
participant App as Saving app
participant Dir as watched dir
participant AV as AV / indexer
participant W as FileSystemWatcher
App->>Dir: Starts saving report.xlsx
Dir-->>W: Created
Dir-->>W: Changed
App->>Dir: Rename from a temp file
Dir-->>W: Renamed
Dir-->>W: Changed
AV->>Dir: Scan / attribute access
Dir-->>W: Changed
Note over W: Not guaranteed to be once-only or in this order
Figure 3: Even an ordinary save splits into several events, mixed with whatever external processes touch. Neither the count nor the order can be relied on.
Expectations like “one Changed means done” or “nothing touches the file after Renamed” are quite precarious.
Additional notes:
- A file rename can produce a
Changedevent RenamedEventArgs.Namecan benullwhen the OS cannot correlate the old/new names- Hidden files are not exempt. “It’s a hidden temp name, so it won’t be seen” does not hold
- If the watched directory itself is renamed, that change is not reported
2.3. Losing Changes to Internal Buffer Overflow
FileSystemWatcher has an internal buffer.
When changes concentrate in a short window, it overflows and individual notifications get dropped.
flowchart LR
A[Many changes in a short window] --> B[Notifications pile up in the internal buffer]
B --> C{Can processing keep up?}
C -- Yes --> D[Process individual events in order]
C -- No --> E[Overflow]
E --> F[Error event]
F --> G[Stop trusting the completeness of the per-event history]
G --> H[Full rescan of the directory]
Figure 4: When a notification burst exceeds the internal buffer it overflows, and the completeness of the individual event stream falls apart.
The important point here is that “an overflow means losing just one event” is not guaranteed. The completeness of the entire individual-event stream becomes suspect, so it is best to simply re-examine the whole picture.
3. Anti-Patterns
3.1. Processing Directly Inside the Event Handler
This puts too much weight - completion detection and ownership acquisition - on the events themselves.
watcher.Created += (_, e) =>
{
using var stream = File.OpenRead(e.FullPath);
Import(stream); // May still be mid-copy
};
watcher.Error += (_, e) =>
{
Console.WriteLine(e.GetException()); // Just printing it
};
There are two problems.
- At the time of
Created, the contents may be incomplete - There is no recovery from failures or overflow
An event handler is at its best when it just raises a rescan request and returns immediately. If you start heavy I/O or DB updates here, you only make things worse for yourself during bursts.
flowchart TB
accTitle: What separates a light event handler from a heavy one
accDescr: Shows that an event handler should raise a rescan request and return immediately, and that starting heavy I/O or DB updates inside the handler carries both the risk of reading incomplete content and the problem of falling behind during bursts.
ev["Event handler"] --> light["Raise a rescan request and return immediately"]
heavy["Heavy I/O or DB updates inside the handler"] -.-> raw["Risk of reading incomplete content"]
heavy -.-> choke["Cannot keep up during bursts"]
Figure 5: Keep the handler light. Do not make events carry completion detection and ownership acquisition.
3.2. Trying to Reconstruct the True State From the Event Stream
The design of “add to a dictionary on Created, update on Changed, remove on Deleted, re-key on Renamed” looks clean at first glance.
But once duplicates, splits, overflow, and external interference enter, the bookkeeping gradually stops adding up.
switch (e.ChangeType)
{
case WatcherChangeTypes.Created:
state[e.FullPath] = Pending;
break;
case WatcherChangeTypes.Changed:
state[e.FullPath] = Modified;
break;
case WatcherChangeTypes.Deleted:
state.Remove(e.FullPath);
break;
}
Rather than struggling in this direction, it is stronger to re-check the actual files on disk each time. What matters in file integration is correctly finding what may be processed right now - not faithfully reconstructing the event history.
flowchart TB
accTitle: Reconstructing from events versus checking the actual files
accDescr: Shows that a design reconstructing state from the event stream stops adding up under duplicates, splits, overflow, and external interference, so re-checking the actual files on disk each time and correctly finding what may be processed right now is stronger.
ev2["Reconstruct state from the event stream"] -.-> broke["Bookkeeping breaks under duplicates, splits, and overflow"]
disk["Check the actual files on disk each time"] --> goal["Correctly find what may be processed"]
Figure 6: The goal is not reproducing the event history but finding what may be processed right now.
3.3. Treating “No More Changed” as Completion
This design smells just like the previous article’s “size stopped changing, so it’s done.” It looks convenient, but it determines completion by guessing.
if (lastChangedAt + TimeSpan.FromSeconds(10) < DateTime.UtcNow)
{
return Ready;
}
Cases where this fails include:
- A large file copy pauses midway
- The sending app saves in multiple stages
- Notifications appear delayed over a network share
- An external process rewrites attributes or timestamps afterwards
Completion is more stable when stated explicitly rather than guessed.
flowchart TB
accTitle: The danger of inferring completion from silence
accDescr: Shows that guessing completion once Changed has been quiet for a while misjudges paused copies, multi-stage saves, delayed notifications, and later attribute rewrites, so having the sender declare completion explicitly is more stable.
guess["Guess completion once Changed stops"] -.-> c1["Misjudged when a copy pauses"]
guess -.-> c2["Misjudged on multi-stage saves and delayed notifications"]
fix["The sender declares completion explicitly"] --> stable["Stable without relying on guesswork"]
Figure 7: Silence is no evidence of completion. Decide completion by explicit declaration, not by inference.
3.4. Believing a Bigger InternalBufferSize Solves It
Tuning InternalBufferSize matters, but it is not the heart of the design.
- The default is
8192bytes - It cannot go below
4096bytes, and cannot exceed64 KB - The buffer uses non-paged memory, so increasing it is not as casual as it sounds
In other words, even at 64 KB, a notification burst beyond it ends the story.
And it does nothing at all for the “is this a completion notification?” problem.
Before enlarging the buffer, there are things to do first.
- Narrow the watch scope with
Filter/Filters - Keep
NotifyFilterto the necessary minimum - Do not set
IncludeSubdirectoriestotruecarelessly - Keep the event handlers lightweight
- Add full rescans and idempotency
flowchart TB
accTitle: What to do before enlarging the buffer
accDescr: Shows the order to work in - since raising InternalBufferSize to 64 KB still drops events when a burst exceeds it, first narrow the watch scope with Filter and NotifyFilter, keep the handlers light, and add full rescans and idempotency.
first["Things to do first"] --> f1["Narrow the scope with Filter and NotifyFilter"]
first --> f2["Keep the handlers light"]
first --> f3["Full rescans and idempotency"]
buf["Tuning InternalBufferSize"] -.-> aux["Keep it as a last-resort aid"]
Figure 8: Enlarging the buffer is not the heart of the design. Narrowing the scope and building recovery come first.
3.5. Logging Error and Ignoring It
Error is not the kind of notification you can “see occasionally and shrug off.”
Buffer overflows and failures to continue watching surface here.
watcher.Error += (_, e) =>
{
_logger.LogError(e.GetException(), "watcher error");
// Ending here means noticing the loss but never recovering
};
At a minimum, you want to go this far.
- Request a full rescan
- If continued watching is in doubt, consider recreating the watcher
- Make reprocessing idempotent, on the assumption that events were lost
4. Best Practices
4.1. Fold Notifications Into “Rescan Requests”
Wiring Created / Changed / Deleted / Renamed / Error each directly into separate business logic ruins clarity. First fold them all into one kind of signal: “go look.”
flowchart LR
A[Created / Changed / Deleted / Renamed] --> Q[scan request]
B[Error / overflow] --> Q
C[startup] --> Q
Q --> D[Rescan the directory]
D --> E[Enumerate ready candidates]
E --> F[Attempt a claim]
Figure 9: Every notification, and startup too, folds into a single kind of scan request; the rescan then looks for ready candidates and attempts a claim.
Implementation points:
- In the event handler, do little more than set
dirty = trueand raise a signal - Concentrate scanning in a single worker
- During bursts, coalesce for around 100-300 ms, then scan once
- If more notifications arrive during a scan, scan once more afterwards
The 100-300 ms in that third bullet is not a number backed by a standard or by official documentation - it is a starting value from the author’s operational experience. In practice you are better off measuring these two things before deciding.
| What to look at | How to decide |
|---|---|
| The time one scan takes | If the wait is shorter than this, the next scan request simply piles up before the current scan finishes. Use something at or above the scan time as the lower bound |
| The detection delay you can tolerate | The wait translates directly into detection delay. If the requirement is “processed within n seconds of being dropped in,” cap the wait at a fraction of that |
For example, if one scan finishes in 50 ms and detection within one second is acceptable, 100-300 ms fits comfortably. Conversely, if there are so many files that a single scan takes several seconds, reworking how the scan is built (narrowing the target set, looking only at done files, splitting subdirectories) is more effective than stretching the wait.
Done this way, whether five events arrive or fifty, the final action is unified: “look at the actual files and find what is ready.”
4.2. Make Completion Explicit on the Sender Side
If you also control the sender, fixing the publishing protocol beats straining over completion detection on the FileSystemWatcher side.
The proven route, once again, is:
- Write the full content under a
tempname closeitrename / replaceon the same file system- If needed, place a
donefile / manifest last
flowchart TD
A[Write the full content to data.tmp] --> B[flush / close]
B --> C[rename / replace to data.csv]
C --> D[Place data.done / manifest.json]
D --> E[Receiver watches only final names or done files]
Figure 10: The sender writes the full content to a temp file, closes it, publishes by rename, and if needed places done / manifest last.
Same as the previous article, but this is where the real payoff is.
The right way to see FileSystemWatcher is not as a tool that invents completion, but as a tool that notices explicitly declared completion sooner.
4.3. The Receiver Takes a Claim Atomically
Even when a rescan finds a ready candidate, going straight in to read lets multiple workers grab it simultaneously. So take a claim atomically before processing.
sequenceDiagram
participant Scan as scanner
participant IN as incoming
participant P1 as processing/worker1
participant P2 as processing/worker2
Scan->>IN: Finds order-123
Scan->>P1: rename order-123
Scan->>P2: rename order-123
Note over P1,P2: Only the one that succeeds first holds ownership
Figure 11: Even when several workers find the same candidate, only the one whose rename succeeds holds ownership.
As mentioned in the previous article, the incoming -> processing/<worker>/ rename is the clearest.
Bundling the payload + manifest + auxiliary files into one directory is especially convenient, since you can then claim per bundle.
incoming/
order-123/
payload.csv
manifest.json
With this, a single rename of the bundle directory takes ownership.
4.4. Full Rescan on Startup / Overflow / Reconnection
This is quite important.
- Files placed before the app started are not picked up by events
- Once an overflow occurs, the individual event stream becomes hard to trust
- With network shares and transient disconnections in play, it is safer to assume “something in between” was missed
So at least at these moments, a full rescan should be performed.
- At startup
- On receiving
Error - Right after recreating the watcher
- Periodically, as insurance, at a fixed interval
The philosophy here: “the watcher is a hint about deltas; the rescan is the recovery of consistency.”
flowchart TB
accTitle: When to run a full rescan
accDescr: Shows that running a full rescan at four moments - at startup, on receiving Error, right after recreating the watcher, and periodically as insurance - recovers the changes that events cannot pick up.
t1["At startup"] --> fr["full rescan"]
t2["On receiving Error"] --> fr
t3["Right after recreating the watcher"] --> fr
t4["Periodically as insurance"] --> fr
fr --> heal["Recovery of consistency"]
Figure 12: The watcher is a hint about deltas; the full rescan is the recovery of consistency. Always include these four moments.
4.5. Assume Idempotency
With FileSystemWatcher, you will end up examining the same target multiple times.
That is not a bug - it is more stable to accept it as part of the design.
Concretely, it goes like this.
- Put an
IdempotencyKeyin the manifest - If already processed, do not re-execute the side effects
- Make archived / DB-recorded / sent statuses verifiable
- Ensure that a full rescan amounts to nothing more than “safely looking at the same things again”
Trying to build exactly-once out of events alone gets painful fast. Accepting at-least-once and closing the loop with idempotency is the stronger position in practice.
flowchart TB
accTitle: How to absorb duplicates by design
accDescr: Shows that examining the same target multiple times should be accepted as part of the design rather than treated as a bug, and that checking an IdempotencyKey in the manifest against what has already been processed keeps side effects from running twice, making rescans safe.
multi["Examine the same target multiple times"] --> accept["Accept it as part of the design"]
accept --> key["Check processed state with an IdempotencyKey"]
key --> safe["Do not re-execute the side effects"]
safe --> strong["A full rescan is just a safe second look"]
Figure 13: Do not build exactly-once out of events; accept at-least-once and close the loop with idempotency.
5. Pseudocode (Excerpts)
5.1. The Typical Failure Pattern
using var watcher = new FileSystemWatcher(incomingDir)
{
Filter = "*.csv",
IncludeSubdirectories = false,
EnableRaisingEvents = true,
InternalBufferSize = 64 * 1024
};
watcher.Created += (_, e) =>
{
// Assumes Created = completion notification
ProcessFile(e.FullPath);
};
watcher.Changed += (_, e) =>
{
// It keeps firing, so just process again
ProcessFile(e.FullPath);
};
watcher.Error += (_, e) =>
{
Console.WriteLine(e.GetException());
// No recovery
};
There are four problems.
Created/Changedare wired directly into business processing- There is no completion detection
- No full rescan on overflow
- No mechanism to stop processing the same file repeatedly
5.2. An Example in the Right Direction (Roughly Sketched)
private readonly SemaphoreSlim _scanSignal = new(0, int.MaxValue);
private int _scanRequested = 0;
private int _fullRescanRequested = 0;
void OnAnyChange(object? sender, FileSystemEventArgs e)
{
RequestScan(full: false);
}
void OnRenamed(object? sender, RenamedEventArgs e)
{
RequestScan(full: false);
}
void OnError(object? sender, ErrorEventArgs e)
{
Log(e.GetException());
RequestScan(full: true);
}
void RequestScan(bool full)
{
if (full)
{
Interlocked.Exchange(ref _fullRescanRequested, 1);
}
if (Interlocked.Exchange(ref _scanRequested, 1) == 0)
{
_scanSignal.Release();
}
}
async Task ScannerLoopAsync(CancellationToken cancellationToken)
{
RequestScan(full: true); // startup scan
while (!cancellationToken.IsCancellationRequested)
{
await _scanSignal.WaitAsync(cancellationToken);
// Coalesce notification bursts a little
await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken);
Interlocked.Exchange(ref _scanRequested, 0);
bool full = Interlocked.Exchange(ref _fullRescanRequested, 0) == 1;
foreach (var bundle in EnumerateReadyBundles(incomingDir, full))
{
var claimedPath = Path.Combine(processingDir, bundle.Name);
if (!TryClaimByRename(bundle.Path, claimedPath))
{
continue; // Another worker claimed it first
}
var manifest = ReadManifest(Path.Combine(claimedPath, "manifest.json"));
if (AlreadyProcessed(manifest.IdempotencyKey))
{
MoveToArchive(claimedPath, archiveDir);
continue;
}
ProcessBundle(claimedPath);
RecordProcessed(manifest.IdempotencyKey);
MoveToArchive(claimedPath, archiveDir);
}
if (Volatile.Read(ref _scanRequested) == 1)
{
_scanSignal.Release(); // Do not drop notifications that arrived mid-scan
}
}
}
What matters in this example is the flow, not the fine API details.
- Fold notifications into scan requests
- Find what is ready by scanning
- Take a claim
- Check idempotency
- Process, record, and move to the archive
flowchart TB
accTitle: The flow of the approach that works
accDescr: Shows the sequence the pseudocode represents - fold notifications into scan requests, find ready candidates by scanning, take a claim, check idempotency, then process, record, and move the result to the archive.
n["Fold notifications into scan requests"] --> s["Find what is ready by scanning"]
s --> c["Take a claim"]
c --> i["Check idempotency"]
i --> p["Process, record, and move to the archive"]
Figure 14: This flow, not the fine API details, is the substance. Events are only a trigger.
The FileSystemWatcher events are nothing more than a trigger here.
Note that EnumerateReadyBundles / TryClaimByRename / ReadManifest / AlreadyProcessed and the like are functions named in this article to show the flow - they are not standard .NET APIs. A form that actually builds and runs (a library, a console demo that runs against a temporary directory, and unit tests that verify the events) is in the sample set linked at the top.
filesystemwatcher-safe-basics - komurasoft-blog-samples (GitHub)
6. A Rough Guide to Choosing
-
Single receiving worker / you can also fix the sender Start with
temp -> close -> renameand a startup scan. That alone gets you quite far. -
Multiple receiving workers Add the
incoming -> processingclaim rename on top of the above. -
High-frequency, notification-heavy Narrow
Filter/NotifyFilter/IncludeSubdirectoriesand minimize the event handlers. TuningInternalBufferSizecomes after that. -
Overflows hurt / missed events are unacceptable Build on full rescans, and if that is still not enough, do not bet on
FileSystemWatcheralone. If you are Windows-only, the USN change journal is also an option. -
You cannot control how the other system writes Rather than papering over completion conditions with guesses, first consider whether the publishing protocol can be negotiated. If not, lower the guarantee level and lean into an idempotent receiving design.
The last two items are fairly important calls about when to walk away.
FileSystemWatcher is useful, but it is not an all-powerful truth detector.
What is different about the USN change journal
The USN change journal is a record of changes that NTFS keeps per volume. Directory notifications like FileSystemWatcher can only be received if the application is running at the moment the change happens, but the change journal leaves the record on the volume side, so changes that occurred while the application was down can be read back later from the position (USN) you last read. Microsoft’s own documentation lists “the application has to be kept running at all times” as a weakness of directory notifications, and presents the change journal as the way around it.
The cost, on the other hand, goes up.
FileSystemWatcher |
USN change journal | |
|---|---|---|
| Unit of watching | The specified directory (+ subdirectories) | The whole volume. Narrowing to the range you need is on you |
| While the application was down | Unknown. Fill the gap with a full rescan | Can be read back from the record |
| Missed events | Caused by internal buffer overflow | Once the journal’s size limit is exceeded, the oldest records are dropped |
| What you need | Only the .NET API | A volume handle and FSCTL_* calls. Administrative operations such as creating or deleting the journal require administrator rights |
In short, it is the option for when “cannot be kept running at all times” or “changes during downtime must also be picked up” enter the requirements. If neither applies, FileSystemWatcher plus a full rescan is the more straightforward implementation.
flowchart TB
accTitle: The difference between FileSystemWatcher and the USN change journal
accDescr: Shows the difference that FileSystemWatcher cannot know about changes made while the application was down and fills the gap with a full rescan, whereas the USN change journal keeps the record on the volume side so changes during downtime can be read back from the position last read.
fsw["FileSystemWatcher"] -.-> gap["Changes during downtime are unknown"]
gap --> fill["Fill the gap with a full rescan"]
usn["USN change journal"] --> keep["The record stays on the volume side"]
keep --> resume["Read back from the last USN"]
Figure 15: Once the requirements include not being able to run at all times, or picking up changes made during downtime, the change journal becomes an option.
7. Conclusion
FileSystemWatcher is no substitute for completion notifications. The truth is not in the event stream but in what is visible on disk right now. Make completion explicit via temp -> close -> rename / replace or done files / manifests, and decide ownership by taking a claim atomically. That is where the heart of the design lives.
Processing immediately on Created, trusting the count or order of Changed, treating “no more Changed” as completion, taking comfort in InternalBufferSize alone, seeing Error and never recovering - all designs to avoid. Instead, fold notifications into rescan requests, perform full rescans on startup / overflow / reconnection, take ownership through claim renames, and absorb duplicates and re-scans with idempotency.
In other words, the trick with FileSystemWatcher is to never equate “having received an event” with “being allowed to process.”
Just separating those two greatly reduces the kind of monitoring code that breaks only once in a while.
8. References
- Complete sample code for this article (library, demo, unit tests) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/filesystemwatcher-safe-basics
- Related article: Mutual Exclusion Fundamentals for File-Based Integration - Best Practices for File Locks and Atomic Claims
- FileSystemWatcher Class (System.IO)
- System.IO.FileSystemWatcher class - .NET
- FileSystemWatcher.InternalBufferSize Property (System.IO)
- FileSystemWatcher.NotifyFilter Property (System.IO)
- FileSystemWatcher.Error Event (System.IO)
- FileSystemWatcher.Created Event (System.IO)
- FileSystemWatcher.Changed Event (System.IO)
- FileSystemWatcher.Renamed Event (System.IO)
- Change Journals - Win32 apps
- Creating, Modifying, and Deleting a Change Journal - Win32 apps
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
Code Design for Business Systems — Deciding Product and Customer Codes, and Check Digits
A practical guide to deciding the code scheme for a business system, including product and customer codes. Covers a decision table for me...
Why Use the .NET Generic Host and BackgroundService in Desktop Apps
How to use the Generic Host and BackgroundService to organize startup, periodic processing, shutdown, logging, configuration, and DI in W...
Practical Multithreading Best Practices: .NET Edition — What to Decide Before You Add More Threads
A practical rundown of the design rules that keep multithreaded .NET/C# code from occasionally crashing or hanging: ride on Task instead ...
Using WMI/CIM from C# and PowerShell — A Practical Guide to Hardware Info Retrieval, Process Monitoring, and Remote Queries
WMI/CIM is the standard way to get a PC's serial number, monitor free disk space, and detect process launches. This article covers how to...
Versioning Your Business App's Database Schema — Migration Practices to Prevent 'Every Customer Has a Different DB'
A practical guide to versioning the database schema of a business app whose databases are scattered across customer sites. Covers PRAGMA ...
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
File integration and monitoring tools built on FileSystemWatcher are a frequently recurring real-world topic within our Windows application development work.
Technical Consulting & Design Review
If you want to organize missed-event countermeasures, rescans, and completion detection as a design, this fits well with technical consulting and design review.
Frequently Asked Questions
Common questions about the topic of this article.
- Is it safe to read a file in the FileSystemWatcher Created event?
- No. Created only means the name became visible; it does not guarantee that the file is ready to read. During copies and transfers, Created fires the moment the file is created, and one or more Changed events can follow. The basic rule is that the sender declares completion explicitly with temp -> close -> rename/replace or with a done/manifest file, and the receiver looks only at the final name or the done file.
- Can FileSystemWatcher drop notifications?
- Yes. When the internal buffer (8192 bytes by default, never below 4096 bytes, capped at 64 KB) overflows, individual notifications are lost and the Error event fires. Once an overflow happens, the completeness of the individual event stream itself becomes questionable, so the safe move is a full rescan of the directory to re-examine the whole picture. A full rescan belongs at startup, on receiving Error, right after recreating the watcher, and periodically as insurance.
- Why does Changed arrive so many times?
- Even ordinary operations such as moving or saving can show up as several separate events, and on top of that you pick up whatever antivirus software or indexers touch. A design that trusts the count or the order is dangerous. Fold every notification into one kind of signal - a rescan request - concentrate scanning in a single worker, and during bursts coalesce for around 100-300 ms before scanning once. That is what stays stable.
- Does increasing InternalBufferSize fix missed events?
- No. Even raised to 64 KB, a notification burst beyond that still drops events, and it does nothing at all for the question of whether a notification means completion. The buffer uses non-paged memory, so enlarging it is not as casual as it sounds. The right order is to first narrow the watch scope with Filter and NotifyFilter, review IncludeSubdirectories, keep the event handlers lightweight, and add full rescans and idempotency.