Skip to content

AxonOps — AI-Native Control Plane for Open Source Data Platforms

At-Least-Once Delivery

At-least-once delivery guarantees that messages are never lost but may be delivered multiple times. This semantic is the default for most Kafka deployments and requires consumers to handle potential duplicates.


At-least-once guarantee and the scenarios that cause duplicatesAt-least-once guarantee and the scenarios that cause duplicatesAt-Least-Once GuaranteeMessages Sent: 100Messages Delivered: ≥ 100Unique Messages: 100Scenarios causing duplicates:- Producer retry after timeout (when idempotence is disabled)- Consumer crash before commit- Rebalance during processingall delivered(some maybe twice)duplicatespossible
PropertyGuarantee
Delivery count≥ 1
Message lossNever when durability settings are met
DuplicatesPossible
OrderingPreserved within partition (with constraints)

Durability requirements

To avoid loss, use acks=all, keep min.insync.replicas satisfied, and disable unclean leader election.


At-least-once producers wait for acknowledgment and retry on failure.

Producer retry after an acknowledgment timeout writing a duplicate recordProducerKafka BrokerProducerProducerKafka BrokerKafka Brokersend(record, seq=0)acksend(record, seq=1)Network timeoutretry send(seq=1)ack (may be duplicate)send(record, seq=2)ackRetry ensures deliverybut may cause duplicates
# At-least-once producer configuration
acks=all # Wait for all in-sync replicas
retries=2147483647 # Retry indefinitely
retry.backoff.ms=100 # Initial retry delay
delivery.timeout.ms=120000 # Total delivery timeout
max.in.flight.requests.per.connection=5 # Default parallelism
request.timeout.ms=30000 # Per-request timeout
<div class="admonition note">
<p class="admonition-title">Idempotence default</p>
`enable.idempotence=true` is the default in current Kafka releases, which prevents duplicates caused by producer retries.
</div>
ConfigurationValueRationale
acks=allallWait for all ISR replicas; strongest durability
retriesMAX_INTRetry until delivery.timeout.ms expires
delivery.timeout.ms120000Total time budget for delivery
max.in.flight.requests.per.connection5Balance parallelism and ordering

With max.in.flight.requests.per.connection > 1, retries may cause reordering:

Reordering caused by a retry with multiple in-flight requestsReordering caused by a retry with multiple in-flight requestsRequest 1Send ATimeoutRetry ARequest 2Send BSuccessDoneBroker Order B writtenB, AMessage B written before Adue to retry ordering012

To preserve strict ordering:

# Strict ordering (lower throughput)
max.in.flight.requests.per.connection=1
# Or use idempotent producer (recommended)
enable.idempotence=true
# Idempotence allows max.in.flight=5 with ordering preserved
Properties props = new Properties();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "kafka:9092");
props.put(ProducerConfig.ACKS_CONFIG, "all");
props.put(ProducerConfig.RETRIES_CONFIG, Integer.MAX_VALUE);
props.put(ProducerConfig.RETRY_BACKOFF_MS_CONFIG, 100);
props.put(ProducerConfig.DELIVERY_TIMEOUT_MS_CONFIG, 120000);
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
Producer<String, String> producer = new KafkaProducer<>(props);
// Synchronous send (blocks until ack or timeout)
try {
RecordMetadata metadata = producer.send(
new ProducerRecord<>("orders", order.getId(), order.toJson())
).get();
log.info("Delivered to partition {} offset {}",
metadata.partition(), metadata.offset());
} catch (ExecutionException e) {
log.error("Failed to deliver after retries", e);
// Handle permanent failure
}
// Asynchronous send with callback
producer.send(
new ProducerRecord<>("orders", order.getId(), order.toJson()),
(metadata, exception) -> {
if (exception != null) {
log.error("Delivery failed", exception);
// Retry application-level or alert
} else {
log.debug("Delivered to {} @ {}",
metadata.partition(), metadata.offset());
}
}
);

At-least-once consumers process messages before committing offsets.

Consumer processing records before committing the offsetConsumerKafkaProcessingConsumerConsumerKafkaKafkaProcessingProcessingpoll()records [0-99]loop[for each record]process(record)successcommitSync(offset=100)commit OKIf crash before commit,records 0-99 redelivered(duplicates possible)
# At-least-once consumer configuration
enable.auto.commit=false # Manual commit control
auto.offset.reset=earliest # Process all messages on new consumer
max.poll.records=500 # Reasonable batch size
max.poll.interval.ms=300000 # Processing time budget
session.timeout.ms=45000 # Heartbeat timeout
Properties props = new Properties();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "kafka:9092");
props.put(ConsumerConfig.GROUP_ID_CONFIG, "order-processor");
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 500);
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Collections.singletonList("orders"));
while (running) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
// Process first (at-least-once)
processOrder(record.value());
}
// Commit after successful processing
if (!records.isEmpty()) {
consumer.commitSync();
}
}
StrategyImplementationTrade-off
Commit per batchcommitSync() after poll loopBalance safety/performance
Commit per recordcommitSync(offsets) per recordSafest, lowest throughput
Async commitcommitAsync()Faster, may lose commits
Periodic commitCommit every N recordsBalance
// Commit per record (safest, slowest)
for (ConsumerRecord<String, String> record : records) {
processOrder(record.value());
Map<TopicPartition, OffsetAndMetadata> offset = Map.of(
new TopicPartition(record.topic(), record.partition()),
new OffsetAndMetadata(record.offset() + 1)
);
consumer.commitSync(offset);
}
// Async commit with retry (faster)
consumer.commitAsync((offsets, exception) -> {
if (exception != null) {
log.warn("Commit failed, will retry on next poll", exception);
}
});

Producer retry, consumer redelivery, and rebalance as sources of duplicatesProducer retry, consumer redelivery, and rebalance as sources of duplicatesDuplicate SourcesProducer RetryConsumer RedeliveryRebalancesend(M)timeout (ack lost)retry send(M)M written twicepoll() → Mprocess(M)crash before commitrestart → poll() → Mprocessing Mrebalance triggeredpartition reassignednew consumer gets M

Operations that produce the same result regardless of execution count.

// Naturally idempotent: SET operation
database.execute("UPDATE users SET email = ? WHERE id = ?",
record.email(), record.userId());
// Running twice sets the same value
// NOT idempotent: INCREMENT operation
database.execute("UPDATE accounts SET balance = balance + ? WHERE id = ?",
record.amount(), record.accountId());
// Running twice doubles the amount!

Track processed message IDs to detect and skip duplicates.

Deduplication table lookup before processing and on a repeated messageConsumerDedup TableBusiness DBConsumerConsumerDedup TableDedup TableBusiness DBBusiness DBcheck(message_id)not seenprocess(message)successmark_seen(message_id)Duplicate Arrivescheck(message_id)already seenskip
// Deduplication with database
public void processWithDedup(ConsumerRecord<String, String> record) {
String messageId = extractMessageId(record);
// Check if already processed
if (deduplicationService.isProcessed(messageId)) {
log.debug("Skipping duplicate: {}", messageId);
return;
}
// Process within transaction
transactionTemplate.execute(status -> {
processBusinessLogic(record);
deduplicationService.markProcessed(messageId);
return null;
});
}

Include unique keys in messages; downstream systems deduplicate.

// Producer: include idempotency key
ProducerRecord<String, String> record = new ProducerRecord<>(
"payments",
payment.getId(),
payment.toJson()
);
record.headers().add("idempotency-key",
UUID.randomUUID().toString().getBytes());
// Consumer: use key for external API
String idempotencyKey = new String(
record.headers().lastHeader("idempotency-key").value()
);
paymentGateway.charge(payment, idempotencyKey);
// Gateway deduplicates based on idempotency key

Use database upsert operations where duplicates overwrite with same data.

-- PostgreSQL upsert
INSERT INTO events (event_id, event_type, payload, created_at)
VALUES (?, ?, ?, ?)
ON CONFLICT (event_id) DO UPDATE SET
event_type = EXCLUDED.event_type,
payload = EXCLUDED.payload;
-- Duplicate insert has no effect (same data)
// Cassandra natural idempotence
session.execute(
"INSERT INTO events (event_id, event_type, payload) VALUES (?, ?, ?)",
record.eventId(), record.eventType(), record.payload()
);
// Cassandra INSERT is an upsert; duplicates overwrite with same values
Deduplication window retaining recent message IDsDeduplication window retaining recent message IDsDeduplication Window DesignMessage IDsWindow: 24hID: abc (2h ago)ID: def (1h ago)ID: ghi (5m ago)After window expires, IDis removed from dedup store.Late duplicate may slip through.within window(deduplicated)within windowwithin window
Window SizeTrade-off
Short (1h)Less storage, may miss late duplicates
Medium (24h)Balanced for most use cases
Long (7d)High storage, catches very late duplicates
ForeverMaximum safety, unbounded storage

Kafka's idempotent producer prevents duplicates caused by producer retries.

Idempotent producer sequence numbers suppressing a retried recordProducerBrokerProducer(PID: 1000)Producer(PID: 1000)BrokerBrokersend(partition=0, seq=0)store recordacksend(partition=0, seq=1)Ack lost in networkretry send(partition=0, seq=1)seq=1 already existsack (no duplicate written)Broker tracks (PID, partition, seq)Rejects duplicate sequence numbers
# Enable idempotent producer
enable.idempotence=true
# Implied settings (automatically set):
# acks=all
# retries=Integer.MAX_VALUE
# max.in.flight.requests.per.connection ≤ 5
ScopeCoveredNot Covered
Single producer session-
Producer restartNew PID assigned
Multiple producersDifferent PIDs
Cross-partitionPer-partition sequence

Idempotent Producer Limitations

Idempotent producers prevent duplicates within a single producer instance session. For cross-session or cross-producer deduplication, use transactions or application-level deduplication.


Handling retriable and non-retriable producer errorsHandling retriable and non-retriable producer errorsSend recordError?yesnoRetriable error?yesnoNetworkExceptionNotEnoughReplicasExceptionTimeoutExceptionRetry with backoffSerializationExceptionRecordTooLargeExceptionInvalidTopicExceptionHandle fatal errorSuccess
Error TypeExamplesAction
RetriableNetworkException, TimeoutExceptionAutomatic retry
Non-retriableSerializationException, RecordTooLargeExceptionFail immediately
ConditionalNotEnoughReplicasExceptionRetry until timeout
producer.send(record, (metadata, exception) -> {
if (exception == null) {
// Success
return;
}
if (exception instanceof RetriableException) {
// Already retried by producer; this is final failure
log.error("Retriable error exhausted retries", exception);
deadLetterQueue.send(record);
} else if (exception instanceof SerializationException) {
// Non-retriable; bad data
log.error("Serialization failed", exception);
errorMetrics.increment("serialization_error");
} else {
// Other fatal error
log.error("Unexpected error", exception);
alertService.alert("Kafka producer error", exception);
}
});

MetricAlert ConditionImplication
record-retry-rate> 0.1/sec sustainedNetwork or broker issues
record-error-rate> 0Delivery failures
request-latency-avg> 100msSlow broker response
batch-size-avgVery smallInefficient batching
records-per-request-avgLowCheck linger.ms
MetricAlert ConditionImplication
records-lag-maxGrowingProcessing too slow
commit-latency-avg> 100msSlow commits
rebalance-latency-avg> 30sLong rebalances
// Track duplicates in application
Counter duplicatesDetected = meterRegistry.counter("kafka.consumer.duplicates");
Counter recordsProcessed = meterRegistry.counter("kafka.consumer.processed");
public void process(ConsumerRecord<String, String> record) {
String messageId = extractMessageId(record);
if (deduplicationService.isProcessed(messageId)) {
duplicatesDetected.increment();
return;
}
processBusinessLogic(record);
recordsProcessed.increment();
deduplicationService.markProcessed(messageId);
}

PracticeRationale
Use acks=allStrongest durability guarantee
Enable idempotencePrevents producer-side duplicates
Set reasonable delivery timeoutAvoid infinite retries
Include message ID in payloadEnable consumer deduplication
PracticeRationale
Disable auto-commitExplicit commit control
Process before commitEnsures at-least-once
Implement idempotent processingHandle duplicates gracefully
Use reasonable poll intervalsAvoid session timeout
PracticeRationale
Design for idempotenceSimplifies duplicate handling
Use natural keys as message keysEnables partition-level deduplication
Consider deduplication layerCentralized duplicate handling
Monitor duplicate ratesDetect configuration issues

// WRONG: At-most-once, not at-least-once
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
consumer.commitSync(); // Commit first
for (ConsumerRecord<String, String> record : records) {
process(record); // Crash here loses messages
}
// WRONG: Non-idempotent operation without deduplication
for (ConsumerRecord<String, String> record : records) {
// Duplicate execution doubles the balance!
accountService.credit(record.accountId(), record.amount());
}
consumer.commitSync();
// RISKY: Lost commits cause redelivery
for (ConsumerRecord<String, String> record : records) {
process(record);
}
consumer.commitAsync(); // May silently fail
// If commit fails and consumer crashes, redelivery occurs