Skip to content

Ack semantics

The ack is the core primitive of reliable delivery — producer releases buffered messages once acked; consumer “officially processed” the message before acking. Timing of the ack matters.

Three positions:

StrategyWhen ack firesAt-least-once vs exactly-once
Ack firstBefore processingLoses messages on consumer crash mid-handler. At-most-once.
Ack lastAfter processingRetransmits on crash mid-handler. At-least-once.
Persist then ackAfter persistenceSame as ack-last + state survives restart. Effectively-once.

Ack last is the framework default + recommended. Combined with idempotent processing or persisted high-watermark, gives strong delivery guarantees.

class Consumer extends Actor<DeliveryEnvelope<T>> {
override async onReceive(msg: DeliveryEnvelope<T>): Promise<void> {
// Ack last:
await this.processIdempotently(msg.payload);
msg.ack();
}
}
1. Handler starts processing (msg.payload)
2. Handler completes
3. Handler calls msg.ack()
4. ConsumerController sends ack envelope to producer
5. Producer removes msg from unacked buffer

If the consumer crashes between 3 and 4, the ack arrives at the producer. Fine — no problem.

If the consumer crashes between 2 and 3, the ack never arrives. Producer retransmits after timeout.

The window from “processing done” to “ack sent” is the narrow zone where duplicates happen. Idempotent handlers (or persisted high-watermark) cover it.

For effectively-once processing of side effects:

class PersistentConsumer extends PersistentActor<...> {
private highWatermark = 0;
override async onCommand(state, cmd: { kind: 'process'; env: DeliveryEnvelope<T> }): Promise<void> {
const msg = cmd.env;
if (msg.seq <= this.highWatermark) {
msg.ack(); // dedupe
return;
}
// Process + persist atomically:
await this.persistAsync(
{ kind: 'processed', seq: msg.seq, payload: msg.payload },
() => {
this.highWatermark = msg.seq;
msg.ack();
},
);
}
}

The sequence:

  1. Receive envelope.
  2. Skip if already-processed.
  3. Persist event + new high-watermark to journal.
  4. After persistence: ack.

On crash:

  • Before persistence: producer retransmits; new consumer-actor instance has the old high-watermark; processes again. Net: zero duplication.
  • After persistence, before ack: producer retransmits; new instance has the new high-watermark; dedup skips + acks the duplicate. Net: zero duplication.

Combined with persistent producer state (unacked-message buffer in the producer’s journal), this is end-to-end durable + idempotent.

Producer sends msg #5 → crash before transmit completes

Recovery: producer restarts. Without persistence, msg #5 is lost. With persistence, the message is in the producer’s unacked-buffer journal; transmits on recovery.

Consumer receives msg → crashes before handler runs

No ack sent; producer retransmits. Idempotent handler dedupes (or processes for the first time).

Producer sends msg #5
Consumer processes msg #5
Consumer sends ack #5
Network drops the ack envelope

Producer retransmits msg #5 after timeout. Consumer dedupes (highWatermark already at 5); re-acks.

The dedup + re-ack pattern means transient acks are recoverable — eventually one arrives.

Producer.maxOutstanding = 100
Consumer takes 10s per message

Backpressure: after 100 unacked messages, the producer’s send queue stops accepting new sends. Senders see backpressure (either tells stash, or asks return rejected).

This is good — the system can’t get further ahead of itself than the buffer size.

Consumer on node-A processes msg #5 + sends ack
Node-A crashes before the ack envelope reaches node-B (producer)

Producer doesn’t see ack within retransmit window → retransmits.

If the consumer’s delegate is sharded (moved during failover), the new instance:

  • Receives the retransmit.
  • Looks up its high-watermark (from snapshot or replay).
  • Dedupes if already-processed.

For sharded consumers, always pair with persistence — otherwise dedup state resets on rebalance.

msg.ack();

The framework’s ack() is fire-and-forget — sends the ack envelope, doesn’t wait for confirmation. If the network drops it, the framework’s retransmit logic catches it.

This means: calling ack() doesn’t block your handler. No extra latency.