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.
When to ack
Section titled “When to ack”Three positions:
| Strategy | When ack fires | At-least-once vs exactly-once |
|---|---|---|
| Ack first | Before processing | Loses messages on consumer crash mid-handler. At-most-once. |
| Ack last | After processing | Retransmits on crash mid-handler. At-least-once. |
| Persist then ack | After persistence | Same 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(); }}What happens between processing and ack
Section titled “What happens between processing and ack”1. Handler starts processing (msg.payload)2. Handler completes3. Handler calls msg.ack()4. ConsumerController sends ack envelope to producer5. Producer removes msg from unacked bufferIf 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.
Persist then ack
Section titled “Persist then ack”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:
- Receive envelope.
- Skip if already-processed.
- Persist event + new high-watermark to journal.
- 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.
Crash scenarios
Section titled “Crash scenarios”Producer crash before send
Section titled “Producer crash before send”Producer sends msg #5 → crash before transmit completesRecovery: producer restarts. Without persistence, msg #5 is lost. With persistence, the message is in the producer’s unacked-buffer journal; transmits on recovery.
Consumer crash before processing
Section titled “Consumer crash before processing”Consumer receives msg → crashes before handler runsNo ack sent; producer retransmits. Idempotent handler dedupes (or processes for the first time).
Network drop between transmission and ack
Section titled “Network drop between transmission and ack”Producer sends msg #5Consumer processes msg #5Consumer sends ack #5Network drops the ack envelopeProducer 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.
Slow consumer
Section titled “Slow consumer”Producer.maxOutstanding = 100Consumer takes 10s per messageBackpressure: 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.
Cluster failover
Section titled “Cluster failover”Consumer on node-A processes msg #5 + sends ackNode-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.
Ack as a guarantee (or not)
Section titled “Ack as a guarantee (or not)”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.
When NOT to use the controllers
Section titled “When NOT to use the controllers”Where to next
Section titled “Where to next”- Delivery overview — the bigger picture.
- ProducerController — the sender side.
- ConsumerController — the receiver side.
- PersistentActor — for end-to-end durable processing.