Monitoring Files and Folders with launchd: WatchPaths in Practice

In this post
In the previous post about launchd, scheduling worked by time: StartCalendarInterval defined “every day at 7 AM” and the system took care of the rest, including recovering missed executions when the Mac was asleep. For periodic tasks like sending a daily briefing or running a maintenance script, that model works perfectly — it is the functional equivalent of cron, but integrated into the macOS lifecycle.
But not every automation makes sense tied to a clock. Some tasks only need to happen when something changes. A backup that runs every hour is wasting 23 executions per day if the database was only modified once. An image conversion that runs every 5 minutes has nothing to convert most of the time, and when it finally does, up to 5 minutes have passed since the file appeared. The time-based model works, but it is polling disguised as scheduling — and polling is almost always the least elegant solution to any synchronization problem.
launchd offers an alternative that works in a fundamentally different way: instead of asking “what time is it?”, it asks “did something change?”. The WatchPaths key accepts a list of paths — files or directories — and fires the job when any of them is modified. It is not an interval. It is not cron in disguise. It is a reactive trigger based on filesystem events that turns launchd into a kind of Linux inotifywait, but declarative and integrated into the operating system.
This post explores two practical scenarios for this capability. The first is an automatic SQLite backup that syncs with a remote server whenever the database is modified, with a throttle mechanism to avoid hammering the destination on every save. The second is an image converter that watches a folder and transforms PNGs and JPGs into optimized formats as soon as they appear. The scripts themselves are covered in dedicated posts — the image conversion script is already published. The focus here is on the launchd mechanics and plist construction.
From Schedules to Events: the Other Side of launchd#
The StartCalendarInterval from the previous post and the WatchPaths we will use here are mutually exclusive in the same plist. Each LaunchAgent has a single activation trigger: it either fires by time, or by a filesystem change, or by another condition like StartInterval (a simple timer in seconds) or KeepAlive (which keeps the process running continuously and restarts it if it dies). Mixing StartCalendarInterval with WatchPaths in the same plist does not produce a syntax error, but the resulting behavior is undefined and Apple’s documentation does not guarantee which one takes precedence.
The restriction makes sense when you think about launchd’s mental model: each plist describes a job, and each job has a reason to exist — a condition that brings it to life. If you need a script that runs at 7 AM and when a file changes, the solution is to create two plists pointing to the same script (or to different scripts that share the same logic). It seems redundant compared to a systemd timer that accepts multiple OnCalendar and PathChanged in the same unit, but in practice the separation keeps jobs simple and makes debugging easier — each plist does one thing, and when something breaks, the problem is isolated in a single file.
WatchPaths: How It Works#
The WatchPaths syntax is an array of strings in the plist, where each string is the absolute path of a file or directory. When launchd detects a modification on any of the listed paths, it executes the job. Detection uses the kernel’s kqueue — the same mechanism that fswatch and FSEvents use under the hood — so there is no polling involved. The overhead is zero while nothing changes, and the reaction is practically instantaneous when something does.
Here is the minimal form of a plist with WatchPaths:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.example.watchpaths</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/janio/bin/my-script.sh</string>
</array>
<key>WatchPaths</key>
<array>
<string>/Users/janio/data/file.db</string>
</array>
</dict>
</plist>
Every time /Users/janio/data/file.db is modified — whether by a direct write, by a mv that replaces the file, or by any operation that changes its metadata — launchd executes /Users/janio/bin/my-script.sh. No loop needed, no while true, no resident process consuming memory. The script runs, does what it needs to do, and exits. On the next modification, launchd runs it again.
One point that is not obvious in the documentation: when the monitored path is a directory, launchd fires when any file inside it is created, removed, or renamed, but not necessarily when the content of an existing file is modified without changing the directory structure. The exact behavior depends on how the application writing the file implements the write — some editors create a temporary file and do an atomic rename (which modifies the directory), while others write directly to the existing file (which may not trigger the directory watch). To monitor the content of a specific file, the most reliable practice is to point WatchPaths directly at the file, not at the directory containing it.
It is also worth knowing that WatchPaths is not recursive. Monitoring /Users/janio/data/ detects changes at the immediate level of that directory, but not in subfolders. If the directory structure matters, each relevant path needs to be listed explicitly in the array. This is a limitation compared to Linux’s inotifywait --recursive, but in practice most use cases involve watching one or two specific paths, not an entire tree.
Scenario 1: Reactive SQLite Backup#
The Problem#
SQLite databases are files. This is simultaneously SQLite’s greatest quality and greatest risk: there is no intermediary server, no daemon managing connections, no built-in replication. The database is a .db file on disk, and if that file gets corrupted or the disk fails, the data goes with it. For personal applications that store data that matters — a task manager, an RSS reader, a home inventory — the absence of automatic backup is a silent time bomb that only goes off at the worst possible moment.
The traditional approach would be to schedule a periodic backup with StartCalendarInterval — say, every hour. It works, but has two problems. First, most executions will copy a database that hasn’t changed since the last backup, wasting time and bandwidth with the remote destination. Second, if you make a series of important changes and the laptop dies before the next full hour, the changes are lost. The fixed interval creates a vulnerability window proportional to its frequency, and reducing the frequency to one minute turns the backup into aggressive polling that consumes resources needlessly.
With WatchPaths, the model changes: the backup only runs when the database is actually modified. No change, no process runs. With a change, the script fires in seconds. The vulnerability window drops from “up to an hour” to “up to a few seconds,” and the resource cost drops to zero during periods of inactivity.
The Script Logic#
The backup script does four things in order: copy the relevant files to a local staging directory, force a WAL checkpoint on the SQLite database to ensure consistency, sync the staging with the remote destination via rclone, and record the execution time for throttle control.
The staging directory exists to prevent rclone from reading the database directly while it is being written. SQLite in WAL mode (Write-Ahead Logging) maintains a -wal file alongside the main database with transactions not yet consolidated. Copying the .db without first running a PRAGMA wal_checkpoint(TRUNCATE) can result in an inconsistent backup — the main file missing the latest transactions and the WAL absent or truncated. The checkpoint forces WAL consolidation into the main database before the copy, and staging ensures that rclone works with a static snapshot, not a file that might change during upload.
The remote destination in this case is a Backblaze B2 bucket, but the logic is identical for any remote that rclone supports — S3, Google Drive, SFTP, a local NAS. rclone sync handles transferring only the blocks that changed, so even a multi-megabyte database generates minimal traffic when the changes are small.
The Plist#
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.janio.fuqu-backup</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/janio/bin/fuqu-backup.sh</string>
</array>
<key>WatchPaths</key>
<array>
<string>/Users/janio/.local/share/fuqu/fuqu.db</string>
</array>
<key>StandardOutPath</key>
<string>/Users/janio/.local/share/fuqu/backup.log</string>
<key>StandardErrorPath</key>
<string>/Users/janio/.local/share/fuqu/backup.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>
</dict>
</plist>
The WatchPaths points directly at the fuqu.db file, not at the directory containing it. As mentioned in the previous section, monitoring the directory might not capture writes made directly to the file by SQLite — especially in WAL mode, where the initial write goes to the -wal and is only later consolidated into the main database. Pointing at the .db ensures that launchd fires when the database itself is modified, including after an automatic SQLite checkpoint.
The PATH in EnvironmentVariables includes /opt/homebrew/bin because that is where Homebrew installs binaries on Apple Silicon Macs, and rclone comes from there. Without this variable, the script would not find rclone — the same PATH issue that came up in the previous post with fuqu telegram.
Throttle: Why You Cannot Trust launchd Alone#
There is a detail that Apple’s documentation mentions in passing but that has serious practical implications: launchd has an internal throttle of 10 seconds between executions of the same job. If the database is modified twice in quick succession, the second execution is delayed to respect this minimum interval. The value can be adjusted with the ThrottleInterval key in the plist, but only upward — it cannot be reduced below 10 seconds.
For a remote backup, 10 seconds is actually too little. An application that makes many small writes to SQLite — adding a task, marking another as complete, editing a note — can generate dozens of database modifications in a few minutes of active use. Firing an rclone sync for each one does not make sense: each execution has the overhead of connecting to the remote server, authenticating, comparing checksums, and transferring data. On a B2 bucket, each write operation is an API transaction that counts toward the monthly free limit.
The solution is to implement the throttle in the script itself, independent of launchd. The mechanism is simple: the script writes a timestamp to a control file after each successful backup. On the next execution, before doing anything, it reads that timestamp and calculates how much time has passed. If the interval is less than the defined limit — five minutes, for example — the script exits immediately with a log message and exit code 0. launchd sees the execution as successful and goes back to sleep until the next database modification.
The result is a two-layer system: launchd ensures the script is called when the database changes, and the script ensures the actual backup does not happen more than once every five minutes. launchd handles reactivity; the script handles containment. Separating responsibilities this way keeps the plist simple and the business logic where it belongs — in the script, where it can be tested, adjusted, and versioned independently of the operating system configuration.
Scenario 2: Automatic Image Optimization#
The Problem#
Modern image formats like WEBP and AVIF offer significantly better compression than PNG and JPG without perceptible quality loss. A 2 MB PNG becomes a 200 KB AVIF with a visually indistinguishable result; a 5 MB camera JPG drops to less than 1 MB in WEBP. The difference is large enough to matter in any context where images are served — blogs, portfolios, documentation, even email attachments.
The problem is that nobody wants to open a converter manually every time they save an image. Graphical tools like Squoosh exist and work well for one or two images, but conversion needs to become a habit to have real impact, and habits that depend on manual steps die in the second week. What works is removing the human from the process: save the image in a specific directory, and the conversion happens on its own.
On Linux, the classic solution would be an inotifywait loop inside a script monitoring the folder, or a systemd path unit activating a service. On macOS, launchd’s WatchPaths does the same job with fewer moving parts: point at the directory, and launchd notifies when something appears.
The Script Logic#
The script monitors a directory — for example, ~/Pictures/optimize/ — and processes any PNG or JPG file it finds there. For each image found, it generates an AVIF (or WEBP, depending on preference) version, verifies the conversion was successful, and removes the original. The directory works as an inbox: files come in one format, leave in another.
The conversion itself uses command-line tools installable via Homebrew. cwebp (from the webp package) converts to WEBP; avifenc (from the libavif package) converts to AVIF. Both accept quality parameters that let you adjust the balance between size and visual fidelity — a quality value between 75 and 85 is usually the sweet spot for photographic images, producing dramatically smaller files with no visible artifacts to the naked eye.
The script needs to handle a detail that seems minor but causes real problems: files that are still being written. When a browser saves a large image or an application exports a high-resolution PNG, the file appears in the directory before it is complete. If the script fires at that moment and tries to convert a partially written image, the result will be a corrupted AVIF and the original deleted. The simplest protection is to verify the file has stopped growing — compare the size, wait a second or two, compare again — before starting the conversion.
The Plist#
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.janio.image-optimizer</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/janio/bin/optimize-images.sh</string>
</array>
<key>WatchPaths</key>
<array>
<string>/Users/janio/Pictures/optimize</string>
</array>
<key>StandardOutPath</key>
<string>/Users/janio/.local/log/image-optimizer.log</string>
<key>StandardErrorPath</key>
<string>/Users/janio/.local/log/image-optimizer.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>
</dict>
</plist>
Unlike the previous scenario, here WatchPaths points at a directory, not a specific file. This makes sense: the goal is to detect the arrival of new files, and file creation is a modification to the containing directory. launchd fires the script when anything changes in ~/Pictures/optimize/, and the script decides what to do — process PNGs and JPGs, ignore everything else.
The /opt/homebrew/bin in the PATH is where cwebp and avifenc live after being installed by Homebrew. Without this variable, the script would not find the converters.
Dealing with Loops and Filters#
There is an obvious trap in this setup that needs to be handled in the script, not the plist: the script itself creates new files in the monitored directory. When it converts photo.png to photo.avif, the AVIF that appears in ~/Pictures/optimize/ is a modification to the directory — and launchd fires the script again. If the script does not filter files by extension before processing them, it will try to convert the AVIF it just created, fail or produce an absurd result, and potentially enter an infinite loop of executions.
The cleanest solution is for the script to process exclusively files with .png, .jpg, and .jpeg extensions, ignoring everything else. A find with an extension filter at the beginning of the script solves this in one line. If the find returns nothing, the script exits immediately — the execution was triggered by a change that is none of its business, like the creation of an .avif or the removal of an already processed file.
A more robust alternative is to use two separate directories: an input where original images are saved, and an output where optimized files are written. WatchPaths monitors only the input, and the script never writes to it — it only reads and deletes. This separation completely eliminates the possibility of loops, at the cost of an extra folder in the structure. Which approach to use depends on the workflow: if the goal is to drag images to a folder and find them optimized in the same place, the extension filter is enough. If the script is part of a larger pipeline where other processes read the output, separating into two directories is safer.
launchd’s throttle also appears here, but with different implications than the backup scenario. If you drag ten images to the folder at once, launchd fires the script on the first change and then applies the 10-second interval before allowing a new execution. But this is not a problem in practice: the script does not process a single file per execution — it sweeps the entire directory with find and processes everything it finds. The ten images that arrived together are all converted in the first execution. The throttle would only affect the case where new images keep arriving while the conversion of the previous ones is still running, and even then the next execution would catch everything that was left pending.
WatchPaths vs. QueueDirectories#
launchd has a second key for directory monitoring that appears rarely in the documentation and even less in tutorials: QueueDirectories. The syntax is identical to WatchPaths — an array of absolute paths — but the semantics differ in a way that matters for certain workflows.
WatchPaths fires the job when it detects any modification on the monitored paths. It does not matter what changed or whether the path still exists after the change — the event happened, the job runs. If the script deletes the file that triggered the event, launchd does not care. If the monitored directory is empty after the script finishes, launchd does not care. Its job is to detect the change and execute the command; what happens next is the script’s problem.
QueueDirectories adds a condition: the job only fires when the monitored directory is not empty. And furthermore — if the directory is still not empty when the script finishes, launchd runs the job again. The cycle repeats until the directory is empty, at which point launchd goes back to sleep and waits for the next file arrival. It is, literally, a queue: items come in, the job processes them, and it only stops when the queue is empty.
The difference seems subtle but changes how the script needs to be written. With WatchPaths, the script typically sweeps the directory, processes everything it finds, and exits. If something new arrives during execution, launchd fires another execution after the current one finishes (respecting the throttle). With QueueDirectories, the script can afford to process a single item per execution, because launchd will call it again automatically as long as there are pending items. This model simplifies the script — no loops, no find — at the cost of more process invocations.
For the image optimization scenario, QueueDirectories would be a natural choice if using the two-directory approach. The input directory works as a queue: images arrive, the script converts one at a time (or all at once, either way), moves the results to the output directory, and launchd keeps calling the script until the input is empty. The advantage over WatchPaths in this case is that there is no risk of missing files that arrived during a long-running conversion — QueueDirectories guarantees the job runs again if there is still work to do.
For the SQLite backup scenario, QueueDirectories does not make sense. The database never “empties” — it is a file that exists permanently and changes content. The queue logic does not apply to a file that is always present. WatchPaths is the right choice when what matters is the modification event, not the presence or absence of content in a directory.
In practice, WatchPaths is the general-purpose tool and covers most cases. QueueDirectories shines in pipelines where the directory functions as a disposable inbox — upload processing, format conversion, file ingestion — and where the guarantee that no item is left behind matters more than plist simplicity.
What Is Left for Future Posts#
This post showed the mechanics of WatchPaths and QueueDirectories, the plists that make everything work, and the pitfalls that need to be handled in the script — throttle, loops, partially written files, the difference between monitoring a file and monitoring a directory. What was deliberately left out were the scripts themselves.
The SQLite backup script involves decisions that deserve their own space: the correct order of operations to ensure database consistency, the WAL checkpoint, the staging directory construction, and the rclone configuration with a Backblaze B2 remote. Each of these pieces has its gotchas, and cramming everything into a section of a post about launchd would dilute both launchd and the backup.
The image optimization script opens a similar path. The choice between WEBP and AVIF is not obvious — AVIF compresses better but encodes slower, and not every context accepts both formats. The quality parameters in cwebp and avifenc have different behaviors that affect the final result. And the protection logic against incomplete files, which seems trivial described in one paragraph, has more nuance in implementation than the concept suggests.
The image conversion script is already done — complete code with encoder fallback, OS detection, and protection against incomplete files. launchd is the trigger; what it fires is where the real complexity lives.
If you use Linux, I wrote the equivalent of this post with systemd path units and inotifywait — the same scenarios, adapted for the Linux ecosystem.
Sysadmin, 53, Brazilian working from home for the world. Manages Linux servers, LXC containers, and cats that won't get off the keyboard.