Skip to content
ExponaBLOG
PROMETHEUS, GRAFANA & MONITORING · DAY 083 / 100

Monitoring background workers: best practices explained

Jobs need metrics too.

Expona Admin8 min read

Introduction

Background workers handle the heavy lifting in modern applications—sending emails, processing payments, generating reports, syncing data across systems. Yet many teams invest heavily in monitoring API endpoints and databases while treating job queues as a black box. Monitoring background workers: best practices explained means capturing the full operational picture of your async workloads, so you catch problems before they cascade into production incidents.

Without proper metrics, you're flying blind. A job queue can silently accumulate thousands of unprocessed tasks while your dashboards remain green. In this article, we'll walk through the essential metrics every background worker needs, from simple counters to sophisticated queue depth monitoring, with patterns you can apply whether you're using Bull, Celery, RabbitMQ, or Kafka.

Jobs processed and failed counters

The foundation of any background worker monitoring strategy is straightforward: count what goes in, count what comes out, and track what breaks. These counters tell you the health and throughput of your job system at a glance.

A processed counter increments when a job completes successfully. A failed counter increments when a job exhausts retries or throws an unhandled exception. Together, they form your primary signal for worker health. If processed jobs stop climbing but system resources look fine, something is wrong upstream—maybe the job queue is empty, maybe workers crashed, maybe a new code deployment introduced a bug.

Here's how you'd instrument a NestJS worker using Prometheus:

typescript
import { Injectable } from '@nestjs/common';
import { Counter } from 'prom-client';

@Injectable()
export class JobMetricsService {
  private jobsProcessed = new Counter({
    name: 'jobs_processed_total',
    help: 'Total number of jobs processed',
    labelNames: ['job_type', 'status'],
  });

  recordJobCompletion(jobType: string, status: 'success' | 'failure') {
    this.jobsProcessed.labels(jobType, status).inc();
  }
}

// In your worker handler
async handleEmailJob(job: Job) {
  try {
    await sendEmail(job.data);
    metricsService.recordJobCompletion('email', 'success');
  } catch (error) {
    metricsService.recordJobCompletion('email', 'failure');
    throw error; // Let Bull handle retry logic
  }
}

Notice the label_names including job type. This lets you query success rates per job type in Grafana—critical because one slow email service shouldn't hide that your report generation is failing silently. You can then alert when the failure rate for any job type exceeds a threshold, say 5% over five minutes.

In a Python/Celery environment, the pattern is similar:

python
from prometheus_client import Counter
from celery import shared_task

jobs_processed = Counter(
    'jobs_processed_total',
    'Total jobs processed',
    ['task_name', 'status']
)

@shared_task
def process_payment(order_id):
    try:
        # Process payment
        jobs_processed.labels('process_payment', 'success').inc()
    except Exception as e:
        jobs_processed.labels('process_payment', 'failure').inc()
        raise

Track your counters daily. Create a Grafana panel showing the rate of processed and failed jobs. A sudden drop in processed jobs often signals that workers stopped picking up tasks. A spike in failures might indicate an external dependency (payment API down, database unavailable) or a code issue introduced in the last deployment.

Job duration histograms

Counters tell you how many jobs completed. Histograms tell you how long they took. Duration metrics unlock performance insights and help you catch regressions before they become operational problems.

Different job types have wildly different performance profiles. Sending an email might take 500ms; generating a PDF report might take 30 seconds. A histogram with appropriate buckets captures this distribution without collapsing all jobs into a single "average" figure that hides outliers.

Set up buckets that match your expected ranges. For email jobs, buckets like [0.1, 0.5, 1, 2, 5] seconds make sense. For report generation, try [1, 5, 10, 30, 60] seconds. Prometheus calculates percentiles (p50, p95, p99) from these buckets, giving you visibility into tail latency.

typescript
import { Histogram } from 'prom-client';

const jobDuration = new Histogram({
  name: 'job_duration_seconds',
  help: 'Job execution duration in seconds',
  labelNames: ['job_type'],
  buckets: [0.1, 0.5, 1, 2, 5, 10, 30, 60],
});

async handleEmailJob(job: Job) {
  const timer = jobDuration.startTimer({ labels: { job_type: 'email' } });
  try {
    await sendEmail(job.data);
  } finally {
    timer(); // Records the duration automatically
  }
}

In Grafana, plot the 95th percentile duration for each job type on a time-series dashboard. If p95 email job duration suddenly jumps from 800ms to 5 seconds, you've got a performance regression—investigate before it degrades user experience. If median duration creeps upward week over week, that's a signal to optimize or scale horizontally.

Histograms also reveal if certain job instances are outliers. A single payment processing job taking 120 seconds while others finish in 2 seconds suggests that specific request hit a timeout or retry loop. Correlate high-duration jobs with logs to understand why.

Queue wait time

Duration metrics measure how long a job takes to execute once a worker picks it up. Queue wait time measures how long a job sits in the queue before execution begins. This gap is crucial.

When a queue is healthy, jobs are picked up immediately—wait time is near zero. When workers are saturated or crashed, wait time climbs. High wait time means users experience delayed notifications, slower report delivery, and overall system latigue. It's the earliest warning sign that you need more workers or your jobs are taking too long.

Capture the job creation timestamp and record the delta when the worker starts execution:

typescript
import { Histogram } from 'prom-client';

const queueWaitTime = new Histogram({
  name: 'queue_wait_time_seconds',
  help: 'Time job spent waiting in queue',
  labelNames: ['job_type'],
  buckets: [0.1, 0.5, 1, 5, 10, 30, 60],
});

async handleJob(job: Job) {
  const createdAt = job.timestamp || Date.now();
  const waitSeconds = (Date.now() - createdAt) / 1000;
  queueWaitTime.labels(job.name).observe(waitSeconds);
  
  // Now process the job
}

Set an alert on queue wait time. If the 95th percentile wait time exceeds 30 seconds for critical jobs like payment processing, page the on-call engineer. Wait time is your canary—it tells you before actual job processing fails that your system is under strain.

In a Redis-backed queue, you can also query queue length directly and correlate it with wait time. If queue length is growing but wait time stays flat, workers are catching up. If both grow together, you have a scaling problem.

Alert on growing backlogs

A backlog is the number of jobs waiting in the queue. Unlike duration and wait time, backlog is a gauge—a point-in-time measurement—not a counter or histogram. It's simple to compute but critical to monitor.

Most queue libraries provide a way to inspect queue length. In Bull (Node.js/Redis), it's queue.count(). In Celery, it's app.control.inspect().active(). Export this as a gauge metric every 10-30 seconds:

typescript
import { Gauge } from 'prom-client';

const queueDepth = new Gauge({
  name: 'queue_depth',
  help: 'Number of jobs waiting in queue',
  labelNames: ['queue_name'],
});

// Poll queue depth periodically
setInterval(async () => {
  const pendingCount = await emailQueue.count();
  queueDepth.labels('email').set(pendingCount);
}, 15000); // Every 15 seconds

A stable backlog means workers are keeping up with inbound jobs. A linearly growing backlog means workers are falling behind—jobs are arriving faster than they're being processed. An exponentially growing backlog is an emergency: you're heading toward a queue overflow or system saturation.

Set up Grafana alerting on backlog growth rate. For example, trigger an alert if queue depth increases by more than 100 jobs in a 5-minute window. This gives you time to scale workers, debug slow processing, or throttle incoming job submissions before the system melts down.

yaml
alert: HighQueueBacklog
expr: rate(queue_depth[5m]) > 100
for: 5m
annotations:
  summary: "Queue backlog growing rapidly"
  description: "{{ $labels.queue_name }} backlog increasing at {{ $value }} jobs/min"

In production, we've found that monitoring backlog growth saved us from multiple incidents. Once, a database connection pool exhaustion caused jobs to slow down. The backlog alert fired within minutes; we scaled workers horizontally and added connection pool monitoring. Without backlog visibility, users would have experienced 30+ minute delays before anyone noticed.

Common mistakes

Only monitoring overall throughput, not per-job-type metrics: When you aggregate all jobs into a single "jobs processed" counter, a failure in one job type hides in the noise. Always use labels to break down metrics by job type, queue, or tenant. This surfaces issues quickly.

Setting histogram buckets incorrectly: Buckets that are too coarse (e.g., [0, 60] seconds) or too fine (e.g., [0.001, 0.002, 0.003]) waste storage or miss important percentiles. Choose buckets that match your expected job duration ranges and the SLAs you care about.

Ignoring queue wait time: Many teams focus on job execution time but ignore how long jobs wait. Wait time is often a better leading indicator of problems—it rises before users complain.

Alerting on absolute values instead of rates: An alert like "queue depth > 1000" might be fine during a batch processing run but catastrophic for a stream processing system. Alert on rate of change (jobs queued minus jobs processed) instead.

Not correlating worker metrics with system resources: High job duration might be due to CPU throttling, memory swapping, or I/O contention. Always have worker CPU, memory, and disk metrics visible on the same dashboard as job metrics. Don't assume the job is slow; check if the worker is starved.

Key takeaways

  • Instrument jobs with counters for success and failure, labeled by job type, to track throughput and catch regressions fast.
  • Use histograms to measure job duration with appropriate buckets matching your expected performance ranges; alert on p95 latency.
  • Track queue wait time as an early warning signal of worker saturation; high wait time often precedes job failures.
  • Monitor queue backlog (depth) and alert on growth rate, not absolute value; exponential growth means your system is struggling to keep up.
  • Always correlate job metrics with system resource metrics (CPU, memory, disk, connections) to diagnose root causes accurately.
  • Break down all metrics by job type, queue, or tenant so that failures in one don't hide behind aggregated totals.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles