RabbitMQ head message age: the queue latency that depth alone cannot show

A queue with 10 messages whose oldest is 3 hours old is in worse shape than a queue with 10,000 messages whose oldest is 5 seconds old. Queue depth tells you volume. It says nothing about how long messages are actually waiting. Operators who alert on depth alone end up paged for healthy batch queues and blind to a real-time queue that has been stalled for an hour.

head_message_timestamp closes that gap. It is the timestamp of the message currently at the head of the queue, and the difference between now and that timestamp is your queue’s latency floor: the minimum time any message in that queue has been waiting. It is also the signal that catches the two failure modes depth structurally misses: poison-message stalls and slow-consumer drift on low-volume queues.

It has real limitations, and RabbitMQ maintainers have said so publicly. The sections below cover what it measures and the cases where it misleads you.

What it is and why it matters

head_message_timestamp is a per-queue info item exposed by the broker:

  • HTTP Management API: GET /api/queues, field head_message_timestamp (Unix epoch seconds)
  • CLI: rabbitmqctl list_queues name head_message_timestamp
  • Prometheus plugin: rabbitmq_queue_head_message_timestamp

The value is not computed by the broker. It is copied from the timestamp property of whichever message happens to be at the head of the queue right now. That distinction matters for everything that follows.

Head message age is computed by your monitoring, not by RabbitMQ:

# Head message age for every queue, in seconds
curl -s -u guest:guest http://localhost:15672/api/queues | \
  jq -r --argjson now "$(date +%s)" \
  '.[] | select(.head_message_timestamp != null) |
   "\(.name) age=\($now - .head_message_timestamp)s ready=\(.messages_ready)"'

Note that the default guest user can only authenticate from localhost; against a remote broker use a monitoring user with the monitoring tag.

The reason this signal earns a place next to depth is that it inverts cleanly per workload. A batch queue draining 10,000 messages with a 5-second-old head is healthy. A real-time queue holding 10 messages with a 3-hour-old head is an incident. Depth-based alerts cannot express that difference. Age-based alerts express it directly: “messages in queue X should never be older than Y seconds.”

A reasonable starting heuristic: age over 10 minutes for real-time queues, age over 1 hour for batch queues. Beyond that, set the threshold from the per-queue SLA.

How it works

The data path has three hops, and the signal can break at each one:

flowchart LR
  P[Publisher sets timestamp property] --> B[Broker stores message]
  B --> H[Message reaches queue head]
  H --> A[API exposes head_message_timestamp]
  A --> M[Monitoring computes now minus timestamp]
  T[TTL expiry or dead-lettering] -. resets head .-> H
  R[Consumer nack with requeue] -. original timestamp returns to head .-> H
  1. The publisher sets the timestamp. The value comes from the AMQP timestamp message property, set by the publishing client. If your publishers do not set it, the field is absent or null and the signal does not exist. Many client libraries and applications never set it.
  2. The message reaches the head. Only the head message’s timestamp is reported. You learn nothing about the second message until the first one leaves.
  3. The message must be paged in. Per the rabbitmqctl man page, timestamps only appear when the head message is in the paged-in state, meaning resident in RAM. Under memory pressure, when the broker is paging aggressively, the field can go absent exactly when things are getting bad. See paging messages to disk for that mechanism.

Because the value is publisher-supplied, you can fix an absent signal two ways. The right fix is publishers setting the timestamp property. The broker-side fix, for 3.12 and later, is the built-in incoming message interceptor in rabbitmq.conf:

# Stamp the arrival time on messages that lack a timestamp property
message_interceptors.incoming.set_header_timestamp.overwrite = false

With overwrite = false, publisher-supplied timestamps are preserved and only missing ones are filled in. This replaces the rabbitmq-message-timestamp plugin, which is deprecated as of 3.12.

Where it shows up in production

Three patterns are where head message age pays for itself.

Poison-message stalls. A message the consumer cannot process gets delivered, nacked, requeued, delivered again. Queue depth stays flat because nothing is completing and nothing new is stuck. The only signals that move are the redeliver rate and the head message age climbing minute after minute. The signature: stable depth, rising head age, elevated redeliver, unacked count pinned at a small number. Depth alerting sees nothing. Age alerting pages you.

Slow-consumer drift on low-volume queues. A queue that gets one message a minute will never trip a depth threshold, but if consumers degrade, the head age trends upward for hours before depth becomes interesting. Age is the leading indicator; depth is the lagging one.

Distinguishing backlog from latency during incidents. During a traffic spike, depth and age together tell you whether you have a capacity problem (deep queue, young head: consumers are keeping up with latency, just behind on volume) or a stall (any depth, old head: something is stuck). These have different fixes.

When the signal lies to you

This metric has documented reliability problems. RabbitMQ maintainers have described it as not working “all that well in practice.” Treat it as a strong signal with known blind spots, not ground truth.

Quorum queues. This is the biggest operational gotcha. Historically head_message_timestamp was not populated for quorum queues, and maintainers have cited the resource cost of tracking it in the Raft log path. Behaviour here is version-dependent, and since quorum queues are the recommended queue type on 3.13+, verify it on your version before building alerts on it. For quorum queues, the fallback is non-destructive inspection of the head message with rabbitmqadmin get queue=<queue-name> count=1, which shows message headers including any timestamp. That is a manual check, not a metric.

Absent publisher timestamps. Covered above: no timestamp property, no signal. Verify before you build alerts on it.

TTL and dead-lettering reset the head. When the head message expires via TTL and is dead-lettered or dropped, the next message becomes head and the age drops. A queue with a persistent consumer stall and a short TTL can show a young head forever while messages silently expire. Age going down is not always recovery; check whether messages are being consumed or being discarded.

Requeue preserves the original timestamp. A message nacked with requeue=true returns to the head carrying its original publish time. That is usually what you want, since it reflects true time-in-system, but it means head age measures “age of the message at the head” and not “time since the queue last made progress.” In a redelivery loop, the age keeps climbing, which is precisely the poison-message signature.

Paging can blank it out. When memory pressure pages the head message to disk, the timestamp can go absent during exactly the incidents where you want it. If your age series has gaps, correlate with messages_paged_out and the memory ratio before concluding the queue drained.

Empty queues have no head. No messages, no timestamp. Your alerting must treat “field absent” as its own state, distinct from “age is zero,” or you will either spam false alerts or silence real ones.

Signals to read alongside it

Head message age is only interpretable in combination:

SignalWhy it mattersWarning sign
Queue depth (ready + unacked)Depth + age together give the complete picture: volume and latencyDepth flat or rising while age climbs steadily
Redeliver rate per queueDistinguishes poison-message loop from slow consumerElevated redeliver + rising age + flat depth = poison message
Ack rateConfirms whether consumers are completing workDeliver rate positive, ack rate zero, age rising = stuck consumer
Consumer count and consumer_utilisationTells you whether consumers exist and are effectiveUtilisation under 0.5 with backlog and rising age
messages_paged_outExplains gaps or nulls in the age seriesPaged-out count rising while age field goes absent

The two shapes worth memorizing: rising age with rising depth is a capacity problem (add consumers or speed them up). Rising age with flat depth and elevated redeliver is a poison message (inspect the head message, dead-letter it, fix the consumer).

How Netdata helps

Netdata’s RabbitMQ collector pulls per-queue state from the Management API, so the correlation work above happens on one dashboard instead of three terminals:

  • Per-queue messages_ready and messages_unacknowledged charted over time, so you can see the flat-depth or rising-depth shape next to any age signal you compute.
  • Per-queue message rates including redeliver, which is the companion signal that separates a poison-message loop from a slow consumer.
  • Ack versus deliver rates per queue, exposing consumers that receive but never complete.
  • Consumer counts per queue, so “age rising” is immediately checkable against “did anyone’s consumers just disappear.”
  • messages_paged_out per queue, which explains why a head-age series went null during memory pressure.

Per-queue collection is optional in the Netdata collector (collect_queues_metrics); make sure it is enabled for the vhosts you care about, and be deliberate about it on deployments with thousands of queues because of cardinality. Whether a given collector version surfaces the head timestamp itself varies, so the practical pattern is: let Netdata chart depth, rates, and paging, compute age from the API or Prometheus endpoint, and alert on the combination. Depth plus age is the complete picture; either one alone is half of it.