14,400 Seconds: Two Plists and One Less Thing to Think About
14,400. That is the StartInterval in seconds I put in the launchd plist for my content dashboard generator. Four hours. Not a cron expression, not a while True loop with a sleep, not a subprocess backgrounded from a terminal session I will inevitably kill by accident. A proper macOS daemon.
Here is the actual problem this solved. I had a FastAPI server and a generator job that needed to stay alive and run on a schedule. The naive path is to open two terminal tabs, run both, and forget. That works until the next reboot. Or until you close the laptop lid in the wrong way. Or until macOS decides to zap the process group. The slightly less naive path is to write a shell script with nohup and redirect stdout to a log file. That works until you need to restart one process without the other, and now you are grepping for PIDs and feeling bad about your life choices.
launchd is the right answer for exactly this use case. macOS uses it for everything. It is stable, it survives reboots automatically, it handles stdout and stderr logging without any effort from you, and KeepAlive means I never have to think about whether the server is up. The OS restarts it if it crashes. That is the deal.
Two plists. com.arihantdeva.content dashboard runs a uvicorn process on port 8766 with KeepAlive set to true. The OS keeps it alive. I do not need to check on it. com.arihantdeva.content dashboard generator runs the draft generation job every 14,400 seconds. Not a cron job because cron on macOS is second class and launchd handles it better. Not a while True loop because that ties up a process and couples the scheduling to the process lifecycle in ways that will bite you.
The tradeoff is that launchd plists are verbose. There is no way around the XML. A cron expression is five characters. The launchd equivalent for “every four hours” is seven lines of XML, and if you get the structure slightly wrong, the plist loads silently and does nothing. That is the failure mode that hurts: launchctl load returns success, the job never runs, and you spend twenty minutes staring at the wrong thing. The fix is running
launchctl list | grep your label
right after load to confirm the process ID shows up. If the PID column is blank, it failed.
The other tradeoff: debugging launchd jobs is painful if you do not set StandardOutPath and StandardErrorPath in the plist. Do it. Point them at files in /tmp or wherever you keep logs. A daemon that fails silently is worse than a daemon that does not exist.
What I would do differently: set those log paths from the start instead of adding them after the first silent failure. And I would write a one liner shell script to unload, reload, and tail the log in one command. The load/unload cycle is the most common operation when you are iterating, and doing it by hand three times in a row makes you want to go back to cron.
The broader lesson is that background jobs on macOS have a right answer that most people avoid because the XML feels like overkill. It is not overkill. Once the plist is written, you never touch it again. The server runs. The generator runs every four hours. Nothing breaks on reboot. That is the whole point.
Write a comment