# Sidekiq Monitoring: Queue Latency, Failed Jobs, and Missed Runs

By Dan Ochoa · Published 2025-12-04 · Updated 2026-10-19
Canonical: https://cronradar.com/blog/sidekiq-monitoring

> Monitor Sidekiq in production: queue latency, retries and dead jobs, stuck workers, and scheduled jobs that never run, with alerts that reach your team.

**To monitor Sidekiq, alert on queue latency, a growing retry set, new jobs in the dead set, and processes that disappear, and give every recurring job a check-in that a schedule-aware monitor expects.** Sidekiq's Web UI and API expose the first four. None of them notices a scheduled job that was never enqueued, because nothing fails; that check needs a monitor outside Sidekiq.

This guide covers the metrics that matter, common failure patterns, Sidekiq's own monitoring tools, and external alerting. Examples use the Sidekiq 7 APIs (`Sidekiq::Job`, `Sidekiq::ServerMiddleware`).

## Why Sidekiq monitoring matters

Sidekiq's Web UI shows you what's happening _right now_, but it won't tell you when problems start. The gap is between "jobs are processing" and "jobs are processing correctly and on time."

Without monitoring, you find out through:

- Customer complaints about missing emails or delayed reports
- Database bloat from cleanup jobs that stopped running
- Failed payment processing
- Manual dashboard checks that nobody remembers to do

Monitoring moves discovery earlier. Instead of hearing about a queue backlog from a customer, you get an alert when latency crosses your threshold.

## Key metrics to track

Effective Sidekiq monitoring covers three categories: queue health, job performance, and infrastructure.

### Queue health metrics

| Metric             | What It Measures                      | How to Access                           | Starting Threshold          |
| ------------------ | ------------------------------------- | --------------------------------------- | --------------------------- |
| Queue latency      | Seconds since oldest job was enqueued | `Sidekiq::Queue.new("default").latency` | >30s warning, >60s critical |
| Queue size         | Number of jobs waiting                | `Sidekiq::Queue.new("default").size`    | >100 jobs (varies by app)   |
| Scheduled set size | Jobs scheduled for future execution   | `Sidekiq::ScheduledSet.new.size`        | Baseline + 50%              |

Queue latency is more meaningful than queue size. A queue with 1,000 fast jobs can have lower latency than a queue with 10 slow ones. Latency is the time between now and when the oldest job in the queue was enqueued ([Sidekiq API](https://github.com/sidekiq/sidekiq/wiki/API)), so it tells you how long work actually waits.

```ruby
# Check queue health across all queues
Sidekiq::Queue.all.each do |queue|
  puts "#{queue.name}: #{queue.size} jobs, #{queue.latency.round(2)}s latency"
end
```

### Job performance metrics

| Metric                | What It Measures                | Starting Threshold                  |
| --------------------- | ------------------------------- | ----------------------------------- |
| Failure rate          | Failed jobs / total jobs        | >1% (investigate), >5% (critical)   |
| Retry set size        | Jobs awaiting retry             | Steadily growing = persistent issue |
| Dead set size         | Jobs that exhausted all retries | Any increase warrants investigation |
| Processing throughput | Jobs processed per second       | Below baseline = capacity issue     |

The retry set deserves special attention. Sidekiq retries a failed job 25 times with exponential backoff, over approximately 20 days ([error handling](https://github.com/sidekiq/sidekiq/wiki/Error-Handling)). A growing retry set usually points to a systemic problem: an API that's down, a database issue, or a bug affecting a whole class of jobs.

```ruby
# Monitor retry and dead job counts
retry_count = Sidekiq::RetrySet.new.size
dead_count = Sidekiq::DeadSet.new.size

puts "#{retry_count} jobs waiting to retry, #{dead_count} dead jobs"
```

### Infrastructure metrics

| Metric             | What It Measures               | Starting Threshold                  |
| ------------------ | ------------------------------ | ----------------------------------- |
| Busy workers       | Active threads processing jobs | All busy + growing queue = scale up |
| Memory per process | Sidekiq process RSS            | Growth that never levels off        |
| Redis memory       | Redis used_memory              | >70% of maxmemory                   |

A healthy Sidekiq process's memory levels off at a steady value. Sidekiq's [memory guide](https://github.com/sidekiq/sidekiq/wiki/Memory) warns that RSS that never stops growing can indicate a leak, and that a sudden spike can point to a job loading too much data at once.

```ruby
# Get worker and process stats
stats = Sidekiq::Stats.new
processes = Sidekiq::ProcessSet.new

puts "Processed: #{stats.processed}"
puts "Failed: #{stats.failed}"
puts "Busy workers: #{processes.sum { |p| p['busy'] }}"
puts "Total workers: #{processes.total_concurrency}"
```

## Common Sidekiq problems and how monitoring catches them

Understanding failure modes helps you configure alerts that catch real problems without creating noise.

### Problem 1: Jobs stuck processing

**Symptoms:** Queue grows continuously, workers show as busy, latency increases

**Root cause:** Usually a network call without a timeout. A job waiting on an unresponsive external API blocks its worker thread indefinitely.

**What monitoring catches:** Queue latency above threshold while worker utilization stays high

**Prevention:**

```ruby
class ExternalApiJob
  include Sidekiq::Job
  sidekiq_options retry: 3

  def perform(url)
    # Always set timeouts on external calls (this uses the http.rb gem)
    response = HTTP.timeout(connect: 5, read: 10).get(url)
    process_response(response)
  end
end
```

### Problem 2: Silent job failures

**Symptoms:** Work never completes, but there are no errors and no retries

**Root cause:** Custom middleware or `rescue` blocks that catch exceptions without re-raising them

**What monitoring catches:** A heartbeat monitor notices that an expected completion didn't arrive

```ruby
# BAD: Swallows exceptions silently
def perform(user_id)
  process_user(user_id)
rescue StandardError => e
  Rails.logger.error(e.message)
  # Job appears successful but work didn't complete
end

# GOOD: Log and re-raise to trigger retry
def perform(user_id)
  process_user(user_id)
rescue StandardError => e
  Rails.logger.error(e.message)
  raise # Re-raise to trigger Sidekiq retry
end
```

### Problem 3: Memory bloat

**Symptoms:** Process memory grows continuously until the process is killed

**Root causes:**

- Loading entire tables into memory (`User.all.each` instead of `User.find_each`)
- A job that loads a large dataset at once
- glibc's allocator on Linux, which the memory guide says "clashes very badly with Ruby's multithreading and bloats quickly"

**What monitoring catches:** Memory above baseline, frequent process restarts

**Prevention:**

```ruby
class LargeDatasetJob
  include Sidekiq::Job

  def perform
    # BAD: Loads all records into memory
    # User.all.each { |user| process(user) }

    # GOOD: Processes in batches of 1000
    User.find_each(batch_size: 1000) do |user|
      process(user)
    end
  end
end
```

On Linux with glibc, set `MALLOC_ARENA_MAX=2` in the Sidekiq process's environment, or run Ruby with jemalloc; both are recommendations from the [memory guide](https://github.com/sidekiq/sidekiq/wiki/Memory).

### Problem 4: Queue starvation

**Symptoms:** High-priority jobs wait while long-running jobs occupy every worker

**Root cause:** Long-running jobs in a shared queue block other job types

**What monitoring catches:** Latency spikes on specific queues, throughput drops

**Prevention:** Give long-running jobs their own queue and weight the queues:

```yaml
# config/sidekiq.yml
:queues:
  - [critical, 10]
  - [default, 5]
  - [bulk, 1]
```

```ruby
# Route long-running jobs to the bulk queue
class MediaImportJob
  include Sidekiq::Job
  sidekiq_options queue: :bulk
end
```

### Problem 5: Jobs lost or repeated during deploys

**Symptoms:** Jobs running during a deploy never finish, or run twice

**Root cause:** On `TERM`, Sidekiq shuts down within its timeout (`-t`, 25 seconds by default). Jobs still running when the timeout ends are stopped and pushed back to Redis, and they run again from the beginning after the restart ([Signals](https://github.com/sidekiq/sidekiq/wiki/Signals)). Jobs are lost when the process dies without that shutdown. Sidekiq OSS fetches jobs with `BRPOP`, which removes them from Redis, and its [reliability guide](https://github.com/sidekiq/sidekiq/wiki/Reliability) warns: "If Sidekiq crashes while processing that job, it is lost forever." A `KILL`, an out-of-memory kill, or a deploy that sends `KILL` before the timeout ends all have that effect.

**What monitoring catches:** Missed check-ins after a deploy, and runs that started but never completed

**Prevention:**

- Send `TSTP` early in the deploy so Sidekiq stops taking new work, send `TERM` as late as possible, and wait at least the timeout plus five seconds before sending `KILL` ([deployment guide](https://github.com/sidekiq/sidekiq/wiki/Deployment)).
- On Heroku, processes have [30 seconds after `SIGTERM`](https://devcenter.heroku.com/articles/dyno-shutdown-behavior) before `SIGKILL`. Sidekiq's 25-second default timeout is set to fit that window, so don't raise `-t` there.
- Make jobs safe to run twice, because a job pushed back to Redis starts over.
- For jobs that must survive crashes, Sidekiq Pro's `super_fetch` keeps jobs in Redis until they finish and recovers jobs orphaned by a dead process.

### Problem 6: Transaction race conditions

**Symptoms:** `ActiveRecord::RecordNotFound` errors on recently created records

**Root cause:** The job runs before the database transaction that created the record commits

```ruby
# BAD: Job may run before transaction commits
User.transaction do
  user = User.create!(params)
  WelcomeEmailJob.perform_async(user.id)  # May fail with RecordNotFound
end

# GOOD: Use after_commit callback
class User < ApplicationRecord
  after_commit :send_welcome_email, on: :create

  private

  def send_welcome_email
    WelcomeEmailJob.perform_async(id)
  end
end

# ALSO GOOD: Transaction-aware push (Sidekiq 6.5+, requires the after_commit_everywhere gem)
# config/initializers/sidekiq.rb
Sidekiq.transactional_push!
```

## Native Sidekiq monitoring options

### Sidekiq Web UI

The Web UI provides a dashboard showing:

- Processed and failed job counts
- Queue sizes and latency
- Busy workers and their current jobs
- Scheduled, retry, and dead job sets
- Since Sidekiq 7, a Metrics tab with job execution data

**Setup** with Devise, restricted to admins:

```ruby
# config/routes.rb
require "sidekiq/web"

authenticate :user, lambda { |u| u.admin? } do
  mount Sidekiq::Web => "/sidekiq"
end
```

The [monitoring guide](https://github.com/sidekiq/sidekiq/wiki/Monitoring) also shows HTTP Basic authentication for apps without Devise.

**Limitations:**

- The Metrics tab keeps its data in Redis for only 8 hours ([Metrics](https://github.com/sidekiq/sidekiq/wiki/Metrics))
- No alerting; someone has to look
- It can't show a scheduled job that was never enqueued

### Sidekiq API

For programmatic access, Sidekiq provides a stats API:

```ruby
require "sidekiq/api"

stats = Sidekiq::Stats.new
stats.processed          # Total jobs processed
stats.failed             # Total jobs failed
stats.enqueued           # Jobs currently enqueued
stats.scheduled_size     # Jobs scheduled for future
stats.retry_size         # Jobs in retry set
stats.dead_size          # Jobs in dead set
stats.processes_size     # Number of running processes

# Daily history for the last 5 days
history = Sidekiq::Stats::History.new(5)
history.processed        # Hash of date => count
history.failed           # Hash of date => count
```

### Sidekiq Pro and Enterprise

[Sidekiq Pro](https://sidekiq.org/products/pro) adds, among other features:

- Batches with completion callbacks
- Reliability when a Ruby process crashes (`super_fetch`)
- Metrics sent to Statsd
- Queue pausing and job expiration

[Sidekiq Enterprise](https://sidekiq.org/products/enterprise) adds Pro's features plus:

- Periodic (cron) jobs
- Rate limiting and unique jobs
- Historical metrics
- Multi-core processing and rolling restarts

See each product page for current pricing.

## Setting up external monitoring

Native tools show what's happening now. External monitoring alerts you when something goes wrong and keeps history for debugging.

### Approach 1: Check-ins for scheduled jobs

Sidekiq OSS doesn't run recurring jobs on its own. They come from [Sidekiq Enterprise periodic jobs](https://github.com/sidekiq/sidekiq/wiki/Ent-Periodic-Jobs) or from gems such as [sidekiq-cron](https://github.com/sidekiq-cron/sidekiq-cron) and [sidekiq-scheduler](https://github.com/sidekiq-scheduler/sidekiq-scheduler); [Rails scheduled jobs](https://cronradar.com/blog/rails-scheduled-jobs) covers Solid Queue, whenever, and GoodJob. Whatever enqueues them, a heartbeat monitor, also called a [dead man's switch](https://cronradar.com/blog/cron-dead-mans-switch), expects a check-in on each job's schedule and alerts when one is late. It covers what queue metrics can't: a scheduler that stopped, a schedule removed in a deploy, or a job killed mid-run.

CronRadar has no Ruby SDK and doesn't discover Sidekiq jobs, so this is manual HTTP reporting to its [ingestion API](https://docs.cronradar.com/api-reference). Each recurring job gets its own monitor key and its real schedule as a [cron expression](https://cronradar.com/blog/cron-expression), and the first check-in that includes the schedule creates the monitor. Store the ingestion key in `CRONRADAR_API_KEY` through your secret manager, with a separate CronRadar application and key for each environment.

CronRadar evaluates schedules in UTC. Enterprise periodic jobs use the Ruby process's time zone, which is the system time zone unless `TZ` is set, and sidekiq-scheduler uses `config.time_zone` in Rails. Convert the expression if they differ.

A small reporting module that never raises into the job:

```ruby
# app/lib/cron_radar.rb
require "erb"
require "net/http"

module CronRadar
  BASE_URL = "https://cron.life/api/v1/ping"

  # Sends one check-in: event is "start", "complete", "fail", or nil for a heartbeat.
  # Never raises: a monitoring problem must not fail or retry the job.
  def self.report(key, event = nil, schedule:, run_id: nil, message: nil)
    uri = URI([BASE_URL, ERB::Util.url_encode(key), event].compact.join("/"))
    params = { schedule: schedule, run_id: run_id, message: message&.slice(0, 1000) }.compact
    uri.query = URI.encode_www_form(params)

    request = Net::HTTP::Post.new(uri)
    request.basic_auth(ENV.fetch("CRONRADAR_API_KEY"), "")
    request.body = ""

    response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 5, read_timeout: 5) do |http|
      http.request(request)
    end
    return if response.is_a?(Net::HTTPSuccess)

    Sidekiq.logger.warn("CronRadar #{event || 'heartbeat'} for #{key} rejected (HTTP #{response.code})")
  rescue StandardError => e
    Sidekiq.logger.warn("CronRadar #{event || 'heartbeat'} for #{key} not recorded (#{e.class})")
  end
end
```

A server middleware reports every run of the mapped jobs without changing job classes:

```ruby
# config/initializers/sidekiq.rb
class CronRadarMiddleware
  include Sidekiq::ServerMiddleware

  # Job class => [CronRadar monitor key, cron schedule in UTC].
  # Keep each schedule identical to the job's scheduler entry.
  MONITORED_JOBS = {
    "NightlyReportJob" => ["nightly-report", "0 3 * * *"],
    "HourlyInventorySyncJob" => ["hourly-inventory-sync", "0 * * * *"],
  }.freeze

  def call(_job_instance, job_payload, _queue)
    # Active Job wraps jobs; the real class name is in "wrapped".
    key, schedule = MONITORED_JOBS[job_payload["wrapped"] || job_payload["class"]]
    return yield unless key

    run_id = job_payload["jid"] # The jid stays the same when Sidekiq retries the job
    CronRadar.report(key, "start", schedule: schedule, run_id: run_id)
    yield
    CronRadar.report(key, "complete", schedule: schedule, run_id: run_id)
  rescue StandardError => e
    # Send the exception class only: never job arguments, payloads, or log output.
    CronRadar.report(key, "fail", schedule: schedule, run_id: run_id, message: e.class.name) if key
    raise
  end
end

Sidekiq.configure_server do |config|
  config.server_middleware do |chain|
    chain.add CronRadarMiddleware
  end
end
```

How this behaves:

- **The job's result is unchanged.** The middleware re-raises the job's exception, so Sidekiq's retries work as before, and a monitoring error is logged instead of raised.
- **Each request has five-second connect and read timeouts.** The calls are synchronous, so a slow network adds that time to the job.
- **Retries report each attempt.** A failed attempt reports a failure, and a successful retry reports completion and recovery. For a scheduled job, a short retry budget such as `sidekiq_options retry: 3` keeps a failing run from retrying for weeks while later runs start on schedule.
- **Shutdowns aren't failures.** Sidekiq stops jobs still running at the end of the shutdown timeout with `Sidekiq::Shutdown`, which inherits from `Interrupt`, not `StandardError`, so the middleware doesn't report it. The job reruns with the same jid after the restart.
- **Every run of a mapped job reports**, including one enqueued by hand. Map only jobs that run on a schedule.

If a scheduler stops enqueueing, no start arrives and the monitor alerts once the grace period passes. If a process dies mid-job, the start has no matching completion, so the run is flagged. Alerts go to email, Slack, a webhook, Discord, or PagerDuty.

### Approach 2: Structured job logs

To feed job durations and latency into a log pipeline, log one line per job from a middleware:

```ruby
# config/initializers/sidekiq.rb
class JobLogMiddleware
  include Sidekiq::ServerMiddleware

  def call(_job_instance, job_payload, queue)
    started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
    success = false
    yield
    success = true
  ensure
    Sidekiq.logger.info({
      event: "sidekiq.job.finished",
      job_class: job_payload["wrapped"] || job_payload["class"],
      queue: queue,
      jid: job_payload["jid"],
      duration_ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round,
      success: success,
    }.to_json)
  end
end
```

Register it with `chain.add JobLogMiddleware` in the same `server_middleware` block.

### Approach 3: Death handlers

Get notified when a job exhausts its retries and moves to the dead set:

```ruby
# config/initializers/sidekiq.rb
Sidekiq.configure_server do |config|
  config.death_handlers << ->(job, exception) do
    Sidekiq.logger.error(
      "Job died after its retries: #{job['wrapped'] || job['class']} " \
      "jid=#{job['jid']} error=#{exception.class}"
    )
    # Forward the same summary to your error tracker or team channel here.
  end
end
```

### Approach 4: Health check endpoint

Expose Sidekiq health for load balancers and uptime checks:

```ruby
# config/routes.rb
get "/health/sidekiq", to: "health#sidekiq"

# app/controllers/health_controller.rb
require "sidekiq/api"

class HealthController < ApplicationController
  def sidekiq
    processes = Sidekiq::ProcessSet.new.size
    latency = Sidekiq::Queue.new("default").latency
    Sidekiq.redis { |conn| conn.call("PING") }

    healthy = processes.positive? && latency < 60
    render json: { processes: processes, default_latency: latency.round(2) },
           status: healthy ? :ok : :service_unavailable
  rescue StandardError => e
    render json: { error: e.class.name }, status: :service_unavailable
  end
end
```

The `PING` fails when Redis is unreachable, and the `rescue` turns any error into a 503. Skip any authentication filter your `ApplicationController` applies to this route.

### Approach 5: Prometheus and Grafana

For self-hosted metrics and dashboards, use [yabeda-sidekiq](https://github.com/yabeda-rb/yabeda-sidekiq) with a Prometheus adapter:

```ruby
# Gemfile
gem "yabeda-sidekiq"
gem "yabeda-prometheus"

# config/initializers/sidekiq.rb
Sidekiq.configure_server do |_config|
  Yabeda::Prometheus::Exporter.start_metrics_server!
end
```

It exports metrics such as `sidekiq_jobs_failed_total`, `sidekiq_queue_latency`, `sidekiq_jobs_retry_count`, and `sidekiq_jobs_dead_count`. The project's README links a sample Grafana dashboard, [ID 11667](https://grafana.com/grafana/dashboards/11667).

## Choosing alert thresholds

Start from these and tune them to your own baseline:

| Metric                 | Warning                  | Critical     |
| ---------------------- | ------------------------ | ------------ |
| Queue latency          | > 60s                    | > 300s       |
| Failure rate           | > 1%                     | > 5%         |
| Dead set               | Any new job              |              |
| Retry set              | Growing for an hour      | > 1,000 jobs |
| Redis memory           | > 70% of maxmemory       | > 85%        |
| Scheduled job check-in | Late, still inside grace | Missed       |

Smaller applications can start with the Web UI, an error tracker, and check-ins for recurring jobs. As volume grows, add Prometheus metrics through yabeda, a dedicated Redis instance with its own monitoring, and autoscaling based on queue latency.

## Monitoring checklist

Before going to production, verify you have:

**Queue visibility:**

- [ ] Queue latency alerts
- [ ] Queue size baseline established
- [ ] Retry set growth alerts
- [ ] Dead set alerts

**Job performance:**

- [ ] Failure rate tracking
- [ ] Check-ins for every recurring job
- [ ] Job duration baselines

**Infrastructure:**

- [ ] Redis memory monitoring
- [ ] Worker memory monitoring
- [ ] Process count verification
- [ ] Health check endpoint for load balancers

**Alerting:**

- [ ] Alerts route to channels people watch
- [ ] Escalation path defined for critical alerts
- [ ] Runbook for common alert types

## Frequently Asked Questions

### What should I monitor in Sidekiq?

Queue latency per queue, the size of the retry and dead sets, the failure rate, process count and memory, and Redis memory. Add a schedule-aware check-in for each recurring job, because none of the other metrics changes when a scheduled job is never enqueued.

### How do I know if a Sidekiq cron job ran?

The Web UI and logs show jobs that ran, not the one that didn't. Have each recurring job report a start and a completion to a monitor that knows its schedule, so a missing run triggers an alert. [How to know if a cron job ran](https://cronradar.com/blog/how-to-know-if-cron-job-ran) compares this with log-based checks.

### Does Sidekiq lose jobs on restart or deploy?

Not on a graceful shutdown: after `TERM`, Sidekiq waits up to its timeout (25 seconds by default), then pushes unfinished jobs back to Redis to run again. Sidekiq OSS does lose jobs that were running when a process crashes or is killed with `KILL`. Sidekiq Pro's `super_fetch` recovers those.

### How long does Sidekiq retry failed jobs?

By default, 25 times with exponential backoff, over approximately 20 days. After the last retry, the job moves to the dead set, and any death handlers run. Set `sidekiq_options retry:` per job to change the number.

### Does CronRadar have a Ruby SDK or Sidekiq integration?

No. There is no CronRadar Ruby SDK and no Sidekiq auto-discovery. Report recurring jobs over HTTP with a module and middleware like the ones above, giving each job its own monitor key and schedule. The [HTTP API reference](https://docs.cronradar.com/api-reference) documents the endpoints.

## Summary

Sidekiq's reliability depends on visibility. The Web UI shows current state, but production applications need alerts that fire before users notice.

Start with the basics: alert on queue latency and dead jobs, add check-ins for recurring jobs, and route alerts to people who will act on them. As your application grows, add metrics and dashboards. For the habits that keep scheduled jobs from failing quietly in the first place, see [preventing errors in scheduled automation](https://cronradar.com/blog/prevent-errors-in-scheduled-automation).
