Cron vs systemd daemon: which one for Node.js?

On the same machine, two Node.js scripts run in the background. The first publishes to dev.to at 9am and LinkedIn at 10am — a cron, three lines of config. The second watches a job queue every 30 seconds, keeps state in memory, and reacts to user interface actions within seconds — a daemon supervised by systemd.

Same language, same server, apparently the same goal: run tasks in the background. Yet the choice is different. It's not a matter of taste or habit — it's that the two problems only look similar from a distance.

The simple case: a cron for publishing

The publishing cron is deliberately boring. A user crontab entry:

0 9 * * * bash /home/folken/work/cv/scripts/devto-cron.sh >> logs/devto-cron.log 2>&1
0 10 * * * /usr/bin/node /home/folken/work/cv/scripts/linkedin-cron.js >> logs/linkedin-cron.log 2>&1

The shell script loads environment variables from a .env file and calls the Node.js script. That script reads devto-schedule.json, picks the first article with drafted status, calls the dev.to API, updates the file, and exits. Runtime: a few seconds. On failure, the log contains the error, the article stays in queue for the next day.

Why does cron work here? Because the task is atomic. It doesn't need to know what happened yesterday. It doesn't need to react to an external event. It won't overlap with itself. A process starts, does its job, stops. That's exactly what cron was designed for.

Where cron starts to fall short

The automated monitoring system poses a different problem. There are four active monitors (crypto, tech, Epstein, retro), each with its own frequency (from 6 hours to 7 days). The admin interface allows manually triggering a monitor from the browser. A "generate" job can be queued at any time to reconfigure a monitor via Claude.

With a cron, you quickly run into several problems:

  • Minimum 1-minute granularity. Can't react in 5 or 10 seconds to a job queued from the UI. The user clicks, waits, sees nothing happen.
  • No state between runs. To know when each monitor last ran, you need to read it from a file at each startup. Not catastrophic, but it complicates coordination between multiple monitors sharing the same resources.
  • No job queue. If two crons trigger simultaneously and both try to write the same files, you need to handle overlap with flock or equivalent — which works, but requires attention.
  • No native structured logging. Stdout redirected to a file becomes unreadable quickly when multiple monitors write to the same log in parallel.

None of these limits are individually insurmountable. But stacked together, they signal that you're trying to make cron do something outside its domain.

The daemon: a process that never sleeps

veille-daemon.js runs continuously. It polls jobs.json every 30 seconds for on-demand jobs, and cron-config.json every 60 seconds to trigger scheduled monitors whose frequency has elapsed. It keeps the monitor registry and current run state in memory.

When the user clicks "Rerun" in the interface, PHP writes a job to jobs.json. The daemon picks it up at the next poll (30 seconds worst case), executes it, and updates the status. The interface can display progress in real time via a /veille/status endpoint.

systemd supervises the daemon with a minimal .service file:

[Unit]
Description=Veille daemon
After=network.target

[Service]
Type=simple
WorkingDirectory=/home/folken/work/cv
ExecStart=/usr/bin/node scripts/veille-daemon.js
Restart=on-failure
RestartSec=10

[Install]
WantedBy=default.target

What this concretely provides: automatic restart on crash, logs in journald (journalctl --user -u veille-daemon -f), automatic startup on boot with systemctl --user enable veille-daemon. No more manual restart scripts, no more separate log file to watch.

systemd timer: the middle ground

There's a third option that often gets overlooked: the systemd timer. It's essentially an enhanced cron — a periodic task, but supervised by systemd. On this project, crypto-veille.timer is a legacy example:

# crypto-veille.service
[Unit]
Description=Crypto veille job
After=network.target

[Service]
Type=oneshot
WorkingDirectory=/home/folken/work/cv
ExecStart=/usr/bin/node scripts/crypto-veille.js
# crypto-veille.timer
[Unit]
Description=Crypto veille — every 6h

[Timer]
OnBootSec=5min
OnUnitActiveSec=6h

[Install]
WantedBy=timers.target

Compared to a cron, the systemd timer adds: native logs in journald, the ability to declare dependencies (After=network.target), a boot delay with OnBootSec (cron can fire too early if the machine just rebooted), and more readable calendar expressions than classic cron syntax.

The trade-off: two files to manage instead of one line. For a simple task that doesn't need structured logs and whose minimum frequency is 1 minute, cron remains more direct.

Comparison table

cron systemd timer daemon
Granularity 1 min minimum 1 second free (polling)
Logs manual file native journald native journald
State between runs none none in memory
On-demand reaction no no yes
Crash supervision no yes yes
Config complexity very low medium (2 files) high (code to write)
Ideal use case atomic periodic task periodic task + logs/deps job queue, state, on-demand

Three questions to choose

In practice, three questions are enough to guide the choice:

1. Does the task need to react to an event in under a minute?
If yes: daemon. Cron can't do better than one minute, and neither can a systemd timer (it runs scheduled tasks, not reactive ones).

2. Does it need state between runs?
If yes: daemon. Reading and writing a file at each run works up to a point, but once there are multiple workers or decisions to make based on recent history, a continuous process with in-memory state is cleaner.

3. Do structured logs and supervision matter?
If yes but the first two answers are no: systemd timer. It gives you the systemd benefits (journald, restart, dependencies) without the complexity of writing an event loop.

If all three answers are no: cron. Three lines in the crontab, a redirect to a log file, zero additional infrastructure. Don't over-engineer what doesn't need it.

Conclusion

Both approaches coexist without friction on the same machine. The cron has been publishing articles on schedule for years without ever needing a manual restart. The monitoring daemon took a few days of work to make robust — crash handling, TTL on stuck states, recovery after restart.

It's not cron OR daemon. It's recognizing which one matches the problem at hand. Fixed-schedule publishing doesn't need to react in 30 seconds. Interactive monitoring can't afford to wait a minute between checks. The choice is in the problem, not the technology.

📄 Associated CLAUDE.md

Comments (0)