Skip to content

Push messages: answers, buffering, binary forms

Push to a partition per entity, read the answer for each message (queued, duplicate, error), buffer many small messages, and turn on binary push, compression and framed pop.

Updated View as Markdown

In Kafka you choose a number of partitions and hash keys onto them, so a slow customer holds up every customer that shares its partition. Here a partition is one entity: a customer, an order, a conversation. The push that names it creates it, order holds inside it, and one queue has held ten million.

The transactionId makes a retry safe: the same id within the dedup window (an hour by default) is not written twice.

await queen.queue('orders')
  .partition('customer-42')       // one ordered partition per customer, created by this push
  .push([{
    transactionId: 'order-1001',  // the dedup key: pushing the same id again is a no-op
    data: { orderId: 1001, customer: 42, total: 59.9 },
  }])
Why
order matters per entity (an order’s events must be handled in sequence), not across the whole queue. One partition per entity keeps that order without one slow entity holding up the others. A push with no partition goes to a partition called Default.

Every message gets its own answer

One push can carry many messages, and the broker answers each one: queued (stored, with its offset), duplicate (that transactionId is already there, nothing was written) or error (refused, nothing written). The JavaScript client throws when any message is refused, unless you handle it yourself, and never throws for a duplicate. The other clients never throw for a refused message: they return each message’s status, and their code below reads it.

One push carries three messages. The broker answers each one: the first is queued at offset 7; the second has a transactionId already stored in the dedup window, so it is a duplicate and nothing is written; the third is refused with an error and nothing is written.One push carries three messages. The broker answers each one: the first is queued at offset 7; the second has a transactionId already stored in the dedup window, so it is a duplicate and nothing is written; the third is refused with an error and nothing is written.
One push, one answer per message.
await queen.queue('emails')
  .partition('customer-42')
  .push([{ transactionId: 'welcome-42', data: { to: 'ada@example.com' } }])
  .onSuccess((items) => console.log('stored', items.length))
  .onDuplicate((items) => console.log('already stored', items[0].transactionId))
  .onError((items, error) => console.error('refused', error.message))
Why
a duplicate is usually good news: it means an earlier attempt already got through. A refusal is not. Telling them apart per message lets one bad item in a batch of a hundred be retried alone, and lets a producer that retried after a timeout know that its first attempt worked.

Buffering, for many small messages

Each push is one HTTP request. A producer that pushes thousands of small messages a second can let the client collect them and send them in batches instead.

for (let i = 0; i < 1000; i++) {
  await queen.queue('events')
    .partition(`device-${i % 10}`)
    .buffer({ messageCount: 100, timeMillis: 1000 })  // 100 at a time, or every second
    .push([{ transactionId: `reading-${i}`, data: { device: i % 10, value: i } }])
}
await queen.flushAllBuffers()                         // before exiting, send what is left

A buffer is sent when it holds messageCount messages or when timeMillis has passed, whichever comes first. If the producer gets far ahead of the network (four times messageCount waiting), a buffered push waits instead of letting memory grow.

A producer calls push a thousand times, one small message each. The client collects them in a buffer per queue and partition, and sends a batch as one HTTP request when it holds 100 messages or when a second has passed, whichever comes first. flushAllBuffers sends what is left before the process exits.A producer calls push a thousand times, one small message each. The client collects them in a buffer per queue and partition, and sends a batch as one HTTP request when it holds 100 messages or when a second has passed, whichever comes first. flushAllBuffers sends what is left before the process exits.
Client buffering: many small pushes, a few requests.
Why
one request per message spends most of its time on the round trip, not on the message. Batching gets far more messages through the same connection. The price: a message is stored only when its batch is sent, so a process that crashes loses what was still in its buffer. Call flushAllBuffers() before you exit, and do not buffer messages you cannot afford to lose.

When several messages must land together

A push of several messages is not all-or-nothing: each message gets its own answer, so some can be stored while others are refused. When messages must be stored together or not at all (an order and its payment request, say), push them in a transaction (see Advanced transactional usage).

Binary push, compression and framed pop

By default the client and the broker speak JSON. Three options of the client change how messages travel, and nothing else: the calls, their results, deduplication and the guarantees stay the same.

Option Default What it does
binaryPush false A push, direct or buffered, goes as one binary body instead of a JSON document. A transaction that pushes sends its pushes the same way.
pushCompression false Needs binaryPush. The client packs each partition’s batch the way the broker stores it, and compresses it with zstd once. The broker keeps those bytes as they are.
framedPop false pop() and consume() get the records as they are stored, and the client unpacks them into the same messages.

Turn them on when you create the client:

const fast = new Queen({
  url: 'http://localhost:6632',
  binaryPush: true,       // a push travels as one binary body, not JSON
  pushCompression: true,  // with binaryPush: each partition's batch is zstd-compressed here
  framedPop: true,        // a pop gets the records as stored, and unpacks them here
})

await fast.queue('invoices').partition('customer-42')
  .push([{ transactionId: 'invoice-2001', data: { orderId: 2001, total: 12.5 } }])

await fast.queue('invoices').each().limit(1).consume(async (msg) => {
  console.log(msg.data)           // { orderId: 2001, total: 12.5 }, as with JSON
})

Compression needs zstd in the client:

Language zstd
JavaScript Comes with Node.js.
Python Comes with Python 3.14; before it, pip install "queen-mq[zstd]".
Go Comes with the module.
Rust Comes with the crate.
PHP The zstd extension: pecl install zstd.
C++ Compile with -DQUEEN_CLIENT_ZSTD and link -lzstd.

Some requests stay JSON by themselves: a push with a traceId, a pop of a conflating group, and a pop of an encrypted queue. Acks, KV, timers and locks do not change.

Why
at a high message rate, the broker spends CPU turning JSON into stored records and back, and the network carries every message uncompressed. These options move that work into the clients, where many processes share it, and send the batches compressed. When neither the broker’s CPU nor the network is the limit, leave them off: JSON is easier to read when you debug.

One trap: binaryPush and pushCompression need broker 2.3.0 or later on every node. An older broker answers 400, and the client does not fall back to JSON, so turn them on after the last node is upgraded. framedPop is safe with any broker: an older one ignores it and answers JSON.

See also

  • Queues: the dedup window and the other options a push meets.
  • Transactions: pushes that commit with an ack and state.
  • Limits: message sizes and how many messages one push takes.
Navigation

Type to search…

↑↓ navigate↵ selectEsc close