Queue Worker Monitoring

Catch the worker that’s "alive" but no longer working.

Queue libraries publish their own worker heartbeats — to themselves. They show green when the process is up, even when the consume loop has stalled on a poisoned message or a dead Redis connection. A ping from INSIDE the loop is the only signal that fires when work actually stops.

50 monitors freeSetup in 60 secondsNo SDK required

How it works

Three steps. Less than a minute.

1

Wire the integration once

Sidekiq middleware, Celery signals, BullMQ events, RQ hooks. Best practices below — the lightest hook every library exposes, no per-handler change.

2

Each job pings start + terminal

`/start` at the top of the handler, `/success` or `/failure` at the bottom — both with the same `run_id`. Drumbeats records the lifecycle and the duration.

3

A separate heartbeat watches the loop

Add a 5-minute heartbeat monitor pinging from the worker process loop itself. Catches the silent consume-loop death the per-job pings can't.

The actual product

Three views into a worker process

Real components, virtual data. A 5-minute heartbeat from a queue-consumer loop. Queue-worker monitoring is heartbeat monitoring; same charts, same alert pipeline.

Reliability

Every consume cycle, plotted against the schedule

The strip shows expected heartbeat windows over the last 6 hours. Two consecutive misses = one incident. The status detector polls every 30 seconds, so a stalled consume loop becomes a Slack alert in under a minute past the grace period.

Live status

The work loop is the only signal that lies less

Sidekiq, Celery, BullMQ, RQ — every queue library publishes its own "worker is alive" heartbeat. None of them measure whether the consume loop is making progress. A ping from inside the loop body does.

  • Heartbeat from the consume loop, not the library status endpoint
  • Outbound HTTPS only — works inside Kubernetes, ECS, Heroku, anywhere
  • Same Beats pool covers cron, uptime, and per-job failure pings
  • Best practices below — middleware, signals, events, hooks per tool

Beat history

Every heartbeat, exportable

The dashboard table records each beat with duration, source IP, and any error message your code attached. Failures show the message inline. CSV export is the same one click as the dashboard.

Match the monitor to the enqueue pattern

"Queue worker" is three different shapes

The consume loop is heartbeat-shaped. The producer side is whatever feeds the queue — and that's where most teams under-monitor. Here's the recipe per pattern.

Cron-driven enqueue

A scheduled cron fires hourly, queries for invoices due today, and enqueues one job per customer. The worker drains the queue.

What goes silently wrong

If the cron skips (DST, package upgrade, broken crontab), the queue stays empty and the worker shows green — but no work happened. You only notice when the customer asks where their invoice is.

Drumbeats recipe

  • JOB_CRON · the producer

    Did the cron actually fire on schedule?

  • JOB_HEARTBEAT · the worker loop

    Is the consume loop still pulling?

  • JOB_BASIC · per job (optional)

    Did each enqueued job actually run?

# producer (cron, hourly)
0 * * * * /opt/scripts/enqueue-invoices.sh && \
  curl -s "$DB/v1/ping/CRON_MONITOR_ID"

# worker loop (heartbeat every 5 min)
loop:
  job = queue.pop()
  process(job)
  curl -s "$DB/v1/ping/HEARTBEAT_MONITOR_ID"

Event-driven enqueue

A Stripe webhook handler receives `invoice.paid`, parses the payload, enqueues a "send receipt + render PDF" job. Or: a user uploads a video, the upload handler enqueues a transcoding job.

What goes silently wrong

The webhook returned 200 but the enqueue threw silently and got swallowed by middleware. The receipt is never sent. The producer side is the most invisible failure surface in event-driven systems.

Drumbeats recipe

  • JOB_BASIC · webhook handler

    Did the handler complete and successfully enqueue?

  • JOB_HEARTBEAT · the worker loop

    Is the worker processing what landed in the queue?

  • JOB_BASIC · per job

    Did the receipt actually go out / video transcode complete?

# webhook handler (Stripe → enqueue)
POST /webhooks/stripe:
  curl "$DB/v1/ping/WEBHOOK_MONITOR/start?run_id=$EVT"
  job = enqueue('send-receipt', evt)
  curl "$DB/v1/ping/WEBHOOK_MONITOR?run_id=$EVT"

# worker — same shape as cron-enqueue side
loop:
  job = queue.pop()
  curl "$DB/v1/ping/JOB_MONITOR/start?run_id=$JID"
  process(job)
  curl "$DB/v1/ping/JOB_MONITOR?run_id=$JID"

Self-rescheduling heartbeat

A polling job runs, completes, then schedules its own next run with `perform_in(5.minutes)`. Or a long-poll worker that re-enqueues itself after each iteration. The job IS the queue-feeder.

What goes silently wrong

If one run throws before the re-schedule line, the chain dies silently. There's no separate cron to alert on — the schedule lives inside the job. From the queue's perspective everything looks clean: the job ran, exited, queue empty.

Drumbeats recipe

  • JOB_HEARTBEAT · expected every interval

    Did the next ping arrive within the schedule + grace?

  • JOB_HEARTBEAT · worker instance

    A simple periodic ping from the consume loop itself — catches a process that died between jobs, even when no chain is running.

  • JOB_BASIC · per run (optional)

    Per-run duration, payload, exit code.

# self-rescheduling Sidekiq worker
class PollWorker
  include Sidekiq::Job

  def perform
    do_the_poll
    curl "$DB/v1/ping/HEARTBEAT_MONITOR_ID"
    self.class.perform_in(5.minutes)  # chain
  rescue => e
    curl "$DB/v1/ping/HEARTBEAT_MONITOR_ID/failure"
    raise  # let Sidekiq retry the schedule
  end
end

Most production setups blend two of these. A Stripe webhook (event-driven) enqueues a "send receipt" job that the worker loop drains. A scheduled cron (cron-driven) enqueues a daily batch into the same worker fleet. One Beats pool covers both producers, both consumer heartbeats, and the per-job lifecycle pings — no second SaaS to add per pattern.

Best practices by tool

The integration each queue library actually wants

Each tool has a hook that catches every job without per-handler changes — middleware, signals, events, callbacks. Wire it once and the silent-failure mode each library is most prone to becomes a Slack alert.

Sidekiq

Ruby

Without monitoring

Sidekiq says the worker is healthy. The consume loop is stuck on a poisoned job that's been re-failing for 11 hours. Queue depth chart shows it; nobody is watching the chart.

The fix

Add a server middleware. It runs around every job — one block, every queue. Send /start at the top, /success or /failure at the bottom. Match by jid.

  • Server middleware runs in every worker process — no per-job changes
  • `failure_tolerance` of 3 in Drumbeats absorbs transient retries cleanly
  • Pair with one heartbeat monitor at "every 5 min" to catch the consume loop stalling between jobs
# config/initializers/sidekiq.rb
require 'net/http'

class DrumbeatsMiddleware
  MONITOR = ENV.fetch('DRUMBEATS_MONITOR_ID')
  BASE = "https://api.drumbeats.io/v1/ping/#{MONITOR}"

  def call(worker, job, queue)
    run_id = job['jid']
    Net::HTTP.post(URI("#{BASE}/start?run_id=#{run_id}"), '')
    yield
    Net::HTTP.post(URI("#{BASE}?run_id=#{run_id}"), '')
  rescue => e
    Net::HTTP.post(URI("#{BASE}/failure?run_id=#{run_id}"), e.message)
    raise
  end
end

Sidekiq.configure_server do |config|
  config.server_middleware { |c| c.add DrumbeatsMiddleware }
end

Celery

Python

Without monitoring

Flower dashboard shows your worker is online. The broker connection silently dropped 40 minutes ago. The heartbeat to RabbitMQ is alive; the actual `task_prerun` signal hasn't fired since.

The fix

Use Celery signals. `task_prerun` for /start, `task_success` for /success, `task_failure` for /failure. Wire them once at app boot — every task is covered.

  • Signals fire in every worker, not just the dispatcher — the inside view
  • Pass `task_id` as `run_id` so the dashboard shows the full lifecycle
  • Add a heartbeat monitor pinging on the worker's `worker_ready` signal too — catches workers that crashed at startup
# celery_drumbeats.py
import os, requests
from celery.signals import task_prerun, task_success, task_failure

MONITOR = os.environ['DRUMBEATS_MONITOR_ID']
BASE = f"https://api.drumbeats.io/v1/ping/{MONITOR}"

@task_prerun.connect
def on_start(task_id=None, **_):
    requests.get(f"{BASE}/start", params={'run_id': task_id}, timeout=2)

@task_success.connect
def on_success(sender=None, **_):
    requests.get(BASE, params={'run_id': sender.request.id}, timeout=2)

@task_failure.connect
def on_failure(task_id=None, exception=None, **_):
    requests.post(f"{BASE}/failure",
        params={'run_id': task_id},
        json={'error': str(exception)},
        timeout=2)

BullMQ

Node.js

Without monitoring

Stalled-job detection is opt-in. By default a worker that crashes mid-job leaves the job locked and never re-queued. You won't see it until queue depth crosses your alert threshold (if you set one).

The fix

Subscribe to the Worker's `active`, `completed`, and `failed` events. Five lines, no per-handler changes. Works for any number of queues you spin up.

  • `active` fires for every job that starts processing — perfect /start hook
  • Use the BullMQ `job.id` as your `run_id` so the dashboard groups everything
  • Run a separate heartbeat monitor on the worker process itself for "did the loop crash?" alerting
// drumbeats-bullmq.ts
import { Worker } from 'bullmq';

const MONITOR = process.env.DRUMBEATS_MONITOR_ID!;
const base = `https://api.drumbeats.io/v1/ping/${MONITOR}`;

const ping = (path: string, runId: string, body?: unknown) =>
  fetch(`${base}${path}?run_id=${runId}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: body ? JSON.stringify(body) : undefined,
  }).catch(() => {});

const worker = new Worker('emails', handler, { connection });
worker.on('active', (job) => ping('/start', job.id!));
worker.on('completed', (job) => ping('', job.id!));
worker.on('failed', (job, err) =>
  ping('/failure', job!.id!, { error: err.message }));

RQ

Python

Without monitoring

RQ's worker shows "busy" while a job blocks on a Redis call that already timed out at the network layer. The Worker.work() loop never returns. Nothing surfaces until your customer notices.

The fix

Wrap your worker with `before_perform` / `after_perform` callbacks via Worker.work(with_scheduler=...). Or use the simpler `WORK_HORSE_KILLED_HANDLER` for fail-only signal.

  • Single ping pair (start + terminal) inside the perform callback covers every job
  • Add a 5-min heartbeat monitor pinging from the main worker loop too
  • `max_duration_seconds` on the monitor makes RQ's soft-timeout actionable
# drumbeats_rq.py
import os, requests
from rq import Worker, Queue, Connection

MONITOR = os.environ['DRUMBEATS_MONITOR_ID']
BASE = f"https://api.drumbeats.io/v1/ping/{MONITOR}"

def before_perform(job, *_, **__):
    requests.get(f"{BASE}/start", params={'run_id': job.id}, timeout=2)

def after_perform(job, *_, **__):
    requests.get(BASE, params={'run_id': job.id}, timeout=2)

def on_failure(job, *_, **__):
    requests.get(f"{BASE}/failure", params={'run_id': job.id}, timeout=2)

w = Worker([Queue('default')])
w.push_exc_handler(on_failure)
# Wrap perform to call before/after:
w.work(burst=False)

Why queue workers are uniquely hard

Workers fail "up" — and that’s the failure mode none of your dashboards catch

Queue worker monitoring is one of the most consistently under-served corners of operational observability. Every queue library — Sidekiq, Celery, BullMQ, RQ, Resque, Faktory — ships its own admin UI showing queue depth, processed counts, and worker heartbeats. They're great for the happy path. The catch: they all measure the wrong thing for the most common failure mode.

Workers don't typically die loudly. They fail "up": the process is still running, the heartbeat to the queue admin still fires, the dashboard still shows N workers green. But the work loop has stopped pulling messages — stuck on a poisoned payload, blocked on a database connection that never timed out, deadlocked on a third-party API that returns 200 but holds the connection open. From the outside everything looks fine. The queue depth quietly grows for hours.

Drumbeats heartbeats from INSIDE the consume loop. Every time the worker successfully processes a message (or completes a polling cycle, or finishes a batch), it pings. The moment the loop stops making progress — whatever the cause — the ping stops arriving and an incident fires. Pair that with /start + /failure on individual job runs and you also catch poisoned messages, retry storms, and jobs that "succeed" without actually doing anything.

Stuck on a poisoned message

A malformed payload that throws inside the handler, gets re-queued, and re-fails forever. The worker is alive; the queue is full. `failure_tolerance` (default 1, raise to 3 for transient-tolerant) plus per-job `/failure` pings make the retry storm visible within minutes.

Worker heartbeat fine, consume loop dead

Connection pool exhausted, deadlock with the DB, third-party API holding the connection open. The library says "worker is healthy"; the actual loop body has stopped running. A ping from inside the loop is the only signal that catches this — and `max_duration_seconds` plus the basic-watchdog auto-FAILS orphaned starts on the per-job side.

Job succeeded but produced nothing

An email batch that "completed" with zero recipients because the source query silently broke. `min_duration_seconds` surfaces DURATION_LOW incidents on suspiciously fast successes — the most common silent failure for billing and notification jobs.

Most teams start by monitoring the queue itself; they end by monitoring the work loop. The work loop is the thing that produces customer value, and it's the thing that fails in the most invisible ways.

One ping. You're done.

No SDK, no agent, no library. A single HTTP request is all it takes.

worker.ts
import { Worker } from 'bullmq';

const MONITOR_ID = 'YOUR_MONITOR_ID';
const base = `https://api.drumbeats.io/v1/ping/${MONITOR_ID}`;

const worker = new Worker('emails', async (job) => {
  await fetch(`${base}/start?run_id=${job.id}`);

  try {
    await sendEmail(job.data);
    await fetch(`${base}?run_id=${job.id}`);
  } catch (err) {
    await fetch(`${base}/failure?run_id=${job.id}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ error: err.message }),
    });
    throw err;
  }
});
Any language
No SDK needed
30 second setup
Works anywhere

What does it cost?

What 200K Beats actually buys you for queue workers

Each lifecycle-tracked job uses 2 Beats (start + terminal). Plus one heartbeat monitor at 5-minute cadence is ~8,640 Beats/month (30-day month). Free includes 200,000 Beats — about 95,000 fully tracked jobs per month plus the heartbeat. That covers most production queues.

200,000 Beats / month = $0 forever

Outgrowing Free? Pro at $20/mo gets 1,000,000 Beats — ~495,000 lifecycle-tracked jobs plus heartbeats. Same plan covers cron, background, and uptime monitors too.

Drumbeats vs. queue admin UIs

Sidekiq Pro Web, Celery Flower, BullMQ Dashboard — every queue ships its own admin UI. They're great for "what's in the queue right now". They're not great for "is the work actually happening".

Sidekiq Pro / Flower / BullMQ Dashboard
Drumbeats
Detects "worker up, but consume loop stalled"
Hard — library heartbeat reads as healthy regardless
Yes — heartbeat from inside the loop fires the moment progress stops
Catches a poisoned-message retry storm
Visible only via queue depth on a dashboard
`failure_tolerance` + per-job `run_id` timeline make repeats explicit
Per-job duration distribution
Some queues yes (Sidekiq Pro), most no
Built-in (avg / min / max / p95) per monitor
Cross-library view
One dashboard per library — Celery on Flower, Sidekiq on Web, BullMQ on Bull Board
Same monitor schema across all of them
Slack / PagerDuty / webhook alerts
Build a separate alerting layer on top
Native — every monitor routes to any combination of channels
Public status page for "is the queue draining?"
Engineer-built static page
One click on every plan, Free included
Cost at 10 worker monitors
Sidekiq Pro $1,490/yr per env + Slack integration eng time
Free tier covers most setups

FAQ

Common questions

Is your queue worker actually consuming?

Sidekiq Pro, Flower, BullMQ Dashboard — they all show green when the consume loop is dead. Add one heartbeat from inside the loop and you find out in seconds, not when a customer asks why their email never arrived.

No credit card required · 50 monitors free · Setup in 60 seconds

Keep exploring

What to read next

Related monitoring

Free tools

Resources