Skip to content

The pop routes

Claiming a leased batch: the three pop routes, every query parameter with its default, the response field by field, and what a popped message does not carry.

Updated View as Markdown

A pop claims a leased batch: a contiguous span of offsets just past the consumer group’s cursor in one or more partitions, held for a lease duration so no other worker in that group can take them. It never removes anything. All three routes are GET, take their parameters in the query string, have access level read-write, and return the same response shape.

Route Scope
GET /api/v1/pop/queue/:queue one queue; the broker picks which partitions to claim
GET /api/v1/pop/queue/:queue/partition/:partition exactly one partition
GET /api/v1/pop discovery across every queue matching namespace and/or task

Query parameters

Parameter Type Default Applies to
batch integer 200 all three
partitions integer 1 queue-scoped and discovery
autoAck boolean false all three
wait boolean false all three
timeout integer, ms 30000 all three
leaseSeconds integer the queue’s leaseTime, else 60 all three
consumerGroup string __QUEUE_MODE__ all three
subscriptionMode string new (DEFAULT_SUBSCRIPTION_MODE) all three
subscriptionFrom string empty all three
conflation boolean false all three
autopilot boolean false queue-scoped, with a consumerGroup
namespace string empty discovery only
task string empty discovery only

batch is the total message budget for the call, shared across every partition the pop touches, not a per-partition limit. The budget is consumed partition by partition until it runs out.

partitions caps how many partitions one call may claim. 0 or a negative value means no cap. It is ignored by the single-partition route, which by definition claims one.

timeout is the long-poll deadline in milliseconds. Its default comes from DEFAULT_TIMEOUT, falling back to POP_DEFAULT_TIMEOUT_MS, falling back to 30000; DEFAULT_TIMEOUT wins when both are set. It only has an effect when wait=true.

leaseSeconds overrides the queue’s configured lease for this call only. Values of 0 or less are treated as absent, so the queue value applies: 60 seconds for a queue created implicitly by a push, 300 seconds for one created or updated through /api/v1/configure.

consumerGroup omitted means queue mode, which is implemented as the reserved group name __QUEUE_MODE__. Queue mode is an ordinary group with a reserved name: it has its own cursor like any other, and two consumers that both omit consumerGroup share it.

subscriptionMode and subscriptionFrom seed a cursor that does not exist yet and are ignored for a (partition, group) that has already been contacted. subscriptionMode=all starts at the oldest retained message; new (the default) skips the existing backlog. subscriptionFrom takes now or an ISO-8601 timestamp; an unparseable timestamp is ignored. Queue mode is deliberately excluded from seeding, so a queue-mode pop never skips backlog even if it carries these parameters. When a group registered a durable subscription, that record wins over the pop-carried intent.

The discovery route requires at least one of namespace and task. With neither, it returns 400 with {"success":false,"error":"namespace or task is required","messages":[]} rather than scanning every queue.

Conflation

conflation=true asks for last-value delivery: a pop of a partition returns only that partition’s newest visible message and commits everything below it when you ack. It is for command-style queues where one partition is one logical key and only the freshest “recompute X” matters. Under a backlog the handler runs once per partition instead of once per message, and nothing on disk is touched: retention still governs storage.

The guarantee it keeps is the one that matters:

After the last push to a partition, at least one run of that partition’s handler starts after that push commits.

The broker never commits past an offset it did not observe at pop time, so a message that becomes visible after the claim is still pending afterwards and the next pop delivers it.

It is a property of the consumer group, not of the call. The first pop that registers the group on the queue persists the flag, and from then on the stored value wins for every consumer of that group. A later consumer that disagrees does not flip it: the stored policy is applied, and the response says so (see the two conflation keys). Changing a group’s policy means deleting and recreating the group. /api/v1/configure cannot set it: it is a group policy, which is exactly what lets workers conflate while audit on the same queue reads everything.

partitions defaults to 1, and a conflating pop yields at most one message per partition, so for a conflating group it is the partition cap, not batch, that sizes a call. The broker therefore reads partitions as batch when you leave it out, capped at 64, which is the checkout width the engine is measured on. A conflating pop returns at most 64 messages per round trip whatever batch says.

Two combinations are refused with 400 rather than warned about, because the silent version of each is unfixable in production:

Refused Why
conflation=true with no consumerGroup queue mode is a shared cursor with no group identity to hang a delivery policy on
conflation=true with autoAck=true auto-ack commits at delivery with no lease, so a failed handler loses the tail and the guarantee above degrades to at-most-once

Composition with subscriptionMode is deliberate in both directions: all plus conflation collapses the entire retained history to one message per partition (“rebuild the world once, then stay current”), new plus conflation skips the history in the seed and delivers only live changes.

Retries are unaffected. The retry budget lives on the (partition, group) row and is charged only by an explicit failed ack, so it survives supersession: a poison partition under a hot producer still dead-letters on schedule, and the message filed is the tail of the last attempt.

One side effect to know about: a queue-scoped pop on a queue that does not exist creates its queen.queues configuration row (column defaults, namespace and task derived from the dotted name) before answering 204. That is deliberate, because a group may subscribe before the first push and its durable subscription record hangs off the queue’s id. It does mean a typo in a consumer’s queue name now leaves an empty queue behind rather than nothing.

Response

200 OK when at least one message is delivered:

{
  "success": true,
  "queue": "orders.created",
  "partition": "customer-42",
  "partitionId": "b1c2d3e4-5f60-7180-9a0b-1c2d3e4f5061",
  "leaseId": "0198f2c1-9a7b-7d20-8e31-4c5d6e7f8a90",
  "consumerGroup": "billing",
  "messages": [
    {
      "id": "0198f2c1-4d3a-7c10-9f2b-6a1e5d0c7b83",
      "transactionId": "order-8891-created",
      "traceId": null,
      "data": { "orderId": 8891, "total": 4200 },
      "producerSub": null,
      "createdAt": "2026-07-30T11:02:44.512873Z",
      "offset": 8192,
      "partitionId": "b1c2d3e4-5f60-7180-9a0b-1c2d3e4f5061",
      "partition": "customer-42",
      "leaseId": "0198f2c1-9a7b-7d20-8e31-4c5d6e7f8a90",
      "consumerGroup": "billing"
    }
  ],
  "partitionsClaimed": 1
}

Top-level fields

Field Type Meaning
success boolean always true on a 200
queue string the queue from the path; empty string on the discovery route, which spans queues
partition string the name of the first claimed partition
partitionId string the id of the first claimed partition
leaseId string the lease covering every message in this response; empty string when autoAck=true
consumerGroup string the group the pop used, __QUEUE_MODE__ when you sent none
messages array the delivered messages, in offset order within each partition
partitionsClaimed integer how many partitions this call claimed
conflation boolean present, and always true, only when this pop was served under conflation
conflationConflict boolean present, and always true, only when this request declared a conflation policy the group does not have
autopilot object present only when this request sent autopilot=true and the broker resolved at least one knob for it: partitions and batch are the values the claim actually used, and waitMs is the pacing the broker advises for an empty-poll loop

Autopilot

autopilot=true asks the broker to choose the knobs you did not send. partitions is the sweep width of one claim, and its right value is a fact about the broker (how many partitions of this queue currently hold work for your group, and how long they have been waiting) rather than about your consumer. It also moves: a queue’s partition count grows for years, so a number that was right when it was typed is wrong later. On a measured cloud cell the same workload ran at a 2376 ms ready-age tail with partitions=5000 and at 5 ms with partitions=10.

The choice is per parameter. A knob you send is yours and is never overridden: autopilot=true&partitions=1 is a manual width of 1 with an automatic batch. Send neither and both are chosen. Send both and nothing is, which is exactly the request you would have sent without the flag.

Omitting autopilot entirely keeps today’s behaviour byte for byte, response included, which is what every existing consumer does. The broker applies the choice on the queue-scoped route for a pop that names a consumerGroup; the pinned-partition and discovery routes accept the parameter and ignore it, since a pinned pop is one partition by definition and has no width to choose. Two more positions accept it and do nothing with it, for the same kind of reason. A conflating pop keeps its own meaning for partitions, the message budget rather than a sweep width, so the feature that already owns that dimension keeps it. And the controller reads the hot-list ring for every input it has, so a deployment running the legacy candidate scan with QUEEN_HOTLIST=0 has nothing for it to read: there too the request resolves to the defaults below.

Operators can disable the whole thing with QUEEN_POP_AUTOPILOT=off, or watch what it would choose without letting it act with QUEEN_POP_AUTOPILOT=shadow; in both positions a request that sent the flag simply gets today’s defaults and no autopilot object in the response. The six constants that tune the law, and what the broker logs about its own decisions, are in the configuration reference.

Conflation in the response

Both keys are emitted only when true, so a deployment that never asks for conflation gets byte-identical responses to a pre-1.1.0 broker’s.

conflation reports what the broker applied, never what the request asked for, and it rides every conflating response including empty ones. That is load-bearing: no SDK negotiates a version with the broker, so a client that sends conflation=true to an older broker has the parameter silently ignored and quietly drains the whole backlog one message at a time. The echo is the only evidence there is, and because it is present on empty pops the client can raise on its first round trip, before a single message is handled. Every SDK does exactly that: requested conflation, no echo, no explanation, and the consume loop stops with *“conflation was requested but this broker did not apply it, requires broker

= 1.1.0“*.

conflationConflict is the explanation that keeps that check from firing on a disagreement. A group registered without conflation answers a conflation=true consumer with conflationConflict and no conflation echo: the request was understood, the stored policy won, the consumer keeps running and warns once. A reject would take down the already-correct half of a rolling deploy, which is why this is a warning and not an error.

Because those keys have to reach the client, a response that has anything to say about conflation is a 200 with a body even when it delivered nothing: an empty conflating pop, and an empty conflicting pop, are both 200. That is the steady state of a long-poll consumer on an idle queue, not an edge case.

The top-level partition and partitionId describe only the first claimed partition. When a call claims several, they are not representative of the batch. Always ack using each message’s own partitionId.

Per-message fields

Field Type Meaning
id string (UUIDv7) the message id assigned at push
transactionId string the deduplication key, and the address you ack with
traceId string or null null unless the message was written by a transaction that supplied one
data any JSON value the payload, spliced back verbatim; decrypted transparently if it was encrypted
producerSub string or null the sub of the JWT that pushed it, when authentication was on
createdAt string the timestamp of the segment that holds this message
offset integer the message’s absolute zero-based position in its partition
partitionId string this message’s partition id, the one to ack with
partition string this message’s partition name
leaseId string repeated on every message for convenience; empty on autoAck
consumerGroup string repeated on every message
deliveryAttempt integer 1-based delivery count for this consumer group’s current partition batch; increments when the same batch is redelivered after a nack or lease expiry, and is always 1 with autoAck

deliveryAttempt belongs to the (partition, consumerGroup) claim, not to the stored message. Every message from the same claimed partition in one response therefore carries the same value; another partition in that response may carry a different one. Once the group advances to fresh work, the count resets to 1.

What a popped message does not carry

These absences are deliberate and each one has a consequence:

  • No lease expiry timestamp. You know the lease id, not when it dies. Use leaseSeconds (or the queue’s leaseTime) as the budget you plan against, and extend the lease if you need more.
  • No queue name. Per message there is no queue field; on the queue-scoped routes the top-level queue covers it, but on the discovery route the top-level queue is empty, so a discovery consumer that needs to know which queue a message came from must map it from partitionId itself.
  • No headers or metadata envelope. There is exactly one user-controlled field, data.
  • No status or DLQ flag. deliveryAttempt describes redelivery of the current group claim; the remaining message state is a cursor per (partition, group), not mutable state stored on the message.

createdAt is the segment’s creation timestamp, not the message’s. A segment holds many frames, and the broker coalesces concurrent pushes to the same partition into one segment, so messages written by the same call (and messages from different calls that were coalesced) all report an identical createdAt. It orders segments, not messages inside a segment; ordering inside a partition is the offset order in which messages is returned. PostgreSQL converts it to UTC explicitly before formatting the literal Z suffix, so the wire value is independent of the database session timezone.

offset is informational on the HTTP surface: acknowledgements are still addressed by transactionId, not by an “ack through offset” parameter. It is computed as the segment’s base sequence plus the frame index, so partial and multi-segment claims keep the absolute partition position rather than restarting at zero.

An empty pop is a 204 with no body

When nothing is delivered, the response is 204 No Content with no body at all. There is nothing to parse; check the status first. Three different situations all produce that same bare 204:

  1. Nothing is available for this group.
  2. Pop maintenance is on. The handler builds {"messages":[],"paused":true}, and the flag is dropped along with the body. Consumers cannot distinguish a paused broker from an idle queue.
  3. The pop’s stored procedure reported an error. The error text is discarded with the body.

The one exception is a request that mentioned conflation. Dropping the body there would drop the echo the client uses to tell a 1.1.0 broker from an older one, and it would read the silence as a version mismatch and stop consuming, on an idle queue and on an operator’s routine pop-maintenance pause. So a pop whose answer has anything to say about conflation keeps its 200 and its body:

  • an empty conflating pop: {"messages":[],"conflation":true, ...}
  • an empty conflicting pop: {"messages":[],"conflationConflict":true, ...}
  • pop maintenance, for a request that sent conflation=true: {"messages":[],"paused":true,"conflation":true}, the only response where paused reaches the client. It means “the pop never ran”, not “the policy was refused”

Transport-level failures do come back with a body: 500 with {"error":"pool"} when no database connection is available, or {"error":"pop failed"} when the statement timed out and was cancelled.

Code When
200 at least one message delivered, or the answer carries a conflation key
204 nothing delivered, pop maintenance, or a stored-procedure error (no body)
400 discovery pop with neither namespace nor task; conflation=true without consumerGroup or with autoAck=true; a query parameter that fails to deserialize
403 authentication is on and the token has no read-write role
500 no pool connection, or a cancelled statement

Leases and concurrency

A non-autoAck pop takes a lease on the span (committed, batch_end] for the (partition, group) pair, and the lease id is the id the pop minted for itself. Exactly one live leased batch exists per (partition, group). That is the ordering guarantee, and it is also the concurrency limit: a queue with one partition cannot be consumed in parallel by one group, no matter how many workers poll it. Parallelism comes from having more partitions.

Other workers polling a leased partition are not blocked. They skip it and look elsewhere, which is why a wildcard pop with partitions=1 and many hot partitions still spreads out across workers.

When the lease expires without an ack, the batch becomes claimable again and redelivers. Lease expiry never consumes retry budget; only an explicit failed ack does. That is at-least-once delivery: design handlers to tolerate a repeat.

autoAck=true is different in kind. The cursor is committed inside the pop’s own transaction, before the response is written, and the response carries an empty leaseId. If the client dies after the commit and before it processes the batch, those messages are gone: at-most-once. Use it for throughput on data you can afford to lose, never for work you must not drop.

Long polling

With wait=false (the default) a pop is a single attempt: it returns whatever is claimable now, otherwise 204.

With wait=true the call parks until either messages arrive or timeout elapses. A write that lands messages wakes parked pops: on the queue-scoped routes, one to that queue; on the discovery route, any within the same scope. A push is the usual one, and a timer firing, a stream cycle’s sink emit and the disk spool replaying after an outage wake them on the same path. Between wakes the pop re-checks on an interval that backs off (POP_WAIT_INITIAL_INTERVAL_MS 100 ms, growing after POP_WAIT_BACKOFF_THRESHOLD 3 consecutive empty waits by POP_WAIT_BACKOFF_MULTIPLIER 2.0, capped at POP_WAIT_MAX_INTERVAL_MS 1000 ms), and a wake resets the backoff. A parked pop holds no PostgreSQL connection (the connection is released before parking), so thousands of idle long-poll consumers do not consume pool capacity.

Each re-check is gated: instead of running the full pop procedure, a parked pop first asks a cheap indexed pending probe (one watermark comparison, no admission permit) and keeps parking on a definitive “nothing to deliver”. This holds on all three routes: the wildcard scan, the single-partition route, and discovery. The probe deliberately answers “maybe” on a consumer group’s first contact, so subscription registration and subscriptionMode=new seeding keep their exact timing. An idle parked consumer therefore costs point lookups, not full pop calls; QUEEN_POP_PENDING_GATE=false disables the gate on the single-partition and discovery routes.

A long poll that times out returns 204, not an error.

Visibility delays

Two queue options can make a message that is committed still not deliverable:

  • delayedProcessing: only segments at least that many seconds old are delivered.
  • windowBuffer: if the partition received anything within that many seconds, the pop delivers nothing from that partition, so writes can accumulate into fatter batches.

Both are enforced in SQL, so they apply to every pop route. If a pop returns 204 on a queue you just wrote to, check these before anything else.

A third option trades latency for commit efficiency rather than hiding messages:

  • minPopWaitTime: milliseconds a non-empty but under-full batch may be held back so that one database commit carries more messages. It never delays an empty queue (that is the long-poll’s case), never applies when wait=false or batch is 1, ends as soon as the batch fills, and never outlives the caller’s timeout. It is 0 (off) by default, is clamped to 60000, and takes effect on the queue-scoped wildcard route.

Examples

clients/client-js/test-v2/docs.jsjs
const messages = await client
  .queue('orders')
  .batch(10)
  .wait(true)
  .pop()
clients/client-py/tests/test_docs.pypython
messages = await client.queue("orders").batch(10).wait(True).pop()
clients/client-rust/tests/docs.rsrust
let messages = q.queue("orders").batch(10).wait(true).pop().await.unwrap();

The SDKs’ higher-level consume loop pops, dispatches, acks and repeats:

clients/client-js/test-v2/docs.jsjs
await client
  .queue('orders')
  .group('billing')
  .subscriptionMode('all')
  .limit(1)
  .each()
  .consume(async (message) => {
    console.log(message.data)
  })

Raw HTTP, one partition, leased for 120 seconds, long-polling for up to 10 seconds:

curl -sS -i 'http://localhost:6632/api/v1/pop/queue/orders.created?consumerGroup=billing&batch=50&partitions=4&wait=true&timeout=10000&leaseSeconds=120'

Then acknowledge what you processed with POST /api/v1/ack.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close