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’sleaseTime) as the budget you plan against, and extend the lease if you need more. - No queue name. Per message there is no
queuefield; on the queue-scoped routes the top-levelqueuecovers it, but on the discovery route the top-levelqueueis empty, so a discovery consumer that needs to know which queue a message came from must map it frompartitionIditself. - No headers or metadata envelope. There is exactly one user-controlled field,
data. - No status or DLQ flag.
deliveryAttemptdescribes 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:
- Nothing is available for this group.
- 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. - 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 wherepausedreaches 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 whenwait=falseorbatchis 1, ends as soon as the batch fills, and never outlives the caller’stimeout. It is0(off) by default, is clamped to 60000, and takes effect on the queue-scoped wildcard route.
Examples
const messages = await client
.queue('orders')
.batch(10)
.wait(true)
.pop()messages = await client.queue("orders").batch(10).wait(True).pop()let messages = q.queue("orders").batch(10).wait(true).pop().await.unwrap();The SDKs’ higher-level consume loop pops, dispatches, acks and repeats:
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.