Skip to content

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

Kafka Offset Management

Offsets track consumer progress through partitions. Proper offset management is essential for delivery semantics and reliable message processing.


An offset is a sequential identifier for messages within a partition:

Committed offset, current position, and log end offset within a partitionCommitted offset, current position, and log end offset within a partitionPartition 0Msg0Msg1Msg2Msg3Msg4...Msg999Committed offset: 3(next to read)Current position: 5(being processed)Log end offset: 1000(latest message)
Offset TypeDescription
Committed offsetLast acknowledged position. Resume point after restart.
Current positionWhere consumer is reading now
Log end offset (LEO)Latest message in partition
High watermarkLatest replicated message (readable)
Consumer lagLEO - Committed offset

Kafka stores committed offsets in an internal compacted topic:

Offset commit written to the __consumer_offsets topic by the group coordinatorConsumerGroup Coordinator__consumer_offsetsConsumerConsumerGroup CoordinatorGroup Coordinator__consumer_offsets(50 partitions)__consumer_offsets(50 partitions)OffsetCommitRequest(group=orders, topic=events, partition=0, offset=100)Write to partitionhash("orders") % 50Key: [group, topic, partition]Value: [offset, metadata, timestamp]Compacted: keeps latest per key50 partitions by default in Apache Kafka (configurable)
{
"key": {
"group": "order-processors",
"topic": "orders",
"partition": 0
},
"value": {
"offset": 1000,
"metadata": "batch-123",
"commit_timestamp": 1704067200000,
"expire_timestamp": -1
}
}
PropertyDefaultDescription
offsets.retention.minutes10080 (7 days)Broker-side offset retention

Offset Expiration

If a consumer group is inactive longer than offsets.retention.minutes, committed offsets are deleted. On restart, auto.offset.reset determines behavior.


Offsets are committed automatically at regular intervals:

Properties props = new Properties();
props.put("enable.auto.commit", "true");
props.put("auto.commit.interval.ms", "5000");
// Offsets committed automatically every 5 seconds
while (running) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
process(record); // May fail after auto-commit
}
}

Semantics: At-least-once (duplicates possible on failure)

Trade-offs:

ProsCons
Simple implementationNo control over commit timing
No commit code neededDuplicates if crash after commit
Lower latencyMessages may be reprocessed if crash before commit
props.put("enable.auto.commit", "false");
while (running) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
process(record);
}
// Blocks until broker acknowledges
consumer.commitSync();
}

Semantics: At-least-once

Trade-offs:

ProsCons
Guaranteed durabilityBlocking reduces throughput
Precise commit timingHigher latency
while (running) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
process(record);
}
consumer.commitAsync((offsets, exception) -> {
if (exception != null) {
log.error("Commit failed: {}", offsets, exception);
// Handle failure - may need to retry or alert
}
});
}

Trade-offs:

ProsCons
Non-blockingNo immediate failure handling
Higher throughputCommit ordering not guaranteed

Combine async for throughput with sync on shutdown:

try {
while (running) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
process(record);
}
// Async commit during normal operation
consumer.commitAsync();
}
} finally {
// Sync commit on shutdown
consumer.commitSync();
consumer.close();
}

Commit offsets for individual partitions:

while (running) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (TopicPartition partition : records.partitions()) {
List<ConsumerRecord<String, String>> partitionRecords = records.records(partition);
for (ConsumerRecord<String, String> record : partitionRecords) {
process(record);
}
// Commit this partition only
long lastOffset = partitionRecords.get(partitionRecords.size() - 1).offset();
consumer.commitSync(Map.of(
partition,
new OffsetAndMetadata(lastOffset + 1, "processed") // +1 = next to read
));
}
}

Include metadata with offset commits:

OffsetAndMetadata offsetAndMetadata = new OffsetAndMetadata(
offset + 1,
"batch-id:abc123,timestamp:2024-01-01T00:00:00Z"
);
consumer.commitSync(Map.of(partition, offsetAndMetadata));

When no committed offset exists:

# Start from oldest available message
auto.offset.reset=earliest
# Start from newest message (skip history)
auto.offset.reset=latest
# Throw exception
auto.offset.reset=none
// Seek to beginning
consumer.seekToBeginning(consumer.assignment());
// Seek to end
consumer.seekToEnd(consumer.assignment());
// Seek to specific offset
consumer.seek(new TopicPartition("orders", 0), 1000L);
// Find offsets for a specific timestamp
Map<TopicPartition, Long> timestamps = new HashMap<>();
for (TopicPartition partition : consumer.assignment()) {
timestamps.put(partition, Instant.now().minus(Duration.ofHours(1)).toEpochMilli());
}
Map<TopicPartition, OffsetAndTimestamp> offsets = consumer.offsetsForTimes(timestamps);
for (Map.Entry<TopicPartition, OffsetAndTimestamp> entry : offsets.entrySet()) {
if (entry.getValue() != null) {
consumer.seek(entry.getKey(), entry.getValue().offset());
}
}

For exactly-once semantics or custom requirements, store offsets externally:

@Transactional
public void processAndCommit(ConsumerRecords<String, String> records) {
for (ConsumerRecord<String, String> record : records) {
// Process message
Order order = deserialize(record.value());
orderRepository.save(order);
// Store offset in same transaction
offsetRepository.save(new ConsumedOffset(
record.topic(),
record.partition(),
record.offset() + 1
));
}
// Commit database transaction - atomic with processing
}
// On startup, seek to stored offsets
public void initializeOffsets(KafkaConsumer<String, String> consumer) {
for (TopicPartition partition : consumer.assignment()) {
Optional<ConsumedOffset> offset = offsetRepository.find(
partition.topic(),
partition.partition()
);
offset.ifPresent(o -> consumer.seek(partition, o.getOffset()));
}
}
CREATE TABLE consumed_offsets (
topic VARCHAR(255) NOT NULL,
partition INT NOT NULL,
consumer_group VARCHAR(255) NOT NULL,
offset_value BIGINT NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (topic, partition, consumer_group)
);

Commit before processing:

// DANGER: Messages may be lost
while (running) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
consumer.commitSync(); // Commit first
for (ConsumerRecord<String, String> record : records) {
process(record); // Crash here = message lost
}
}

Commit after processing (default pattern):

while (running) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
process(record); // Process first
}
consumer.commitSync(); // Commit after - duplicates if crash between
}

Use transactions or idempotent processing (Kafka transactions cover Kafka-to-Kafka flows):

// Option 1: Kafka transactions (for Kafka-to-Kafka)
props.put("isolation.level", "read_committed");
props.put("enable.auto.commit", "false");
producer.initTransactions();
while (running) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
producer.beginTransaction();
try {
for (ConsumerRecord<String, String> record : records) {
ProducerRecord<String, String> output = process(record);
producer.send(output);
}
// Commit offsets as part of transaction
producer.sendOffsetsToTransaction(
getOffsets(records),
consumer.groupMetadata()
);
producer.commitTransaction();
} catch (Exception e) {
producer.abortTransaction();
}
}
// Option 2: Idempotent external processing
while (running) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
String idempotencyKey = record.topic() + "-" + record.partition() + "-" + record.offset();
if (!isProcessed(idempotencyKey)) {
process(record);
markProcessed(idempotencyKey);
}
}
consumer.commitSync();
}

Handle offsets during rebalances:

consumer.subscribe(List.of("orders"), new ConsumerRebalanceListener() {
private Map<TopicPartition, OffsetAndMetadata> currentOffsets = new HashMap<>();
@Override
public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
// Commit current progress before losing partitions
log.info("Committing offsets before revoke: {}", currentOffsets);
consumer.commitSync(currentOffsets);
currentOffsets.clear();
}
@Override
public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
// Initialize tracking for new partitions
log.info("Assigned partitions: {}", partitions);
}
});
// Track offsets during processing
while (running) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
process(record);
currentOffsets.put(
new TopicPartition(record.topic(), record.partition()),
new OffsetAndMetadata(record.offset() + 1)
);
}
consumer.commitAsync();
}

Terminal window
kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
--describe --group order-processors
MetricDescription
records-lagCurrent lag per partition
records-lag-maxMaximum lag across partitions
records-lag-avgAverage lag
commit-latency-avgAverage commit latency
commit-latency-maxMaximum commit latency
commit-rateCommits per second

ScenarioRecommendation
High throughputCommit every N records or time interval
Low volumeCommit after each batch
Critical dataSync commit after each message
ScenarioStorage
Standard Kafka__consumer_offsets (default)
Exactly-once to databaseStore with data in transaction
Cross-datacenterExternal storage with replication
while (running) {
try {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
process(records);
consumer.commitSync();
} catch (CommitFailedException e) {
// Lost partition during rebalance
log.warn("Commit failed, partition reassigned: {}", e.getMessage());
// Processing will be repeated by new owner
} catch (WakeupException e) {
// Shutdown signal
if (running) throw e;
}
}