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

Monitoring message queues: practical tips and trade-offs

Queue health at a glance.

Expona Admin7 min read

Monitoring message queues: practical tips and trade-offs

Message queues sit at the heart of distributed systems, but they're also invisible until something breaks. By day 60 of our monitoring journey, you've likely built dashboards for compute and storage; now we need to extend that discipline to the asynchronous backbone that powers order processing, notifications, and event streaming. In this article, we'll explore the core signals that tell you whether your queues are healthy, what trade-offs matter when choosing which metrics to expose, and how to avoid the pitfalls that sneak up on teams running Kafka, RabbitMQ, or SQS in production.

Queue depth and age of oldest message

Queue depth—the total number of messages waiting to be consumed—is often the first metric teams instrument. It's intuitive: if depth keeps growing, you have a backlog. But depth alone doesn't tell the whole story. A queue with 50,000 messages where consumers drain 10,000 per second is healthy; the same depth where consumers process 100 per second is a problem.

More important than raw depth is the age of the oldest message. This metric answers the question: how long has the oldest unconsumed message been sitting there? If messages are stuck for hours or days, you've got a silent failure—consumers might be running but not making progress, or the entire consumer group might be down.

In Prometheus terms, we recommend tracking both metrics separately and setting alerts on age rather than depth:

yaml
# Prometheus scrape config for RabbitMQ
scrape_configs:
  - job_name: 'rabbitmq'
    static_configs:
      - targets: ['localhost:15692']

# Example alerts
groups:
  - name: queue_health
    rules:
      - alert: QueueMessageAgeHigh
        expr: rabbitmq_queue_oldest_message_age_seconds > 3600
        for: 5m
        annotations:
          summary: "Queue {{ $labels.queue }} has messages older than 1 hour"
      
      - alert: QueueDepthIncreasing
        expr: rate(rabbitmq_queue_messages_total[5m]) > 0
        for: 10m
        annotations:
          summary: "Queue {{ $labels.queue }} depth increasing over 10 minutes"

In a real e-commerce scenario: during a flash sale, order events pile up faster than the payment processor can handle them. You see queue depth hit 100,000. But if the oldest message is only 8 seconds old, your system is actually coping—it's just under load. Now flip the scenario: depth is 5,000 but the oldest message is 45 minutes old. That's a signal to page someone immediately, because something is stuck.

The trade-off here is instrumentation cost. Computing the age of the oldest message requires either scanning the queue (expensive for large queues) or relying on broker-provided metrics. With RabbitMQ and Kafka, these metrics come relatively cheaply; with custom queue implementations, you'll pay more. Accept this cost—the insight is worth it.

Consumer lag for Kafka

Kafka consumer lag is the delta between the current offset of your consumer group and the latest offset in a partition. It's a direct measure of how far behind consumers are. Unlike general-purpose queues, Kafka's offset-based model makes lag explicit and queryable.

Consumer lag answers: how many messages is my consumer group behind? If lag is steady at 1,000 messages and your consumer group drains 5,000 per minute, you're losing ground. If lag oscillates between 100 and 500 as batches complete, you're healthy.

We capture Kafka lag via the JMX exporter or third-party exporters like Burrow:

yaml
# Prometheus recording rule for Kafka lag
groups:
  - name: kafka_lag
    interval: 30s
    rules:
      - record: kafka:consumer_lag:sum
        expr: sum by (consumer_group, topic) (
          kafka_consumer_lag{job="kafka"}
        )
      
      - record: kafka:consumer_lag_max:sum
        expr: max by (consumer_group, topic) (
          kafka_consumer_lag{job="kafka"}
        )

Visualize lag per consumer group and topic in Grafana. Set two thresholds: a warning threshold (lag growing) and a critical threshold (lag exceeding your SLA, e.g., 10,000 messages). In a ride-hailing platform, if the driver-location topic shows lag > 50,000, you're not ingesting location updates fast enough—drivers see stale positions on the map.

The trade-off: Kafka lag is cheap to measure but requires understanding your topology. Lag per partition vs. lag per consumer group tells different stories. Lag per partition reveals skew (one slow consumer, uneven partition distribution). Lag per consumer group hides partition-level issues. Measure both, alert on the group, and drill down to partitions only when the group alert fires.

Processing rate vs incoming rate

Queue depth only tells you the current snapshot. To predict whether you'll recover, you need to compare how fast messages are arriving versus how fast they're being consumed.

Define:

  • Incoming rate: messages enqueued per second
  • Processing rate: messages consumed/acknowledged per second

If incoming rate exceeds processing rate consistently, depth will grow unbounded. If processing rate > incoming rate, you're recovering. If they're equal, you're in steady state.

Calculate these with Prometheus rate functions:

yaml
# Recording rules
groups:
  - name: queue_rates
    interval: 1m
    rules:
      - record: queue:messages_incoming:rate1m
        expr: rate(queue_messages_published_total[1m])
      
      - record: queue:messages_processed:rate1m
        expr: rate(queue_messages_acked_total[1m])
      
      - record: queue:backlog_recovery:ratio
        expr: queue:messages_processed:rate1m / (queue:messages_incoming:rate1m + 0.001)

Plot both rates on the same graph. When processing rate dips below incoming rate, you enter backlog mode. Use this to trigger autoscaling: if processing rate < incoming rate for 2 minutes, spin up more consumers. In a notification system, if SMS processing drops from 1,000/sec to 800/sec but inbound requests stay at 1,200/sec, you know to add capacity.

The trade-off: rate calculations are sensitive to scrape intervals and evaluation windows. A 1-minute window hides spiky traffic; a 30-second window creates noise. Use a conservative window (5 minutes) for scaling decisions and a sensitive window (1 minute) for dashboards. Overlay both on the same Grafana panel so teams understand the lag in their signals.

Dead-letter queue size

Dead-letter queues (DLQs) capture messages that consumers failed to process after all retry attempts. DLQ size is a health indicator that many teams overlook until it balloons to millions of messages.

A growing DLQ indicates systematic failures: bad data, downstream service outages, or application bugs. A static DLQ of a few thousand messages is normal; most systems have a steady stream of malformed requests. But if DLQ size doubles in an hour, you have an incident.

Instrument DLQ depth exactly like the main queue, but with a lower alert threshold:

yaml
groups:
  - name: dlq_health
    rules:
      - alert: DLQGrowing
        expr: increase(dlq_messages_total[5m]) > 100
        for: 2m
        annotations:
          summary: "DLQ receiving {{ $value }} messages in 5 minutes"
      
      - alert: DLQDepthHigh
        expr: dlq_messages_current > 50000
        for: 1m
        annotations:
          summary: "DLQ depth at {{ $value }}, investigate failures"

More importantly, emit a metric that tags each DLQ message with its failure reason: dlq_messages_reason{reason="validation_error"}, dlq_messages_reason{reason="timeout"}, etc. This transforms a raw count into actionable intelligence. In a CRM system, if you see 10,000 messages fail with reason="invalid_phone_number", you know the upstream data pipeline has a bug, not that your consumer is broken.

The trade-off: richer DLQ instrumentation requires structured error tracking. You'll spend time building taxonomy of failure modes, but it pays dividends in MTTR (mean time to resolution). Skip this and you'll waste hours correlating DLQ spikes with logs.

Common mistakes

Mistake 1: Alerting only on queue depth. A queue at 1 million messages with a processing rate of 100,000/minute is recovering. Alert on the age of the oldest message or the ratio of processing to incoming rate instead. Depth is context-dependent; age and rates are absolute.

Mistake 2: Ignoring partition skew in Kafka. One hot partition can have 100x the lag of others. You'll see healthy group-level lag and miss that one partition is bottlenecked. Always visualize lag per partition, not just per group.

Mistake 3: Setting static thresholds. A lag of 10,000 is fine for a batch pipeline running once per hour; it's a disaster for a real-time service. Thresholds must reflect your SLA. Better yet, alert on change—lag increasing at rate X for Y minutes—rather than absolute values.

Mistake 4: Forgetting to monitor the DLQ monitoring. If your alerting pipeline itself fails, you won't know your DLQ is growing. Use a separate monitoring stack (or at least separate alert routing) for DLQ signals.

Mistake 5: Not correlating queue metrics with consumer logs. Queue depth might spike due to a deploy. Consumer lag might spike due to network latency, not a real bottleneck. Always link queue dashboards to a search interface over consumer application logs.

Key takeaways

  • Track queue depth and message age; alert primarily on age, which is actionable regardless of queue size.
  • For Kafka, measure consumer lag per group and per partition; lag per group hides imbalances.
  • Compare processing rate to incoming rate; if processing < incoming, you're accumulating backlog and need to scale.
  • Monitor your dead-letter queue with structured error tagging; DLQ growth is an early warning sign of systemic failures.
  • Use rate-based alerts (change over time) rather than static thresholds; context matters.
  • Correlate queue metrics with logs and consumer traces; a spike in queue depth is only useful if you can root cause it in seconds.
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