AutoShelf - Auto-organize files on your Mac | Product Hunt AutoShelf - Auto-organize files on your Mac | Product Hunt
← Back to journey

DispatchSource Over FSEvents: File Watching for 1-10 Folders

After trying both FSEvents and DispatchSource, I settled on DispatchSource for file system monitoring in AutoShelf. Here is why.

DispatchSource Over FSEvents: File Watching for 1-10 Folders

When I started building AutoShelf, the first big technical question was: how do I watch folders for changes?

macOS has two main APIs for this. FSEvents is the heavyweight option. It gives you a daemon-managed event stream with coalescing, historical events, and the ability to watch entire directory trees. It is what tools like Hazel use.

DispatchSource is the lightweight option. It uses kernel events directly through Grand Central Dispatch. It watches a single file descriptor and tells you when something happens.

Most people would reach for FSEvents. It is more powerful. It is more feature-rich. It is the recommended approach for file system monitoring on macOS.

I chose DispatchSource. Here is why.

The Scale Argument

AutoShelf watches 1 to 10 folders. That is it. The typical user sets up a rule for Downloads, one for Desktop, maybe one for a Screenshots folder. Nobody is watching their entire home directory.

FSEvents shines when you need to monitor hundreds or thousands of paths. It has a daemon that coalesces events and delivers them efficiently at scale. For 5 folders, that overhead is pointless.

DispatchSource creates one file descriptor per watched directory and delivers events through GCD. It is incredibly lightweight. No daemon, no overhead, no complex setup.

The Implementation

The file watcher is about 80 lines of Swift. Here is the core of it:

class FileWatcher {
    private var sources: [URL: DispatchSourceFileSystemObject] = [:]
    private var fileDescriptors: [URL: Int32] = [:]
    private let lock = NSLock()
    private let queue = DispatchQueue(label: "com.autoshelf.filewatcher")

    func startWatching(folder: URL) {
        let fd = open(folder.path, O_EVTONLY)
        guard fd >= 0 else { return }

        let source = DispatchSource.makeFileSystemObjectSource(
            fileDescriptor: fd,
            eventMask: [.write, .delete, .rename],
            queue: queue
        )

        source.setEventHandler { [weak self] in
            // Map kernel events to file events
            let flags = source.data
            if flags.contains(.delete) {
                self?.handleEvent(.removed, folder: folder)
            } else if flags.contains(.rename) {
                self?.handleEvent(.renamed, folder: folder)
            } else if flags.contains(.write) {
                self?.handleEvent(.created, folder: folder)
            }
        }

        source.setCancelHandler {
            close(fd)
        }

        lock.lock()
        sources[folder] = source
        fileDescriptors[folder] = fd
        lock.unlock()

        source.resume()
    }
}

The key detail is O_EVTONLY. This opens the directory in a special mode where you only receive events. You cannot read or write through the descriptor. The kernel uses it solely to deliver notifications. This is important for sandbox compliance and resource efficiency.

Mapping Kernel Events to File Events

Kernel events are low level. The dispatch source tells you that something changed in the directory, but it does not tell you which file changed or what specifically happened.

When I get a .write event on a watched directory, it could mean a file was created, a file was modified, or metadata changed. I treat all of these as a "something changed" signal and then enumerate the directory contents to figure out what is new.

This is where the debounce timer comes in. When a file is being downloaded or copied, it can trigger multiple write events in rapid succession. If I processed every event immediately, I would try to move a file while it is still being written.

The Debounce System

Every folder gets a 500-millisecond debounce timer. When an event comes in, I cancel any existing timer for that folder and start a new one. After 500ms of no new events, I enumerate the folder and look for new files.

private var debounceTimers: [URL: Task<Void, Never>] = [:]

func handleFileEvent(folder: URL, eventType: FileEvent.EventType) {
    guard eventType == .created else { return }

    debounceTimers[folder]?.cancel()
    debounceTimers[folder] = Task {
        try? await Task.sleep(nanoseconds: 500_000_000)
        await processNewFiles(in: folder)
    }
}

Using Swift's new Task.sleep for the delay is much cleaner than the old DispatchQueue.asyncAfter approach. The task cancellation handles the edge case where the user is still copying files.

The Processed Files Set

There is a subtle problem with the enumeration approach. When you enumerate a folder and find 10 files, how do you know which ones are new?

I maintain a Set<String> of file paths that have already been processed. When a new event comes in, I diff the current folder contents against this set. Anything in the folder but not in the set is a new file.

This set can grow large over time, so I cap it at 10,000 entries and trim the oldest ones when it gets full. In practice, users do not accumulate that many files in their watched folders before cleaning up.

Why Not FSEvents for Phase 2?

I keep asking myself if I will need to switch to FSEvents as the app grows. For now, the answer is no.

FSEvents would be necessary if I wanted to:

  • Watch nested directory trees recursively (currently I watch flat folders)
  • Get historical events since the last launch (DispatchSource only delivers live events)
  • Monitor hundreds of folders at once

None of these are on the roadmap for the MVP. If AutoShelf grows into a power-user tool that needs recursive watching, I will add FSEvents as a backend option. But the DispatchSource approach handles 95% of use cases with much less code.

One Quirk I Hit

There is one DispatchSource behavior that tripped me up. When a watched folder is moved or deleted, the dispatch source fires a .delete event and then becomes invalid. You cannot reuse it. You have to recreate the source from scratch.

This means if the user moves their Downloads folder while the app is running, the watcher breaks silently. I handle this by checking if the source has been cancelled after a delete event and restarting the watch if the folder still exists at the new path.

It does not come up often in practice, but it was a nasty bug when I first shipped it. Files stopped being organized and nobody knew why.