← Blog  ·  October 2026

Ruby on Rails Cron Job Monitoring: Alerts When Whenever, Sidekiq-Cron, or Delayed::Job Stops Running

The monthly invoice generation job stopped running in January. Sidekiq-Cron was healthy, Redis was up, no exceptions in Rollbar. A customer mentioned in March that they'd never received an invoice. Three months of silent billing failure, invisible to the entire engineering team.

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.


Why Rails scheduled job failures are especially quiet

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 gem: the crontab entry silently disappears

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: Redis key expiry and worker restarts

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 recurring: no built-in recurrence

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

Adding heartbeat monitoring in one line

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.

ActiveJob (works with Sidekiq, Delayed::Job, or any adapter)

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

Whenever runner task

# 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.

Sidekiq-Cron

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
Where to put the ping call Always at the end, after all meaningful work completes. A ping at the top tells you the job started, not that it succeeded. A billing job that starts but fails halfway through looks healthy from the outside — until month end.

Shared module for all your jobs

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.


Catching Sidekiq-Cron failures with middleware

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

Heroku Scheduler edge cases

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

Quick-reference comparison

ToolSchedule storedMain silent failureHeartbeat location
wheneverSystem crontab--update-crontab not run on deployEnd of runner/command
Sidekiq-CronRedis keysRedis flush loses all jobsEnd of perform
Delayed::Job recurringDB rowException breaks re-enqueue chainBefore re-enqueue
Heroku SchedulerHeroku dashboardBest-effort, occasionally skipsEnd of rake task
GoodJobPostgreSQLWorker process restart drops in-flightEnd of perform

🕐 Get alerted when a Rails job stops running

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.

Join the CronWatch waitlist Free cron expression parser

Summary