Rails developers reach for one of three patterns for scheduled tasks: the whenever gem to write cron expressions in Ruby DSL, Sidekiq-Cron for Redis-backed scheduled jobs, or Delayed::Job with recurring scheduling. All three are excellent at getting tasks scheduled. None of them tell you when a task silently stops running.
This guide covers the failure modes specific to each approach and how to add heartbeat monitoring in a single method call.
Rails' exception handling is one of its greatest strengths in web requests — and one of its biggest liabilities in background jobs. ActiveRecord swallows many errors gracefully. ActionMailer silently returns without sending if the SMTP server is down. A job that processes zero records because of a bad scope exits cleanly with no indication it did nothing.
# This job silently does nothing when the SMTP server is misconfigured
class WeeklyReportJob < ApplicationJob
def perform
User.where(weekly_report: true).each do |user|
UserMailer.weekly_report(user).deliver_now
# ActionMailer raises SMTPError, but if you have rescue_from in ApplicationJob
# it logs and swallows the exception — job exits 0, no alert fires
end
end
end
Whenever generates a crontab entry on deploy via whenever --update-crontab. If your deploy pipeline skips this step — a partial deploy, a Capistrano hook that fails silently, a Docker build that doesn't run the cron container — the old crontab entry runs the old version of the task, or no crontab entry exists at all. The job "works" from the application's perspective but never fires.
# schedule.rb — you see this in development
every 1.day, at: '2:30 am' do
runner "ReportService.generate_all"
end
# On the production server, the crontab entry may point to an old release
# or may not exist at all if --update-crontab wasn't run during deploy
Sidekiq-Cron stores job schedules as Redis keys. If Redis is flushed (accidental FLUSHALL, a Redis provider incident, a Redis migration), all scheduled jobs vanish. They need to be re-loaded from initializer on next server start — but if your deployment process doesn't restart all Sidekiq workers, the stale workers never reload the schedule.
# config/initializers/sidekiq.rb
Sidekiq::Cron::Job.load_from_hash({
'monthly_invoices' => {
'cron' => '0 9 1 * *',
'class' => 'MonthlyInvoiceJob'
}
})
# If Redis is flushed and Sidekiq workers aren't restarted, this never re-runs
Delayed::Job has no native recurring schedule. Teams typically re-enqueue the job at the end of its own perform method. When a perform raises an exception, the job is retried up to max_attempts times — then it's marked failed and the recurring chain is broken forever. No more runs, no alert.
class WeeklyDigestJob < Struct.new(:user_id)
def perform
user = User.find(user_id)
DigestMailer.weekly(user).deliver_now
# Re-enqueue for next week — but if this line raises, the chain stops
WeeklyDigestJob.new(user_id).delay(run_at: 1.week.from_now).perform
end
end
The dead man's switch pattern solves all three problems: ping a URL on successful completion. If the URL doesn't receive a ping within the expected interval, you get an alert.
class MonthlyInvoiceJob < ApplicationJob
def perform
# ... your existing logic ...
Invoice.generate_for_current_month
# Heartbeat — fires only after successful completion
heartbeat("YOUR_HEARTBEAT_TOKEN")
end
private
def heartbeat(token)
require 'net/http'
uri = URI("https://cronwatch.lior-seats.io/ping/#{token}")
Net::HTTP.get(uri)
rescue StandardError
nil # monitoring outage never breaks the job
end
end
# schedule.rb
every 1.day, at: '2:30 am' do
runner "ReportService.generate_all && ReportService.ping_heartbeat"
end
# app/services/report_service.rb
class ReportService
def self.generate_all
# ... your logic ...
end
def self.ping_heartbeat
require 'net/http'
Net::HTTP.get(URI("https://cronwatch.lior-seats.io/ping/YOUR_TOKEN"))
rescue StandardError
nil
end
end
Or as a shell command in the Whenever schedule:
every 1.day, at: '2:30 am' do
command "cd /app && bundle exec rake reports:generate && curl -s https://cronwatch.lior-seats.io/ping/YOUR_TOKEN"
end
The && means the heartbeat ping only fires if the rake task exits 0. A failure skips the ping, CronWatch sees a missed heartbeat, and you get an alert.
class SidekiqCronJob
include Sidekiq::Worker
def perform
# ... your logic ...
process_queued_emails
# Heartbeat
heartbeat
end
private
def heartbeat
require 'net/http'
Net::HTTP.get(URI("https://cronwatch.lior-seats.io/ping/YOUR_TOKEN"))
rescue StandardError
nil
end
end
If you have multiple scheduled jobs, extract the heartbeat logic into a module:
# lib/cronwatch_heartbeat.rb
module CronwatchHeartbeat
def heartbeat(token)
require 'net/http'
Net::HTTP.get(URI("https://cronwatch.lior-seats.io/ping/#{token}"))
rescue StandardError
Rails.logger.warn("CronWatch heartbeat failed for #{token}: #{$!}")
nil
end
end
# In any job:
class WeeklyReportJob < ApplicationJob
include CronwatchHeartbeat
def perform
ReportService.generate_all
heartbeat(ENV['CRONWATCH_WEEKLY_REPORT_TOKEN'])
end
end
Store each job's token in environment variables so you can create separate monitors per job in the CronWatch dashboard.
The heartbeat tells you when a job doesn't run at all. For Sidekiq jobs that fail mid-execution, add server middleware to catch and alert on exceptions:
# config/initializers/sidekiq.rb
class SidekiqAlertMiddleware
def call(worker, job, queue)
yield
rescue StandardError => e
# Alert on failure — Slack, PagerDuty, email, etc.
Rails.logger.error("Sidekiq job #{job['class']} failed: #{e.message}")
raise # re-raise so Sidekiq handles retry/dead queue
end
end
Sidekiq.configure_server do |config|
config.server_middleware do |chain|
chain.add SidekiqAlertMiddleware
end
end
If you use Heroku Scheduler instead of whenever, note that Heroku Scheduler has a known reliability problem: it runs tasks on a best-effort basis and occasionally skips runs without notice. This is documented in Heroku's own FAQ. For any business-critical task — billing, reports, data cleanup — don't rely on Heroku Scheduler alone. Always add a heartbeat monitor so you know when a scheduled run didn't happen.
# In your Heroku-scheduled rake task:
task send_weekly_digests: :environment do
DigestService.send_all
# Heartbeat — fires after DigestService.send_all completes without exception
require 'net/http'
Net::HTTP.get(URI("https://cronwatch.lior-seats.io/ping/#{ENV['CRONWATCH_DIGEST_TOKEN']}"))
rescue => e
Rails.logger.error("send_weekly_digests failed: #{e.message}")
raise # non-zero exit so Heroku logs the failure
end
| Tool | Schedule stored | Main silent failure | Heartbeat location |
|---|---|---|---|
| whenever | System crontab | --update-crontab not run on deploy | End of runner/command |
| Sidekiq-Cron | Redis keys | Redis flush loses all jobs | End of perform |
| Delayed::Job recurring | DB row | Exception breaks re-enqueue chain | Before re-enqueue |
| Heroku Scheduler | Heroku dashboard | Best-effort, occasionally skips | End of rake task |
| GoodJob | PostgreSQL | Worker process restart drops in-flight | End of perform |
CronWatch monitors your scheduled tasks with a dead man's switch. Add one line to your job's perform method — get an alert if it doesn't fire on schedule. Works with Sidekiq-Cron, whenever, Delayed::Job, GoodJob, and bare crontab.
--update-crontab isn't run on deploy — always verify the crontab after each deployment