Scheduled work is moving out of crontab and into the database
For years the answer to "run this every night" was a line in a crontab. That is changing. More teams now schedule recurring work inside their application, through a durable job queue that keeps its jobs in Postgres. River in Go, Oban in Elixir, and Rails' Solid Queue all shipped releases in the past few months, and each can run periodic jobs on a schedule the way cron does.
What a database-backed queue buys you
A crontab entry is a line of text on one host, with no history and no retries. A database-backed queue gives you a jobs table you can query, retries with backoff, and an enqueue that runs inside the same transaction as your business logic, so a job is never scheduled for work that got rolled back.
What it costs you
Move the schedule into your database and the scheduler becomes application code. It runs in your process and can fail in ways a crontab never could.
New failure modes when the scheduler lives in your app
The periodic enqueuer can stall
River runs a component that inserts periodic jobs on their cadence. Release 0.37.1, on 15 May 2026, fixed a bug in it. A stalled Postgres call, a Begin, Insert, or Commit that hung, could hang the periodic enqueuer with no timeout at all. All periodic job insertion stopped until the process restarted or a new leader was elected. The fix wraps those calls in a 30-second timeout.
From the outside everything looked healthy: the queue up, the dashboard green, and nothing scheduled until someone restarted the process.
The scheduler can crash-loop on a database outage
Oban hit a related problem from the other side. Its internal processes, the cron scheduler among them, run inside transactions with a retry. Oban 2.23.0, on 27 May 2026, documented what happens when a database outage outlasts that retry window: the process crashes, the supervisor restarts it, it crashes again, and that loop can take down the whole supervision tree.
The fix was a new :on_exhausted option. The default still raises, but internal processes like Cron now use :log and write a sustained outage to the log once instead of turning it into a crash loop.
A duplicate run is one config line away
Distributed queues elect a leader to decide who inserts the scheduled work, so two servers do not both fire the same nightly job. That makes exactly-once a promise the library keeps, and one you can break without noticing.
Oban 2.23.0 added compile-time checks for this exact mistake. Its unique option takes a list of job states, and an incomplete list quietly defeats deduplication: miss the insert states, or leave gaps between states, and duplicates slip past. The queue does not raise. It just runs your job twice. At-least-once delivery means a job you assumed ran once can run twice, and only your own idempotency saves you.
Upgrades can pause the schedule
Here is a failure mode with no equivalent in crontab. These queues keep their state in tables that sometimes need migrating. River 0.40.0, on 2 July 2026, shipped migration version 7, and on the SQLite driver running apps have to be stopped briefly while it applies. A database-backed scheduler turns a routine upgrade into a window where nothing fires. Most windows are a few seconds and fine, but you have to know they exist and check the schedule came back.
Monitoring a database-backed queue
Watch the outcome, not the process
Every failure above shares one trait: the queue's own dashboard cannot report it, because it runs in the same process and database that just failed. A stalled enqueuer shows no error, and a duplicate run looks like two successes.
The check that works is the one cron always needed: watch for the outcome from outside the system, and have the job report success only after it finishes so you can alert when that report does not arrive.
A check-in from inside the worker
In a River worker, the check-in goes last, after the real work returns cleanly:
func (w *NightlyReportWorker) Work(ctx context.Context, job *river.Job[NightlyReportArgs]) error {
if err := buildAndSendReport(ctx); err != nil {
return err // River retries this job; the ping below never runs
}
// Reached only on success: ping an external monitor
req, _ := http.NewRequestWithContext(ctx, "GET",
"https://cronguard.app/api/ping/your-monitor-id", nil)
http.DefaultClient.Do(req)
return nil
}
If the enqueuer stalls, or a migration paused the schedule and it never resumed, the ping stops and you get paged. You do not have to predict which failure hit you, only notice that success went missing.
Frequently asked questions about database-backed job queues
Are database-backed job queues more reliable than cron? They fix real cron weaknesses: a queryable history, retries, and enqueue inside the same transaction as your data. But they move the scheduler into your application, so a stalled transaction, a database outage, or a bad migration can stop scheduled work in ways a standalone cron daemon never would.
Why did my River jobs stop being scheduled while the queue was still running? Before version 0.37.1, a stalled Postgres call inside River's periodic enqueuer could hang it with no timeout, halting all periodic job insertion until the process restarted or a new leader was elected. Workers stayed healthy and the system looked fine, so 0.37.1 added a 30-second timeout around those calls.
Can a durable job queue run the same scheduled job twice? Yes. These queues promise at-least-once delivery and rely on correct configuration to deduplicate. In Oban, an incomplete list of states in the unique option silently defeats deduplication, so the same job can be inserted twice. Only an idempotent job is safe against a double run.
Do database queue migrations interrupt scheduled jobs? They can. River 0.40.0 shipped a migration where, on the SQLite driver, running apps must be stopped briefly while it applies. That is a short window with no scheduling, unlike a plain crontab. The pause is usually seconds long, but confirm the schedule resumed afterward.
How do I monitor a Postgres-backed job queue? Watch the outcome from outside the queue, because its dashboard runs in the process and database that might be failing. Have each scheduled job report success only after it completes, then alert when that report does not arrive on time. That one signal catches a stalled enqueuer, a crash-loop, and a paused schedule.
Further reading
- Cron Alternatives Compared: systemd Timers, Celery Beat, and More
- Dead Man's Switch Monitoring: The Only Reliable Way to Watch Cron Jobs
- Why Cron Jobs Fail Silently (And How to Catch Them)
Conclusion: Moving scheduled work into a Postgres-backed queue beats a lonely crontab line, but it relocates the scheduler into your application, where a stalled transaction, a database outage, or a routine migration can quietly stop jobs from running. The dashboard that ships with the queue cannot see those failures, because it shares their fate. The durable answer is the one cron always needed: watch for the absence of success from outside.
Sources: River CHANGELOG, Oban CHANGELOG.