·QueueHub Team·16 min read

Proactive Redis Queue Health: Anomaly Detection and Capacity Planning for BullMQ

BullMQRedisAnomaly DetectionCapacity PlanningPredictive MonitoringTypeScriptSRE

Your dashboards are live. Your alerts fire when Redis hits 90% memory or latency spikes. You saw the problem coming three hours before the alert — the memory growth curve was linear, predictable, and completely invisible to your threshold-based monitoring tools.

This is the fundamental gap in queue observability today. Threshold-based monitoring tells you when something breaks. Anomaly detection and trend analysis tell you when something will break. The difference is the gap between being woken up at 3 AM to resize your ElastiCache instance versus doing it during business hours while sipping coffee.

In this post, we'll build a proactive monitoring pipeline for BullMQ queues that uses statistical anomaly detection, trend-based capacity forecasting, and a unified health scoring system. Every code example is production-ready TypeScript.

Statistical Anomaly Detection for Queue Metrics

Traditional threshold-based alerts — "alert if memory > 80%" or "alert if failed jobs > 10" — are brittle. They miss gradual degradation and trigger false alarms on predictable spikes. Statistical methods adapt to your queue's normal patterns automatically.

Z-Score Anomaly Detection for Job Failure Rates

A z-score measures how many standard deviations a data point is from the historical mean. Values with an absolute z-score greater than 3 are statistically unusual — likely anomalous.

For BullMQ, instead of hard-coding "alert on failed jobs > 10," compute the z-score of the failure rate over a rolling window. A burst of 20 failures might be normal during peak hours but anomalous at 3 AM.

import { Queue } from 'bullmq';
import IORedis from 'ioredis';

interface AnomalyResult {
  value: number;
  zScore: number;
  isAnomaly: boolean;
  mean: number;
  stdDev: number;
}

class ZScoreAnomalyDetector {
  private windows = new Map<string, number[]>();

  constructor(
    private readonly windowSize = 60,
    private readonly threshold = 3
  ) {}

  feed(key: string, value: number): AnomalyResult {
    let window = this.windows.get(key);
    if (!window) {
      window = [];
      this.windows.set(key, window);
    }

    window.push(value);
    if (window.length > this.windowSize) window.shift();

    if (window.length < 10) {
      return { value, zScore: 0, isAnomaly: false, mean: value, stdDev: 0 };
    }

    const mean = window.reduce((a, b) => a + b, 0) / window.length;
    const variance = window.reduce((sum, v) => sum + (v - mean) ** 2, 0) / window.length;
    const stdDev = Math.sqrt(variance) || 1;
    const zScore = (value - mean) / stdDev;

    return { value, zScore, isAnomaly: Math.abs(zScore) > this.threshold, mean, stdDev };
  }
}

// Usage with BullMQ
async function monitorFailureAnomalies(queue: Queue, detector: ZScoreAnomalyDetector) {
  const counts = await queue.getJobCounts('failed', 'completed');
  const total = counts.completed + counts.failed;
  const failureRate = total > 0 ? (counts.failed / total) * 100 : 0;

  const result = detector.feed(queue.name, failureRate);
  console.log(`[${queue.name}] Failure rate: ${failureRate.toFixed(2)}%`);
  console.log(`  Z-score: ${result.zScore.toFixed(2)}${result.isAnomaly ? '🚨 ANOMALY' : '✅ Normal'}`);
  return result;
}

const connection = new IORedis({ host: 'localhost', port: 6379, maxRetriesPerRequest: null });
const queue = new Queue('notifications', { connection });
const detector = new ZScoreAnomalyDetector(60, 3);

EWMA for Detecting Memory Acceleration

The Z-score detects point anomalies, but the most dangerous queue problems are gradual. Memory leaks, connection pool exhaustion, and worker degradation all happen slowly. The Exponentially Weighted Moving Average (EWMA) gives more weight to recent observations, making it sensitive to trend changes.

class EWMAMemoryDetector {
  private ema: number | null = null;
  private emv = 0;
  private dataPoints = 0;

  constructor(private readonly alpha = 0.15) {}

  feed(memoryPercent: number) {
    this.dataPoints++;

    if (this.ema === null) {
      this.ema = memoryPercent;
      return { smoothed: memoryPercent, raw: memoryPercent, deviation: 0, isAccelerating: false };
    }

    const prevEma = this.ema;
    this.ema = this.alpha * memoryPercent + (1 - this.alpha) * this.ema;

    const diff = memoryPercent - this.ema;
    this.emv = (1 - this.alpha) * (this.emv + this.alpha * diff * diff);

    const stdDev = Math.sqrt(this.emv);
    const deviation = (memoryPercent - this.ema) / (stdDev || 1);

    return {
      smoothed: this.ema,
      raw: memoryPercent,
      deviation,
      isAccelerating: deviation > 2.0 && this.dataPoints > 10,
    };
  }
}

async function pollRedisMemory(connection: IORedis.Redis, maxMemoryMB: number, detector: EWMAMemoryDetector) {
  const info = await connection.info('memory');
  const match = info.match(/used_memory:(\d+)/);
  if (!match) throw new Error('Could not parse used_memory');

  const usedPercent = (parseInt(match[1], 10) / (1024 * 1024)) / maxMemoryMB * 100;
  const result = detector.feed(usedPercent);

  console.log(`[Memory] ${result.isAccelerating ? '⚠️ ACCELERATING' : '✅ Stable'}${usedPercent.toFixed(1)}% (smoothed: ${result.smoothed.toFixed(1)}%, deviation: ${result.deviation.toFixed(2)}σ)`);
  return result;
}

Pattern Recognition: Spike vs. Drift vs. Cycle

Not all anomalies are the same. The shape of the anomaly tells you what's happening:

Pattern Z-Score Signature Likely Cause
Spike failure |z| > 4, single point API outage, network blip
Gradual drift |z| climbs 1→2→3 over hours Memory leak, connection drain
Daily oscillation |z| cycles at 24h intervals Peak-hour load, global cron
Step change |z| jumps and stays Config change, new deploy
Random bursts |z| spikes then recovers Retry storms, network jitter

A combined approach — Z-score for point anomalies and EWMA for trend shifts — gives you coverage for both acute and chronic problems.

Trend Analysis and Predictive Capacity Planning

Reactive scaling (adding Redis memory after OOM) is expensive and dangerous. Predictive capacity planning uses historical trends to forecast when you'll hit limits.

Linear Regression for Memory Growth Forecasting

Fit a line to historical memory usage data and project forward. The slope tells you your daily growth rate. The intersection with your maxmemory threshold tells you your "day of reckoning."

interface DataPoint { timestamp: number; value: number; }

interface ForecastResult {
  slopeMBPerDay: number;
  rSquared: number;
  forecast: (daysFromNow: number) => number;
  daysUntilLimit: number | null;
}

class MemoryForecaster {
  forecast(data: DataPoint[], maxMemoryMB: number): ForecastResult {
    const t0 = data[0].timestamp;
    const days = data.map(d => (d.timestamp - t0) / 86400000);
    const values = data.map(d => d.value);
    const n = data.length;

    const sumX = days.reduce((a, b) => a + b, 0);
    const sumY = values.reduce((a, b) => a + b, 0);
    const sumXY = days.reduce((s, x, i) => s + x * values[i], 0);
    const sumX2 = days.reduce((s, x) => s + x * x, 0);

    const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
    const intercept = (sumY - slope * sumX) / n;

    // R-squared
    const meanY = sumY / n;
    const ssRes = values.reduce((s, y, i) => s + (y - (intercept + slope * days[i])) ** 2, 0);
    const ssTot = values.reduce((s, y) => s + (y - meanY) ** 2, 0);
    const rSquared = 1 - ssRes / ssTot;

    const lastDay = days[days.length - 1];
    let daysUntilLimit: number | null = null;
    if (slope > 0) {
      daysUntilLimit = Math.max(0, (maxMemoryMB - intercept) / slope - lastDay);
    }

    return {
      slopeMBPerDay: slope * 86400000,
      rSquared,
      forecast: (d: number) => intercept + slope * (lastDay + d),
      daysUntilLimit,
    };
  }
}

async function generateCapacityReport(connection: IORedis.Redis, maxMemoryMB: number, history: DataPoint[]) {
  const forecaster = new MemoryForecaster();
  const result = forecaster.forecast(history, maxMemoryMB);
  const current = history[history.length - 1].value;

  console.log('=== Redis Capacity Forecast ===');
  console.log(`Current: ${current.toFixed(0)} MB / ${maxMemoryMB} MB (${(current / maxMemoryMB * 100).toFixed(1)}%)`);
  console.log(`Growth rate: ${result.slopeMBPerDay.toFixed(0)} MB/day`);
  console.log(`R² fit: ${result.rSquared.toFixed(3)}`);

  if (result.daysUntilLimit !== null) {
    if (result.daysUntilLimit <= 7) {
      console.log(`🚨 Will hit maxmemory in ${result.daysUntilLimit.toFixed(1)} days — URGENT`);
    } else if (result.daysUntilLimit <= 30) {
      console.log(`⚠️ Will hit maxmemory in ${result.daysUntilLimit.toFixed(1)} days — plan resize`);
    } else {
      console.log(`✅ Projected to hit maxmemory in ${result.daysUntilLimit.toFixed(1)} days — adequate`);
    }
    console.log(`  30-day forecast: ${result.forecast(30).toFixed(0)} MB`);
    console.log(`  90-day forecast: ${result.forecast(90).toFixed(0)} MB`);
  }
}

Backlog Trend Analysis

A steadily growing backlog means you're losing ground. The formula for "days to drain" tells you whether your current worker pool is sustainable:

daysToDrain = backlogSize / (throughputPerDay - arrivalRatePerDay)

If the arrival rate exceeds throughput, the backlog grows forever — you need more workers.

interface BacklogAnalysis {
  currentBacklog: number;
  arrivalRatePerHour: number;
  throughputPerHour: number;
  backlogTrend: 'growing' | 'shrinking' | 'stable';
  daysToDrain: number | null;
  workersNeeded: number;
}

class BacklogAnalyzer {
  analyze(snapshots: { timestamp: number; waiting: number; completed: number; failed: number }[]): BacklogAnalysis {
    const first = snapshots[0];
    const last = snapshots[snapshots.length - 1];
    const hours = (last.timestamp - first.timestamp) / 3600000;

    const processed = (last.completed - first.completed) + (last.failed - first.failed);
    const throughput = processed / hours;
    const arrived = (last.waiting - first.waiting) + processed;
    const arrivalRate = arrived / hours;

    const netRate = throughput - arrivalRate;
    let trend: 'growing' | 'shrinking' | 'stable';
    let daysToDrain: number | null = null;
    let workersNeeded = 0;

    if (Math.abs(netRate) < arrivalRate * 0.05) {
      trend = 'stable';
    } else if (netRate < 0) {
      trend = 'growing';
      workersNeeded = Math.ceil(Math.abs(netRate) / throughput);
    } else {
      trend = 'shrinking';
      daysToDrain = last.waiting / (netRate * 24);
    }

    return {
      currentBacklog: last.waiting,
      arrivalRatePerHour: Math.round(arrivalRate * 100) / 100,
      throughputPerHour: Math.round(throughput * 100) / 100,
      backlogTrend: trend,
      daysToDrain: daysToDrain !== null ? Math.round(daysToDrain * 10) / 10 : null,
      workersNeeded,
    };
  }
}

Getting the Raw Data from BullMQ

BullMQ's built-in getMetrics() method provides per-minute job completion and failure counts. This is the foundation data for all trend analysis:

import { MetricsTime } from 'bullmq';

async function collectMetrics(queue: Queue) {
  const completed = await queue.getMetrics('completed', 0, MetricsTime.ONE_WEEK * 2);
  const failed = await queue.getMetrics('failed', 0, MetricsTime.ONE_WEEK * 2);

  console.log(`Data points: ${completed.data.length} (completed), ${failed.data.length} (failed)`);
  console.log(`Last minute: ${completed.data[completed.data.length - 1]} completed`);
  return { completed, failed };
}

Building a Queue Health Scoring System

Individual metrics are noise. A health score aggregates multiple signals into a single number (0–100) that tells you at a glance whether your queue infrastructure needs attention.

Factor Weight What It Measures
Memory pressure 30% Used / maxmemory ratio
Backlog health 25% Trend direction + size
Failure rate 20% Z-score deviation from baseline
Processing latency 15% EWMA-smoothed completion time
Worker coverage 10% Active vs. required workers
interface HealthScoreInput {
  memoryUsagePercent: number;
  backlogGrowing: boolean;
  backlogSize: number;
  failureRate: number;
  failureZScore: number;
  avgProcessingTimeMs: number;
  expectedProcessingTimeMs: number;
  activeWorkers: number;
  requiredWorkers: number;
}

interface HealthScoreResult {
  score: number;
  grade: 'A' | 'B' | 'C' | 'D' | 'F';
  recommendations: string[];
}

class QueueHealthScorer {
  score(inputs: HealthScoreInput): HealthScoreResult {
    const recs: string[] = [];

    const memoryScore = Math.max(0, 100 - inputs.memoryUsagePercent * 1.1);
    if (inputs.memoryUsagePercent > 80) recs.push('Redis memory above 80%. Review retention policies.');

    let backlogScore: number;
    if (inputs.backlogGrowing) {
      backlogScore = Math.max(0, 100 - Math.log2(inputs.backlogSize + 1) * 15);
      recs.push(`Backlog growing (${inputs.backlogSize} waiting). Consider adding workers.`);
    } else {
      backlogScore = inputs.backlogSize > 1000 ? 70 : 95;
    }

    let failureScore: number;
    if (inputs.failureZScore > 3) {
      failureScore = Math.max(0, 100 - (inputs.failureZScore - 3) * 25);
      recs.push(`Abnormal failure rate (z=${inputs.failureZScore.toFixed(1)}).`);
    } else if (inputs.failureRate > 5) {
      failureScore = 60;
    } else {
      failureScore = 95;
    }

    const ratio = inputs.avgProcessingTimeMs / (inputs.expectedProcessingTimeMs || 1);
    const latencyScore = ratio <= 1 ? 100 : ratio <= 2 ? 80 : ratio <= 5 ? 50 : Math.max(0, 100 - ratio * 10);
    if (ratio > 2) recs.push(`Processing times ${ratio.toFixed(1)}x expected. Investigate workers.`);

    const workerRatio = inputs.activeWorkers / (inputs.requiredWorkers || 1);
    const workerScore = workerRatio >= 1 ? 100 : workerRatio >= 0.75 ? 80 : workerRatio >= 0.5 ? 60 : 30;
    if (workerRatio < 0.75) recs.push(`Worker shortage (${inputs.activeWorkers}/${inputs.requiredWorkers}).`);

    const score = Math.round(
      memoryScore * 0.30 + backlogScore * 0.25 + failureScore * 0.20 + latencyScore * 0.15 + workerScore * 0.10
    );

    const grade: HealthScoreResult['grade'] = score >= 90 ? 'A' : score >= 70 ? 'B' : score >= 50 ? 'C' : score >= 30 ? 'D' : 'F';

    return { score, grade, recommendations: recs };
  }
}

// Usage
async function reportHealth(queue: Queue, connection: IORedis.Redis) {
  const counts = await queue.getJobCounts('waiting', 'active', 'completed', 'failed');
  const info = await connection.info('memory');
  const used = parseInt(info.match(/used_memory:(\d+)/)?.[1] ?? '0', 10);
  const max = parseInt(info.match(/maxmemory:(\d+)/)?.[1] ?? '0', 10);

  const scorer = new QueueHealthScorer();
  const report = scorer.score({
    memoryUsagePercent: max > 0 ? (used / max) * 100 : 50,
    backlogGrowing: false,
    backlogSize: counts.waiting,
    failureRate: counts.completed > 0 ? (counts.failed / (counts.completed + counts.failed)) * 100 : 0,
    failureZScore: 1.2,
    avgProcessingTimeMs: 450,
    expectedProcessingTimeMs: 300,
    activeWorkers: 3,
    requiredWorkers: 5,
  });

  console.log(`\n=== Health Score: ${queue.name} ===`);
  console.log(`Overall: ${report.score}/100 (Grade ${report.grade})`);
  report.recommendations.forEach(r => console.log(`  → ${r}`));
}

SLO Tracking with Burn-Rate Alerting

Queue health is a means to an end — delivering jobs within SLO. Instead of alerting after a breach, use burn-rate alerting to catch violations before they happen.

The idea: Track your error budget consumption rate. A burn rate of 1.0 means you're consuming budget at exactly the rate that exhausts it at the end of the window. A burn rate of 2.0 means you'll exhaust it twice as fast.

SLO Definition Typical Target
Processing SLO Jobs completed within time threshold 99% < 30s
Freshness SLO Jobs picked up within delay 99.9% < 5s
Completion SLO Jobs that eventually succeed 99.5%
Throughput SLO Minimum jobs per minute Varies
interface SLOConfig {
  target: number;       // e.g., 0.995 for 99.5%
  burnRateThresholds: { critical: number; warning: number };
}

class SLOBurnRateTracker {
  constructor(private config: SLOConfig) {}

  evaluate(totalJobs: number, failedJobs: number): { attainment: number; budgetRemaining: number; burnRate: number; status: string } {
    const ok = totalJobs - failedJobs;
    const attainment = totalJobs > 0 ? ok / totalJobs : 1;
    const allowedErrors = Math.floor(totalJobs * (1 - this.config.target));
    const budgetRemaining = Math.max(0, allowedErrors - failedJobs);
    const allowedErrorRate = 1 - this.config.target;
    const burnRate = allowedErrorRate > 0 ? (failedJobs / totalJobs) / allowedErrorRate : 0;

    let status: string;
    if (burnRate >= this.config.burnRateThresholds.critical) status = '🚨 CRITICAL';
    else if (burnRate >= this.config.burnRateThresholds.warning) status = '⚠️ WARNING';
    else status = '✅ HEALTHY';

    return {
      attainment: Math.round(attainment * 10000) / 100,
      budgetRemaining: Math.round(budgetRemaining * 100) / 100,
      burnRate: Math.round(burnRate * 100) / 100,
      status,
    };
  }
}

async function checkSLO(queue: Queue) {
  const metrics = await queue.getMetrics('completed', 0, MetricsTime.ONE_WEEK);
  const failedMetrics = await queue.getMetrics('failed', 0, MetricsTime.ONE_WEEK);

  const totalCompleted = metrics.data.reduce((a, b) => a + b, 0);
  const totalFailed = failedMetrics.data.reduce((a, b) => a + b, 0);

  const tracker = new SLOBurnRateTracker({
    target: 0.995,
    burnRateThresholds: { critical: 3, warning: 1.5 },
  });

  const result = tracker.evaluate(totalCompleted + totalFailed, totalFailed);
  console.log(`SLO: ${result.attainment}% | Budget: ${result.budgetRemaining}% | Burn rate: ${result.burnRate}x | ${result.status}`);
}

Automated Health Reports and Cost Optimization

Real-time dashboards are great for firefighting. Period-over-period reports reveal slow-burn trends that dashboards flatten.

Weekly Comparison Reports

Track week-over-week changes for all key metrics. A 5% week-over-week memory increase sustained for a month is a 22% monthly growth — invisible on any single day's dashboard but obvious in a trend view.

interface WeeklyComparison {
  metric: string;
  current: number;
  previous: number;
  changePercent: number;
  trend: 'good' | 'bad' | 'neutral';
}

function compareWeek(current: any, previous: any): WeeklyComparison[] {
  const compute = (metric: string, cur: number, prev: number, lowerBetter: boolean) => {
    const change = prev > 0 ? ((cur - prev) / prev) * 100 : 0;
    const isBad = lowerBetter ? change > 5 : change < -5;
    return { metric, current: cur, previous: prev, changePercent: Math.round(change * 10) / 10, trend: isBad ? 'bad' : Math.abs(change) > 5 ? 'good' : 'neutral' as const };
  };

  return [
    compute('Memory %', current.memoryPct, previous.memoryPct, true),
    compute('Failure Rate %', current.failureRate, previous.failureRate, true),
    compute('Backlog', current.backlog, previous.backlog, true),
    compute('Throughput', current.throughput, previous.throughput, false),
    compute('Health Score', current.healthScore, previous.healthScore, false),
  ];
}

// Schedule as a BullMQ repeatable job
const reportQueue = new Queue('queue-health-reports', { connection });
await reportQueue.upsertJobScheduler('weekly-report', { pattern: '0 9 * * 1' }, {
  name: 'generate-report',
  data: { type: 'weekly', channels: ['slack:#ops'] },
  opts: { removeOnComplete: { age: 86400 * 30 } },
});

Data-Driven Redis Right-Sizing

Most teams either over-provision (wasting money) or under-provision (risking OOM). Trend data lets you find the sweet spot.

Use your linear regression forecast to determine the optimal Redis instance size, then add 30% headroom for spikes:

required = max(currentUsage, forecast90day) × 1.3

Round up to the nearest instance tier. The cost savings from downsizing one over-provisioned database can easily pay for your entire monitoring stack.

Integrating with Queue Hub

Queue Hub surfaces these predictive signals alongside real-time queue data, giving operators a single pane of glass for both reactive and proactive monitoring:

  • Health score timeline — plotted health score over days and weeks, annotated with anomalous events
  • Capacity forecast cards — "Forecast to hit 90% memory in 14 days" on your queue overview
  • Anomaly feed — z-score events logged and correlated with job types and deployment markers
  • SLO burn-rate alerts — integrated notifications when burn rates exceed thresholds
  • Weekly health digests — auto-generated reports comparing this week to last

Pushing anomaly events to Queue Hub is as simple as a webhook call:

async function reportAnomaly(event: { queueName: string; metric: string; value: number; zScore: number }) {
  await fetch('https://api.queuehub.tech/v1/events', {
    method: 'POST',
    headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' },
    body: JSON.stringify({ type: 'anomaly_detected', source: 'proactive-monitor', ...event }),
  });
}

Putting It All Together

The full proactive monitoring pipeline looks like this:

Data Collection → Analysis Layer → Action Layer → Output
  Redis INFO       Z-Score          Health score    Queue Hub UI
  BullMQ metrics   EWMA             Forecast        Slack / email
  Job counts       Regression       SLO tracker     PagerDuty
                   Trend analysis   Reports

Each component runs as a scheduled job (or a BullMQ repeatable job itself) that feeds into the next stage. The end result is a system that doesn't just tell you when Redis is at 95% memory — it tells you when it will reach 95% and what to do about it.

Try It with Queue Hub

Ready to move beyond reactive monitoring? Queue Hub gives you the health scores, capacity forecasts, and anomaly detection covered in this post — alongside full queue management, job inspection, and live worker views. Try Queue Hub today and see what your Redis queues have been trying to tell you.

Related Articles