Performance Tuning Guide
May 24, 2026 · View on GitHub
This guide helps you optimize FSWatcher performance for your specific use case.
Understanding Performance Characteristics
Event-Driven vs Polling
FSWatcher uses an event-driven architecture that provides significant performance advantages:
// ❌ Polling approach (inefficient)
Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
let contents = try? FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil)
// Compare with previous state...
}
// ✅ FSWatcher approach (efficient)
let watcher = try DirectoryWatcher(url: url)
watcher.onDirectoryChange = { changedURL in
// Only called when actual changes occur
}
Key Performance Metrics
- CPU Usage: Minimal when idle, scales with actual file system activity
- Memory Usage: ~50KB base + ~1KB per watched directory
- Latency: Typically <10ms from file system event to callback
- Throughput: Can handle thousands of events per second
Debouncing Configuration
Debouncing is crucial for performance when dealing with rapid file changes.
Choosing Debounce Intervals
var config = DirectoryWatcher.Configuration()
// High-frequency scenarios (log processing, real-time monitoring)
config.debounceInterval = 0.05 // 50ms
// Standard scenarios (development, content management)
config.debounceInterval = 0.5 // 500ms (default)
// Batch processing scenarios
config.debounceInterval = 2.0 // 2 seconds
// Low-priority monitoring
config.debounceInterval = 5.0 // 5 seconds
Dynamic Debouncing
Adjust debouncing based on activity level:
class AdaptiveWatcher {
private let watcher: DirectoryWatcher
private var eventCount = 0
private var lastEventTime = Date()
init(url: URL) throws {
self.watcher = try DirectoryWatcher(url: url)
watcher.onDirectoryChange = { [weak self] url in
self?.adaptDebouncing()
self?.processChange(url)
}
}
private func adaptDebouncing() {
let now = Date()
let timeSinceLastEvent = now.timeIntervalSince(lastEventTime)
if timeSinceLastEvent < 1.0 {
// High frequency - increase debouncing
watcher.configuration.debounceInterval = min(2.0, watcher.configuration.debounceInterval * 1.5)
} else if timeSinceLastEvent > 10.0 {
// Low frequency - decrease debouncing
watcher.configuration.debounceInterval = max(0.1, watcher.configuration.debounceInterval * 0.8)
}
lastEventTime = now
}
}
Filter Optimization
Efficient filtering reduces processing overhead significantly.
Filter Performance Hierarchy
From fastest to slowest:
- Extension filters: Fast string comparison
- Size filters: Single file system call
- UTType filters: Type system lookup
- Modification time filters: File system metadata access
- Custom predicate filters: Depends on implementation
Optimized Filter Chains
// ❌ Inefficient order (expensive filters first)
config.filterChain.add(.custom { url in
// Complex custom logic...
return expensiveCheck(url)
})
config.filterChain.add(.fileExtensions(["jpg", "png"]))
// ✅ Efficient order (cheap filters first)
config.filterChain.add(.fileExtensions(["jpg", "png"])) // Fast elimination
config.filterChain.add(.fileSize(1024...)) // Quick metadata check
config.filterChain.add(.custom { url in // Only for remaining files
return expensiveCheck(url)
})
Precompiled Filters
Cache complex filters:
class OptimizedWatcher {
// Precompile expensive filters
private static let imageFilter = FileFilter.imageFiles
private static let sizeFilter = FileFilter.fileSize(1024...)
private static let combinedFilter = imageFilter.and(sizeFilter)
init(url: URL) throws {
var config = DirectoryWatcher.Configuration()
config.filterChain.add(Self.combinedFilter)
let watcher = try DirectoryWatcher(url: url, configuration: config)
}
}
Queue Configuration
Choose appropriate queues for your workload.
Queue Selection Guidelines
var config = DirectoryWatcher.Configuration()
// CPU-intensive processing (image/video processing)
config.queue = .global(qos: .userInitiated)
// I/O intensive processing (file copying, network operations)
config.queue = .global(qos: .utility)
// Background processing (logging, analytics)
config.queue = .global(qos: .background)
// UI updates (immediate user feedback required)
config.queue = .main
// Custom queue for fine-grained control
config.queue = DispatchQueue(label: "file-processing", qos: .userInitiated, attributes: .concurrent)
Concurrent Processing
Handle multiple files concurrently:
watcher.onFilteredChange = { files in
let processingQueue = DispatchQueue(label: "file-processing", attributes: .concurrent)
let group = DispatchGroup()
for file in files {
group.enter()
processingQueue.async {
defer { group.leave() }
processFile(file)
}
}
group.notify(queue: .main) {
print("All files processed")
}
}
Memory Management
Optimize memory usage for long-running applications.
Ignore List Management
class MemoryEfficientWatcher {
private let watcher: DirectoryWatcher
private let cleanupTimer: Timer
init(url: URL) throws {
self.watcher = try DirectoryWatcher(url: url)
// Periodic cleanup to prevent memory growth
self.cleanupTimer = Timer.scheduledTimer(withTimeInterval: 300, repeats: true) { _ in
self.watcher.configuration.ignoreList.cleanup()
}
// Limit ignore list size
watcher.onDirectoryChange = { [weak self] url in
guard let self = self else { return }
if self.watcher.configuration.ignoreList.ignoredCount > 10000 {
self.watcher.configuration.ignoreList.clearIgnored()
}
self.processChange(url)
}
}
deinit {
cleanupTimer.invalidate()
watcher.stop()
}
}
Resource Pooling
Reuse expensive resources:
class PooledProcessor {
private let imageProcessingQueue = OperationQueue()
private let urlSession = URLSession.shared
init() {
imageProcessingQueue.maxConcurrentOperationCount = 4
imageProcessingQueue.qualityOfService = .userInitiated
}
func processFiles(_ files: [URL]) {
for file in files {
let operation = BlockOperation {
self.processFile(file)
}
imageProcessingQueue.addOperation(operation)
}
}
}
Recursive Monitoring Optimization
Optimize recursive monitoring for large directory trees.
Backend Selection
RecursiveWatchOptions defaults to .dispatchSource for compatibility with
FSWatcher 0.1.x. That backend opens one O_EVTONLY file descriptor per watched
directory, which is precise and works on macOS/iOS, but it does not scale well to
thousands of subdirectories.
On macOS, large recursive trees should opt into the FSEvents backend:
let options = RecursiveWatchOptions(
maxDepth: 5,
backend: .fsevents
)
let watcher = try RecursiveDirectoryWatcher(url: directoryURL, options: options)
watcher.start(on: .global(qos: .utility))
FSEvents watches the root hierarchy with one stream. Exact file changes are
surfaced through onFileChange, fileChangePublisher, and fileChanges.
For backward compatibility, the watcher can also perform a bounded snapshot
under changed directories so deep files are surfaced through onFilteredChange.
For large image libraries, sync folders, or any app that only needs the file that changed, disable directory snapshots and consume file-level events:
var configuration = DirectoryWatcher.Configuration()
configuration.scansChangedDirectoriesForFilteredEvents = false
let watcher = try RecursiveDirectoryWatcher(
url: directoryURL,
options: options,
configuration: configuration
)
watcher.onFileChange = { event in
guard event.itemKind == .file, event.eventType != .deleted else { return }
process(event.url)
}
Use .automatic if you want the library to select FSEvents on macOS and
DispatchSource elsewhere. .automatic falls back to DispatchSource when
followSymlinks is enabled because FSEvents does not follow symlinked
directories as independent recursive roots.
Depth Limiting
var options = RecursiveWatchOptions()
// Limit depth for performance
options.maxDepth = 5 // Prevent excessive nesting
// Strategic exclusions
options.excludePatterns = [
".git", // Version control
"node_modules", // Package dependencies
"build", // Build artifacts
".build", // Swift build artifacts
"DerivedData", // Xcode artifacts
"*.xcworkspace", // Xcode workspace internals
"Pods", // CocoaPods
".tmp", // Temporary directories
"cache", // Cache directories
"log", // Log directories
]
FD Ceiling for Sandboxed Processes
For the DispatchSource backend, each watched directory holds an O_EVTONLY file
descriptor. On sandboxed macOS apps the per-process limit is low (~256). Use
maxWatchedDirectories to cap the number of simultaneously watched directories
and prevent FD exhaustion:
var options = RecursiveWatchOptions()
options.maxDepth = 5
options.maxWatchedDirectories = 200 // Leave headroom for the rest of the app
let watcher = try RecursiveDirectoryWatcher(url: directoryURL, options: options)
watcher.onError = { error in
if case .tooManyWatchers(let limit) = error {
// Log or report — deeper subdirectories are silently skipped
print("Hit watcher ceiling: \(limit)")
}
}
maxWatchedDirectories does not apply to the FSEvents backend.
Stress Runner
Use the Swift stress runner to compare backends:
swift run FSWatcherStress --dirs 1000 --files-per-dir 100 --backend fsevents --max-depth 2
swift run FSWatcherStress --dirs 1000 --backend dispatch --max-watchers 256 --timeout 3
swift run FSWatcherStress --dirs 1000 --backend dispatch --max-watchers 2048
Selective Monitoring
Monitor only what you need:
class SelectiveRecursiveWatcher {
private var watchers: [DirectoryWatcher] = []
func watchProject(at url: URL) throws {
// Only watch specific subdirectories
let importantPaths = [
"Sources",
"Tests",
"Resources"
]
for path in importantPaths {
let subdirectory = url.appendingPathComponent(path)
if subdirectory.isDirectory {
let watcher = try DirectoryWatcher(url: subdirectory)
watcher.addFilter(.fileExtensions(["swift", "m", "h"]))
watchers.append(watcher)
}
}
watchers.forEach { \$0.start() }
}
}
Benchmarking and Monitoring
Monitor performance to identify bottlenecks.
Performance Monitoring
class MonitoredWatcher {
private let watcher: DirectoryWatcher
private var metrics = WatcherMetrics()
struct WatcherMetrics {
var eventCount = 0
var totalProcessingTime: TimeInterval = 0
var averageProcessingTime: TimeInterval {
totalProcessingTime / Double(max(eventCount, 1))
}
var eventsPerSecond: Double = 0
var startTime = Date()
}
init(url: URL) throws {
self.watcher = try DirectoryWatcher(url: url)
watcher.onDirectoryChange = { [weak self] url in
self?.recordEvent {
self?.processChange(url)
}
}
// Periodic reporting
Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { _ in
self.reportMetrics()
}
}
private func recordEvent(processing: () -> Void) {
let startTime = Date()
processing()
let processingTime = Date().timeIntervalSince(startTime)
metrics.eventCount += 1
metrics.totalProcessingTime += processingTime
let elapsed = Date().timeIntervalSince(metrics.startTime)
metrics.eventsPerSecond = Double(metrics.eventCount) / elapsed
}
private func reportMetrics() {
print("""
FSWatcher Metrics:
- Events processed: \(metrics.eventCount)
- Average processing time: \(String(format: "%.3f", metrics.averageProcessingTime * 1000))ms
- Events per second: \(String(format: "%.1f", metrics.eventsPerSecond))
""")
// Alert if performance degrades
if metrics.averageProcessingTime > 0.1 {
print("⚠️ Processing time is high")
}
if metrics.eventsPerSecond > 1000 {
print("⚠️ Very high event frequency")
}
}
}
Profiling Tools
Use Xcode Instruments to profile:
// Enable detailed profiling in debug builds
#if DEBUG
class ProfilingWatcher {
private let watcher: DirectoryWatcher
init(url: URL) throws {
self.watcher = try DirectoryWatcher(url: url)
watcher.onDirectoryChange = { url in
os_signpost(.event, log: .default, name: "DirectoryChanged", "URL: %@", url.path)
// Your processing code here
}
}
}
#endif
Performance Best Practices
1. Right-size Your Configuration
// ✅ Good: Tailored to use case
var config = DirectoryWatcher.Configuration()
config.debounceInterval = 0.5 // Appropriate for use case
config.filterChain.add(.imageFiles) // Only watch what you need
config.queue = .global(qos: .utility) // Match processing requirements
// ❌ Bad: One-size-fits-all
var config = DirectoryWatcher.Configuration()
// Using all defaults without consideration
2. Efficient Event Handling
// ✅ Good: Efficient processing
watcher.onFilteredChange = { files in
// Process in batches
let batches = files.chunked(into: 10)
for batch in batches {
processBatch(batch)
}
}
// ❌ Bad: Inefficient processing
watcher.onDirectoryChange = { _ in
// Scanning entire directory on every change
let allFiles = try! FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil)
for file in allFiles {
processFile(file) // Processing everything
}
}
3. Resource Lifecycle Management
class WellManagedWatcher {
private let watcher: DirectoryWatcher
init(url: URL) throws {
self.watcher = try DirectoryWatcher(url: url)
}
func start() {
watcher.start()
}
func pause() {
watcher.stop() // Free resources when not needed
}
func resume() {
watcher.start()
}
deinit {
watcher.stop() // Ensure cleanup
}
}
Troubleshooting Performance Issues
Common Performance Problems
- High CPU usage: Check debounce interval and filter efficiency
- Memory growth: Monitor ignore list size and cleanup frequency
- High latency: Verify queue configuration and processing complexity
- Thread contention: Ensure appropriate queue usage
Diagnostic Commands
// Check current configuration
print("Debounce interval: \(watcher.configuration.debounceInterval)")
print("Filter count: \(watcher.configuration.filterChain.count)")
print("Ignored files: \(watcher.configuration.ignoreList.ignoredCount)")
// Monitor queue usage
print("Current queue: \(watcher.configuration.queue.label)")
Platform-Specific Optimizations
macOS Optimizations
#if os(macOS)
// Take advantage of macOS-specific features
var config = DirectoryWatcher.Configuration()
config.eventMask = [.write, .extend] // Reduce event types if possible
#endif
iOS Optimizations
#if os(iOS)
// iOS-specific optimizations
var config = DirectoryWatcher.Configuration()
config.debounceInterval = 1.0 // Higher debouncing for mobile
config.queue = .global(qos: .background) // Be conservative with resources
#endif
Remember: Always measure performance before and after optimizations to ensure they're effective for your specific use case.