Monitoring File System Changes with FileSystemWatcher in .NET

Solving the File Monitoring Challenge

If you've ever needed to process files as soon as they appear in a folder, you know the frustration of polling. Your application checks the directory every few seconds, wasting CPU cycles and introducing delays between when files arrive and when you process them. Users upload a document, but your system takes 10 seconds to notice it exists.

FileSystemWatcher eliminates polling by subscribing to operating system notifications. When files change, the OS tells your application immediately. You can react to new files in milliseconds instead of seconds, and your CPU stays idle until actual changes occur. This approach scales better and provides instant feedback to users.

You'll learn how to set up watchers for different change types, handle events reliably, filter notifications, and avoid common pitfalls that cause duplicate events or missed changes. By the end, you'll build robust file monitoring that works in production environments.

Creating Your First FileSystemWatcher

FileSystemWatcher monitors a directory for changes and raises events when files or subdirectories are created, modified, deleted, or renamed. You specify the path to watch and which types of changes to track. The watcher runs on a background thread and notifies you through event handlers.

Start by creating a watcher for a specific directory. You'll enable it to start monitoring, then handle events as they occur. The watcher continues monitoring until you disable it or dispose of the object.

Program.cs
using System.IO;

var watchFolder = @"C:\Temp\WatchFolder";
Directory.CreateDirectory(watchFolder);

using var watcher = new FileSystemWatcher(watchFolder);

// Configure what to watch for
watcher.NotifyFilter = NotifyFilters.FileName
                      | NotifyFilters.LastWrite
                      | NotifyFilters.Size;

// Watch for changes to all files
watcher.Filter = "*.*";

// Handle different event types
watcher.Created += (sender, e) =>
{
    Console.WriteLine($"Created: {e.Name} at {DateTime.Now:HH:mm:ss.fff}");
};

watcher.Changed += (sender, e) =>
{
    Console.WriteLine($"Changed: {e.Name} at {DateTime.Now:HH:mm:ss.fff}");
};

watcher.Deleted += (sender, e) =>
{
    Console.WriteLine($"Deleted: {e.Name} at {DateTime.Now:HH:mm:ss.fff}");
};

// Start monitoring
watcher.EnableRaisingEvents = true;

Console.WriteLine($"Monitoring {watchFolder}");
Console.WriteLine("Press Enter to exit");
Console.ReadLine();
Output (when you create/modify test.txt):
Monitoring C:\Temp\WatchFolder
Created: test.txt at 14:23:45.123
Changed: test.txt at 14:23:45.145
Changed: test.txt at 14:23:45.167

Notice the multiple Changed events for a single file save. Most applications write files in several steps, each triggering an event. You'll need to handle this duplication in real applications, which we'll cover shortly. The NotifyFilter controls which attribute changes trigger events, reducing noise from irrelevant changes.

Handling File Renames Correctly

Rename operations are special because they involve two file names: the old name and the new name. FileSystemWatcher provides a dedicated Renamed event with both values, making it easy to track files that move or change names. Many file operations you'd expect to be simple writes are actually renames under the hood.

When applications save files, they often write to a temporary file then rename it to the target name. This atomic operation prevents corruption if the write fails partway through. Your watcher sees this as a rename event, not a creation.

Program.cs
using System.IO;

var watchFolder = @"C:\Temp\WatchFolder";
Directory.CreateDirectory(watchFolder);

using var watcher = new FileSystemWatcher(watchFolder);
watcher.Filter = "*.*";

watcher.Renamed += (sender, e) =>
{
    Console.WriteLine($"Renamed:");
    Console.WriteLine($"  Old: {e.OldName}");
    Console.WriteLine($"  New: {e.Name}");
    Console.WriteLine($"  Change Type: {e.ChangeType}");
};

watcher.Created += (sender, e) =>
{
    Console.WriteLine($"Created: {e.Name}");
};

watcher.EnableRaisingEvents = true;

Console.WriteLine("Monitoring for renames...");
Console.WriteLine("Try renaming a file in the watched folder");
Console.ReadLine();
Output (when renaming document.txt to report.txt):
Monitoring for renames...
Renamed:
  Old: document.txt
  New: report.txt
  Change Type: Renamed

The RenamedEventArgs provides both OldName and Name properties, letting you update database records or tracking structures. If you only handle Created and Deleted events, renames appear as delete-then-create sequences, which can confuse your logic and trigger unnecessary reprocessing.

Filtering Notifications Efficiently

Watching an entire directory with all file types generates many events you might not care about. Use Filter to watch specific file types and NotifyFilter to ignore irrelevant attribute changes. This reduces event volume and prevents your handlers from processing noise.

You can watch multiple file types by changing the filter at runtime or creating multiple watchers. Filtering at the source is more efficient than receiving all events and discarding most of them in your handlers.

Program.cs
using System.IO;

var watchFolder = @"C:\Temp\Documents";
Directory.CreateDirectory(watchFolder);

using var watcher = new FileSystemWatcher(watchFolder);

// Only watch for .txt and .log files
watcher.Filter = "*.txt";

// You can add multiple extensions by creating multiple watchers
using var logWatcher = new FileSystemWatcher(watchFolder);
logWatcher.Filter = "*.log";

// Only notify on these specific changes
watcher.NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite;
logWatcher.NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite;

// Include subdirectories
watcher.IncludeSubdirectories = true;
logWatcher.IncludeSubdirectories = true;

var handler = (object sender, FileSystemEventArgs e) =>
{
    var watcherObj = (FileSystemWatcher)sender;
    Console.WriteLine($"[{watcherObj.Filter}] {e.ChangeType}: {e.Name}");
};

watcher.Created += handler;
watcher.Changed += handler;
logWatcher.Created += handler;
logWatcher.Changed += handler;

watcher.EnableRaisingEvents = true;
logWatcher.EnableRaisingEvents = true;

Console.WriteLine("Monitoring .txt and .log files (including subdirectories)");
Console.ReadLine();
Output (when creating files):
Monitoring .txt and .log files (including subdirectories)
[*.txt] Created: notes.txt
[*.log] Created: app.log
[*.txt] Created: subfolder\readme.txt

Setting IncludeSubdirectories to true monitors the entire directory tree. The e.Name property includes the relative path from the watched folder, so you can see exactly which subdirectory contains the changed file. This is perfect for monitoring upload folders where users can create their own subdirectories.

Common Pitfalls and Solutions

Duplicate events are the most common issue. When you save a file, many applications fire multiple Changed events because they update the file in stages. You might see three or four events for a single user action. To handle this, implement a debouncing mechanism that waits for events to stop before processing. Use a timer that resets with each event, and only process when the timer expires without new events.

Another trap is accessing files immediately in event handlers. When the Created event fires, the file might still be locked by the application writing it. If you try to read it immediately, you'll get IOException. Always wrap file access in retry logic with short delays, or use FileStream options that allow shared reading. Some developers queue file paths and process them on a background thread with retry logic.

Buffer overflows occur when events arrive faster than you can process them. The FileSystemWatcher has an internal buffer that fills up quickly in busy directories. When it overflows, the Error event fires and you miss notifications. Increase InternalBufferSize from the default 8KB to 64KB for busy folders. More importantly, process events quickly by queueing work instead of doing heavy processing in event handlers.

Network paths and remote drives cause unreliable notifications. FileSystemWatcher depends on OS notifications, which don't work consistently over network shares due to caching and latency. If you must monitor network locations, consider periodic polling instead of FileSystemWatcher. For local files synced to cloud storage, the watcher works because changes happen locally first.

Try It Yourself

Build a file processor that automatically processes new text files and moves them to a completed folder. This pattern appears in many real-world scenarios like document processing, file imports, and batch operations.

Program.cs
using System.IO;

var inputFolder = @"C:\Temp\Input";
var processedFolder = @"C:\Temp\Processed";

Directory.CreateDirectory(inputFolder);
Directory.CreateDirectory(processedFolder);

var fileProcessor = new FileProcessor(inputFolder, processedFolder);

Console.WriteLine($"Monitoring: {inputFolder}");
Console.WriteLine($"Processed files go to: {processedFolder}");
Console.WriteLine("Drop .txt files into the input folder");
Console.WriteLine("Press Enter to exit");
Console.ReadLine();

fileProcessor.Dispose();

class FileProcessor : IDisposable
{
    private readonly FileSystemWatcher _watcher;
    private readonly string _processedFolder;
    private readonly Dictionary<string, Timer> _timers = new();
    private readonly object _lock = new();

    public FileProcessor(string inputFolder, string processedFolder)
    {
        _processedFolder = processedFolder;
        _watcher = new FileSystemWatcher(inputFolder)
        {
            Filter = "*.txt",
            NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite
        };

        _watcher.Created += OnFileChanged;
        _watcher.Changed += OnFileChanged;
        _watcher.EnableRaisingEvents = true;
    }

    private void OnFileChanged(object sender, FileSystemEventArgs e)
    {
        lock (_lock)
        {
            // Debounce: reset timer for this file
            if (_timers.TryGetValue(e.FullPath, out var existingTimer))
            {
                existingTimer.Change(500, Timeout.Infinite);
            }
            else
            {
                var timer = new Timer(_ => ProcessFile(e.FullPath),
                    null, 500, Timeout.Infinite);
                _timers[e.FullPath] = timer;
            }
        }
    }

    private void ProcessFile(string filePath)
    {
        try
        {
            // Wait a moment for file to be fully written
            Thread.Sleep(100);

            // Read with shared access in case writer still has handle
            using var stream = new FileStream(filePath, FileMode.Open,
                FileAccess.Read, FileShare.ReadWrite);
            using var reader = new StreamReader(stream);

            var content = reader.ReadToEnd();
            var lineCount = content.Split('\n').Length;

            Console.WriteLine($"\nProcessed: {Path.GetFileName(filePath)}");
            Console.WriteLine($"  Lines: {lineCount}");

            // Move to processed folder
            var destPath = Path.Combine(_processedFolder,
                Path.GetFileName(filePath));

            // Close before moving
            stream.Close();

            File.Move(filePath, destPath, overwrite: true);
            Console.WriteLine($"  Moved to: {destPath}");

            // Cleanup timer
            lock (_lock)
            {
                if (_timers.Remove(filePath, out var timer))
                {
                    timer.Dispose();
                }
            }
        }
        catch (IOException ex)
        {
            Console.WriteLine($"Error processing {filePath}: {ex.Message}");
            // Could retry here
        }
    }

    public void Dispose()
    {
        _watcher?.Dispose();
        lock (_lock)
        {
            foreach (var timer in _timers.Values)
            {
                timer.Dispose();
            }
            _timers.Clear();
        }
    }
}
Output (when dropping test.txt into input folder):
Monitoring: C:\Temp\Input
Processed files go to: C:\Temp\Processed
Drop .txt files into the input folder

Processed: test.txt
  Lines: 5
  Moved to: C:\Temp\Processed\test.txt
Project.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

This example demonstrates debouncing with timers to handle duplicate events, safe file reading with shared access, and proper cleanup. The processor waits 500ms after the last event before processing, which handles multiple change notifications gracefully.

Performance at Scale

When monitoring busy directories, performance becomes critical. The internal buffer size directly affects reliability. The default 8KB buffer fills quickly when hundreds of files change per second. Increase InternalBufferSize to 64KB or higher for production systems. Monitor the Error event to detect buffer overflows and log them as critical issues.

Event handler performance matters because handlers run on threadpool threads. Long-running operations in handlers block those threads and can cause buffer overruns. Instead, queue file paths to a channel or concurrent collection and process them on dedicated worker threads. This keeps handlers fast and prevents the watcher from falling behind.

For extremely high-volume scenarios, consider batching. Collect events over a short window like 100ms, then process unique files once. Many duplicate events arrive for the same file, so batching reduces redundant work. You can use a ConcurrentDictionary to track unique file paths and process them in batches.

Memory management is important for long-running watchers. Disable and re-enable the watcher periodically to clear internal state, or dispose and recreate it. This prevents memory leaks in edge cases where the watcher accumulates internal buffers or event subscriptions. Always implement IDisposable properly and dispose watchers when your application shuts down.

Frequently Asked Questions (FAQ)

Why does FileSystemWatcher sometimes fire multiple events for a single change?

Applications often write files in multiple steps, triggering several events. A text editor might create a temp file, write content, then rename it. Each step fires an event. You can debounce events by collecting them in a short time window or tracking the last modified timestamp to filter duplicates.

Can FileSystemWatcher monitor network drives or cloud folders?

FileSystemWatcher works on network drives but can be unreliable due to network latency and caching. Cloud sync folders like OneDrive or Dropbox may not trigger events consistently. For network locations, consider polling with periodic directory scans as a more reliable alternative.

How do I handle FileSystemWatcher events on a background thread?

FileSystemWatcher events fire on a threadpool thread by default. For long-running operations, queue work to a separate background thread or use Task.Run to avoid blocking the watcher. Set SynchronizingObject in WinForms apps to marshal events to the UI thread automatically.

What happens if events arrive faster than I can process them?

The internal buffer can overflow, causing the Error event to fire and missed notifications. Increase InternalBufferSize from the default 8KB to 64KB or higher for busy directories. Process events quickly and offload work to background queues to prevent buffer overruns.

Back to Articles