To monitor Sidekiq cron jobs, give each scheduled job its own heartbeat URL and ping it on the last line of perform, after the work succeeds. If the job raises, sits in a queue nobody consumes, or never gets enqueued because no Sidekiq process was running, the ping does not arrive and you get an alert. The trap is Sidekiq's retry system: a scheduled job that raises is retried in the background for about 20 days, and nothing outside the Web UI tells you.

I'm Léo, I build Hyperping, and this guide covers the three ways Rails apps schedule Sidekiq jobs (sidekiq-cron, sidekiq-scheduler and Sidekiq Enterprise periodic jobs), how each one fails without an error, what Sidekiq shows you, and the setup I use with Hyperping healthchecks. I tested the pings, the retry behavior and the timezone rules on Sidekiq 8.1.7, sidekiq-cron 2.4.0 and sidekiq-scheduler 6.0.2 (the current releases as of October 2026), with Ruby 3.4.6 and Redis 7.4 on Ubuntu 22.04, against a local HTTP listener. Sidekiq Enterprise is a paid license I did not run: that part relies on the official wiki.

Key takeaways

  • All three schedulers enqueue from inside your Sidekiq processes. If none is running at the scheduled minute, the job is never created, and sidekiq-cron only catches up runs missed by less than 60 seconds.
  • A scheduled job that raises goes to the Retries tab and is retried 25 times over about 20 days before it lands in Dead. Ping on the last line of perform so a raising job stays silent and the missed ping alerts you.
  • Enqueued does not mean done. In my test, sidekiq-cron's last_enqueue_time kept moving every minute while the job piled up in a queue no process listened to.
  • Without a timezone in the cron string, sidekiq-cron uses the TZ variable, then Rails' Time.zone, then the system timezone. Copy that exact zone into the healthcheck, or write it in the string.
  • Monitor from outside Sidekiq: one healthcheck per job with the same expression and timezone, plus a five-minute heartbeat job that proves the poller enqueues and a worker processes.

How Sidekiq scheduled jobs fail silently

Sidekiq itself has no cron. You add a scheduler: the sidekiq-cron gem, the sidekiq-scheduler gem, or the periodic jobs built into Sidekiq Enterprise. Each one runs a thread inside the Sidekiq process that pushes a normal job into a queue at the scheduled time, and a worker then runs it like any other job. Use one of them, not both gems at once: each keeps its own schedule and its own Web UI tab.

A minimal sidekiq-cron setup looks like this:

# config/schedule.yml (sidekiq-cron)
nightly_report:
  cron: "0 2 * * * Europe/Paris"
  class: "NightlyReportJob"
  queue: "default"

The sidekiq-scheduler equivalent lives in config/sidekiq.yml, and Enterprise registers jobs in the Sidekiq initializer:

# config/sidekiq.yml (sidekiq-scheduler)
:scheduler:
  :schedule:
    nightly_report:
      cron: "0 2 * * * Europe/Paris"
      class: "NightlyReportJob"
# config/initializers/sidekiq.rb (Sidekiq Enterprise)
Sidekiq.configure_server do |config|
  config.periodic do |mgr|
    mgr.register("0 2 * * *", "NightlyReportJob", tz: ActiveSupport::TimeZone.new("Paris"))
  end
end

Everything below goes wrong without an exception in your error tracker.

No Sidekiq process, no scheduled job

The sidekiq-cron README is direct about it: "Scheduling jobs are added only when at least one Sidekiq process is running". Its poller checks every 30 seconds, and after a restart it only enqueues runs that were due less than reschedule_grace_period ago, 60 seconds by default (past_scheduled_time?). A deploy that stops Sidekiq from 01:59 to 02:03 skips the 02:00 report.

The other two behave the same way or worse:

  • sidekiq-scheduler: "you need at least one sidekiq worker with scheduler to be up at that moment" (README). Its every and interval schedules count from process start, so "every: '48h' will never run if the Sidekiq process is restarted daily". I saw it with every: "1m": the first run came exactly one minute after boot.
  • Sidekiq Enterprise: only the elected leader enqueues periodic jobs, and the wiki says "This implementation does not support backfill. If Sidekiq is shutdown, it will not create jobs for the times missed on restart."

A crash loop, a worker dyno scaled to zero, or a Kubernetes deployment stuck on a bad image all stop every scheduled job, and nothing in Sidekiq complains because nothing in Sidekiq is running.

The schedule file is never loaded

sidekiq-cron loads config/schedule.yml on startup, relative to the directory the process starts in, and skips it without logging anything when the file is not there:

schedule_loader = Sidekiq::Cron::ScheduleLoader.new
next unless schedule_loader.has_schedule_file?
schedule_loader.load_schedule

I started Sidekiq from / with an absolute -r path on an empty Redis: the process booted, logged nothing about cron, and Sidekiq::Cron::Job.all stayed empty. A Docker image without the file, a systemd unit with the wrong WorkingDirectory, or config.enabled = false left in a shared initializer gives the same result. Jobs already stored in Redis keep running, so this often surfaces only after a Redis migration or a FLUSHDB.

A job disabled in the Web UI stays disabled

Both gems store the enabled state in Redis, and Redis wins over your code. sidekiq-cron reads status from Redis unless the schedule sets it explicitly (job.rb). I disabled a job with disable!, reloaded config/schedule.yml the way a boot does, and the job was still disabled. sidekiq-scheduler does the same with the toggle in its Recurring Jobs tab: the state saved in Redis takes priority over enabled: true in the YAML (scheduler.rb).

Someone pauses a job during an incident and never turns it back on, and every deploy afterwards keeps it paused.

Enqueued is not performed

The scheduler's job ends when it pushes to a queue. If no Sidekiq process listens to that queue, or the queue has a backlog of thousands of jobs, the scheduled job waits. In my test, a job with sidekiq_options queue: "reports" and a Sidekiq process started with only default showed a fresh last_enqueue_time every minute in sidekiq-cron, while Sidekiq::Queue.new("reports") held two jobs with a latency of 66 seconds and growing.

This happens when a queue is renamed in the job but not in config/sidekiq.yml, or when the process that served a low-priority queue is removed during a cost cleanup.

Retries hide the failure for three weeks

When perform raises, Sidekiq catches the exception and schedules a retry. The Error Handling wiki describes the default: "It will perform 25 retries over approximately 20 days", with a delay of (retry_count ** 4) + 15 seconds plus jitter (job_retry.rb). After the last retry the job moves to the Dead set, where it stays for 6 months.

Meanwhile the scheduler keeps creating a new job at every tick, and each one fails the same way. With sidekiq_options retry: 2, my failing job ran at 20:12:41, retried at 20:13:01 and 20:13:19, then moved to Dead, while the 20:13 run was already in the Retries tab. Sidekiq logged each failure and counted it in the stats. Unless an error tracker is wired in, or someone defines a sidekiq_retries_exhausted hook that notifies a person, that is all that happens.

For scheduled jobs, a small retry count makes sense: the next run comes anyway.

class NightlyReportJob
  include Sidekiq::Job
  sidekiq_options retry: 3

  sidekiq_retries_exhausted do |job, ex|
    Sidekiq.logger.warn "#{job['class']} gave up: #{job['error_message']}"
  end
end

The cron string runs in a timezone you did not pick

The sidekiq-cron README says the cron line is evaluated in the Rails timezone, "otherwise the default is UTC". What I measured is different. Without a zone in the string, the parser (Fugit, through et-orbi's determine_local_tzone) uses the TZ environment variable first, then Rails' Time.zone, then the system timezone:

Environment cron: "0 2 * * *" fires at
TZ=UTC 02:00 UTC
TZ=America/New_York 06:00 UTC
TZ=Europe/Paris and Rails Time.zone Tokyo 00:00 UTC (Paris wins)
No TZ, Rails Time.zone Tokyo 17:00 UTC the day before
cron: "0 2 * * * Europe/Paris", any environment 00:00 UTC in summer

sidekiq-scheduler uses the same libraries and the same rule: "if you use the cron syntax and are not running a Rails app, this will be interpreted in the server time zone". Sidekiq Enterprise uses "the Ruby process's Time Zone, which defaults to the system time zone", unless you pass tz:. A container image that sets TZ for log readability moves every scheduled job.

How to check Sidekiq scheduled jobs with its own tools

The Sidekiq Web UI gives you most of the picture once the extensions are mounted:

# config/routes.rb
require "sidekiq/web"
require "sidekiq/cron/web"            # sidekiq-cron: adds the Cron tab
# require "sidekiq-scheduler/web"     # sidekiq-scheduler: adds the Recurring Jobs tab
# require "sidekiq-ent/web"           # Sidekiq Enterprise: adds the Cron tab

mount Sidekiq::Web => "/sidekiq"

The Cron tab of sidekiq-cron lists each job with its status and last enqueue time, and lets you enqueue, disable or delete it. sidekiq-scheduler's Recurring Jobs tab shows last and next run times with an enable toggle. The Enterprise Cron tab shows registered jobs and their history, and since Enterprise 8.0.1 you can enqueue or pause from it. The Retries and Dead tabs list failed jobs with the error message, and the Queues tab shows each queue's size and latency.

From a console, the same data is available through the API:

require "sidekiq/api"

# sidekiq-cron: every job, its status and the last time it was pushed
Sidekiq::Cron::Job.all.each do |job|
  puts [job.name, job.cron, job.status, job.last_enqueue_time].join(" | ")
end

# Failed scheduled jobs waiting for a retry, and jobs that gave up
Sidekiq::RetrySet.new.each { |r| puts [r.klass, r.item["retry_count"], r.item["error_message"]].join(" | ") }
Sidekiq::DeadSet.new.size

# Is anyone consuming the queue, and how far behind is it?
Sidekiq::ProcessSet.new.size
Sidekiq::Queue.new("default").latency

# sidekiq-scheduler: last enqueue time of one schedule
SidekiqScheduler::RedisManager.get_job_last_time("nightly_report")

# Sidekiq Enterprise: registered periodic jobs and their history
Sidekiq::Periodic::LoopSet.new.each { |lop| p [lop.schedule, lop.klass, lop.history] }

The logs help too: sidekiq-cron writes Cron Jobs - added job with name ... for each job it creates at boot, and every run logs class=NightlyReportJob: start followed by done or fail.

All of this lives in Redis and in Sidekiq processes. If no process is running, the Web UI shows an empty Busy tab and a last_enqueue_time that gets older, and nobody looks at either until a customer asks where the report went. That is the case an external heartbeat covers.

How to monitor Sidekiq cron jobs with Hyperping

A Hyperping healthcheck is a secret URL that expects a request on a schedule. When the request does not arrive by the expected time plus a grace period, it opens an incident and alerts you, then resolves on the next successful ping. Healthchecks are included on every plan, Free included.

1. Create one healthcheck per scheduled job

In Hyperping, open Healthchecks, click Create healthcheck, name it after the job, and pick Cron. Use the same expression and the same timezone as the Sidekiq schedule. Hyperping takes five fields, so drop the leading seconds field from six-field expressions:

Sidekiq schedule Hyperping healthcheck
sidekiq-cron cron: "*/5 * * * *" Cron */5 * * * * (every 5 minutes)
sidekiq-cron cron: "0 2 * * * Europe/Paris" Cron 0 2 * * *, timezone Europe/Paris
sidekiq-scheduler cron: "0 30 6 * * 1" (seconds first) Cron 30 6 * * 1
sidekiq-scheduler every: "1h" Simple mode, every 1 hour
Enterprise mgr.register("0 * * * *", "HourlyJob") Cron 0 * * * * (every hour), in the process timezone
Enterprise mgr.register("0 0 * * *", "DailyJob", tz: ActiveSupport::TimeZone.new("Tokyo")) Cron 0 0 * * * (every day), timezone Asia/Tokyo

For an expression without a zone, use the zone the process actually runs in (see the timezone table above), or better, add the zone to the cron string and use the same one in Hyperping. Use simple mode for every and interval, since they count from boot rather than from the clock.

sidekiq-cron also accepts six-field expressions down to the second, like */5 * * * * *. A healthcheck accepts at most 10 pings per minute, so do not ping from a job that runs more often than that: let the heartbeat job in step 5 cover it.

Each healthcheck gives you a URL like https://hc.hyperping.io/tok_....

2. Store the ping URLs in credentials or ENV

The URL is a secret: anyone who has it can mark your job as healthy. Keep it out of config/schedule.yml and out of the repository. With Rails credentials:

# bin/rails credentials:edit
hyperping:
  nightly_report: https://hc.hyperping.io/tok_your_nightly_report_token
  sidekiq_heartbeat: https://hc.hyperping.io/tok_your_sidekiq_heartbeat_token

Or as environment variables on the Sidekiq processes:

HYPERPING_NIGHTLY_REPORT_URL=https://hc.hyperping.io/tok_your_nightly_report_token
HYPERPING_SIDEKIQ_HEARTBEAT_URL=https://hc.hyperping.io/tok_your_sidekiq_heartbeat_token

Then add a small helper. It uses Net::HTTP from the standard library with 5 second timeouts, and rescues every error so a network problem on the ping never fails the job or sends it to the Retries tab:

# config/initializers/hyperping.rb
require "net/http"

module Hyperping
  def self.ping(url, suffix = nil)
    return if url.nil? || url.empty?

    uri = URI(suffix ? "#{url}/#{suffix}" : url)
    Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
                    open_timeout: 5, read_timeout: 5) do |http|
      http.request(Net::HTTP::Get.new(uri))
    end
  rescue StandardError => e
    Sidekiq.logger.warn("Hyperping ping failed: #{e.class}: #{e.message}")
  end
end

I pointed a job at a closed port to check: Sidekiq logged Hyperping ping failed: Errno::ECONNREFUSED as a warning and the job finished as done.

3. Ping at the end of perform, with /start for duration

# app/sidekiq/nightly_report_job.rb
class NightlyReportJob
  include Sidekiq::Job
  sidekiq_options retry: 3

  def perform
    url = Rails.application.credentials.dig(:hyperping, :nightly_report)
    Hyperping.ping(url, "start")

    NightlyReport.generate!

    Hyperping.ping(url) # only reached when generate! did not raise
  end
end

/start opens a run in Hyperping without moving the deadline. The plain ping closes it, records how long the job took, and sets the next deadline. If generate! raises, the job goes to the Retries tab, no success ping is sent, and the healthcheck goes down once the grace period runs out. If a retry succeeds later, its ping closes the incident.

On Sidekiq 8.1.7 with sidekiq-cron 2.4.0, my listener received GET /tok_success/start then GET /tok_success two seconds later for the passing job, and only /start requests for the raising one, at the scheduled run and at each retry.

4. Or ping from a server middleware

If you would rather keep ping code out of job classes, a server middleware can do it for a list of classes. Add it to the same initializer:

# config/initializers/hyperping.rb (continued)
class HyperpingPingMiddleware
  include Sidekiq::ServerMiddleware

  URLS = {
    "NightlyReportJob" => ENV["HYPERPING_NIGHTLY_REPORT_URL"],
  }.compact.freeze

  def call(job_instance, job_payload, queue)
    url = URLS[job_payload["wrapped"] || job_payload["class"]]
    yield
    Hyperping.ping(url) if url # not in an ensure block: a job that raises must not ping
  end
end

Sidekiq.configure_server do |config|
  config.server_middleware do |chain|
    chain.add HyperpingPingMiddleware
  end
end

job_payload["wrapped"] holds the job class when it goes through Active Job, which is how Sidekiq's own logger reads it. If yield raises, the exception goes up to Sidekiq's retry handling and the ping line never runs. I checked both cases with the middleware: the passing job pinged after done, and the raising one sent nothing, at the first run or at its retry. Use the middleware or the perform ping for a given job, not both, or you will count every run twice.

5. Add a heartbeat job for the poller and the workers

A weekly job only notices a stopped scheduler a week later. Add a job that does nothing, on the queue your scheduled jobs use:

# app/sidekiq/sidekiq_heartbeat_job.rb
class SidekiqHeartbeatJob
  include Sidekiq::Job
  sidekiq_options queue: "default", retry: false

  def perform
    Hyperping.ping(ENV["HYPERPING_SIDEKIQ_HEARTBEAT_URL"])
  end
end
# config/schedule.yml
sidekiq_heartbeat:
  cron: "*/5 * * * *"
  class: "SidekiqHeartbeatJob"
  queue: "default"

The job pings its own healthcheck when a worker runs it, so leave it out of the middleware mapping. Create that healthcheck in simple mode, every 5 minutes, with a 5 minute grace period. A ping proves two things at once: the scheduler thread enqueued the job, and a worker took it from the queue. If every process is down, the schedule file was not loaded, or the queue lost its consumers, you know within about ten minutes. If your scheduled jobs use several queues, add one heartbeat per queue.

6. Size the grace period to enqueue lag, queue latency and run time

In cron mode, Hyperping expects the success ping by the scheduled time plus the grace period. Three delays add up before that ping is sent:

  • sidekiq-cron's poll lag. The poller sleeps a random 15 to 45 seconds between checks with fewer than 10 processes (scheduled.rb). In my test, the 20:12 run was enqueued at 20:12:41. sidekiq-scheduler enqueued at the exact second.
  • Queue latency: how long the job waits behind other jobs. Check Sidekiq::Queue.new("default").latency at your busiest hour.
  • The run time of the job itself.

A report that takes 20 minutes and runs on a queue that can be 5 minutes behind needs at least 26 minutes of grace, and I would give it 40. After a few runs, the healthcheck's ping history shows the duration between each /start and success ping. The default is 10 minutes, and the minimum is 1, which is too short for anything scheduled by sidekiq-cron.

7. Route the alerts

A missed ping goes to every channel connected to the project at once: email and SMS to every member, Slack, Discord, Telegram, PagerDuty and Opsgenie. Healthchecks do not use escalation policies or on-call schedules, so if a failed billing job has to wake someone at night, send the alert to PagerDuty or Opsgenie and let their rotation decide who gets paged.

Test it before you trust it

On staging, open the Cron tab and click Enqueue Now on the job, or run Sidekiq::Cron::Job.find("nightly_report").enqueue! in a console. Check that the ping appears in the healthcheck's last pings, with Ruby as the user agent (the default for Net::HTTP). Then make perform raise and wait: the job shows up in the Retries tab, no success ping arrives, and the alert should reach you once the grace period passes. Remove the error, enqueue it again, and the incident closes on the next ping. Last, stop the Sidekiq process for fifteen minutes and confirm the heartbeat healthcheck goes down.

The same pattern works for other schedulers: see Celery beat if part of your stack runs Python, Kubernetes CronJobs if Sidekiq runs in a cluster next to them, and systemd timers for scripts on the host. For a side by side of heartbeat tools, see the best cron job monitoring tools. For the rest of a Rails app (Solid Queue, GoodJob, whenever), see Rails scheduled jobs.