Consumer
import { Kafka } from '@cookiemonsterdev/kafka-core';
const kafka = new Kafka({ clientId: 'my-app', brokers: ['localhost:9092'] });
const consumer = kafka.consumer({ groupId: 'my-group' });
// Opt in to KIP-848 on Kafka 4.0+: kafka.consumer({ groupId: 'my-group', groupProtocol: 'consumer' })
await consumer.connect();
await consumer.subscribe({ topics: ['events'], fromBeginning: true });
await consumer.run({
eachMessage: async ({ topic, partition, message }) => {
console.log({ topic, partition, offset: message.offset, value: message.value?.toString() });
},
});
message.offset is bigint. Methods: Consumer.
Source:
consumer/index.ts.
Consumers are long-running processes. Do not call run() inside a serverless
request handler.
listTopics() and partitionsFor(topic) are the same Metadata helpers as on
the producer. See Consumer API.
Subscribe
fromBeginning: true starts at the earliest offset when the group has no
commit. Pass autoOffsetReset: 'none' to throw instead. Pass
autoOffsetReset: 'by_duration:PT1H' (KIP-1106) to start at the first offset
at or after now minus that ISO-8601 duration, resolved via ListOffsets.
See
auto.offset.reset.
eachMessage vs eachBatch vs stream
run({ eachMessage }) is the usual path. eachBatch plus
partitionsConsumedConcurrently is the heavy-load consume API: you get the
whole fetch batch plus resolveOffset / commitOffsetsIfNecessary, and can
process partitions in parallel. The run default for concurrency remains 1.
Spread throughputPreset().consumer into run() to set concurrency to 4.
See Throughput. consumer.stream() is an async iterator over
batches; it cannot run alongside run().
for await (const batch of consumer.stream()) {
for (const message of batch.messages) {
console.log(message.offset);
}
}
run({ signal }) stops when the signal aborts.
maxRecords
Fetch size is byte-driven (maxBytesPerPartition, maxBytes): the broker can
return a batch holding far more records than an application wants to process
before checkpointing. run({ maxRecords }) (and stream({ maxRecords }))
caps how many records reach the handler per internal delivery cycle of an
already-fetched batch. It never changes the Fetch request itself —
maxBytesPerPartition and maxBytes stay exactly as configured, and the
broker keeps returning full-size batches. This only slices how many of the
already-returned records are handed to the handler at once; any remainder
carries over to the next internal cycle instead of being dropped or
re-fetched. The protocol/ecosystem term for the same idea is
max.poll.records.
The default differs by handler:
eachMessagedefaults to500. An oversized fetched batch is delivered in slices of at most 500 records, with an offset-commit checkpoint between slices so a crash partway through the batch does not lose offsets that were already processed.eachBatchis unlimited unlessmaxRecordsis set explicitly — a batch handler is designed to receive a whole batch by default. SettingmaxRecordssplits an oversized batch into smaller sub-batches instead, each with its ownresolveOffsetand a checkpoint between slices.
await consumer.run({
maxRecords: 100,
eachMessage: async ({ message }) => {
console.log(message.offset);
},
});
This is unrelated to the share consumer’s maxRecords (a ShareFetch wire
field capping records per ShareFetch response; see
ShareConsumerConfig
and Share groups below).
Pause, resume, seek
consumer.pause([{ topic: 'events' }]);
consumer.resume([{ topic: 'events' }]);
consumer.seek({ topic: 'events', partition: 0, offset: 42n });
Assign mode
assign() fetches exact partitions directly, with no group membership: no
JoinGroup/SyncGroup, no ConsumerGroupHeartbeat, no rebalancing. Use it when
you already know which partitions to read (a fixed worker-per-partition
layout, replaying a specific partition for debugging, or coordinating
assignment yourself outside Kafka’s consumer groups). Use subscribe()
instead whenever you want the broker to divide topics across a running set of
consumers.
const consumer = kafka.consumer({}); // groupId is optional in assign mode
await consumer.connect();
await consumer.assign([
{ topic: 'events', partition: 0 },
{ topic: 'events', partition: 1 },
]);
await consumer.run({
eachMessage: async ({ topic, partition, message }) => {
console.log({ topic, partition, offset: message.offset });
},
});
assign() and subscribe() are mutually exclusive on one consumer instance;
calling one after the other throws, and so does run()/stream() if neither
was called first. Pause, resume, and seek work exactly as they do with
subscribe().
Offset policy. Assign mode never auto-commits, regardless of the run()
autoCommit options - there is no consumer group to own the offsets, so the
only way an offset is committed is an explicit consumer.commitOffsets()
call. groupId is optional on kafka.consumer(...); it is required only if
you call commitOffsets() - calling it without one throws immediately rather
than committing nowhere. When a groupId is configured, commits are sent as
a standalone/simple consumer (no generation or membership check), so any
consumer using that group id can read them back with OffsetFetch, whether
or not it ever joined the group.
The starting position for each assigned partition, decided the first time it’s needed:
- An earlier
seek()call always wins. - Otherwise, if
groupIdis configured, that group’s committed offset for the partition, when one exists (OffsetFetch). - Otherwise,
autoOffsetReset(defaultlatest), resolved withListOffsets- the same fallbacksubscribe()uses when a group has no committed offset yet.
seek() overrides the position at any point, before or after run() starts.
Committed offsets, position, and lag
const committed = await consumer.committed([{ topic: 'events', partition: 0 }]);
const position = consumer.position({ topic: 'events', partition: 0 });
const lag = consumer.currentLag({ topic: 'events', partition: 0 });
committed() fetches offsets from the group coordinator (OffsetFetch); it
does not need run()/stream() to have started, since it is reading broker
state rather than local state. A partition with no committed offset comes
back as offset: -1n. This is the live-consumer counterpart to
admin.fetchOffsets, which remains the out-of-band tool for inspecting or
resetting offsets without a running consumer.
position() is the next offset this consumer will read for an assigned
partition; currentLag() is highWatermark - position(). Both return null
when the partition is not currently assigned instead of throwing - so a
rebalance that moves a partition away shows up as null, not an error.
currentLag() also returns null until at least one Fetch response has been
seen for the partition. Both require the group/assignment to have started
(run(), stream(), or assign()) and throw otherwise.
Assigners and isolation
Range, round-robin (default), sticky, and cooperative-sticky are built in
(PartitionAssigners). Cooperative-sticky follows KIP-429: members retain
unchanged partitions, revoke only partitions that move, and automatically run
the follow-up generation needed to assign revoked partitions safely. The
round-robin default and the other eager assigners still revoke their full
assignment during a rebalance.
Classic JoinGroup/SyncGroup remains the default. Set
groupProtocol: 'consumer' (broker property group.protocol) to opt into
the KIP-848 consumer protocol on Kafka 4.0+: membership and incremental
assignment use ConsumerGroupHeartbeat instead of JoinGroup/SyncGroup. The
broker assigns partitions (no client assignor). heartbeatInterval and
sessionTimeout are unused for membership; rebalanceTimeout is still sent
as the revoke budget. Use admin.describeConsumerGroups for KIP-848 groups
and admin.describeClassicGroups (alias of describeGroups) for classic
JoinGroup groups. Isolation defaults to read_committed
(readUncommitted: false). See Compatibility,
KIP-429,
KIP-848,
and consumer configs.
Server-side assignor and regex subscription (KIP-848)
Under groupProtocol: 'consumer', groupRemoteAssignor: 'uniform' | 'range'
requests a specific broker-side assignor (group.remote.assignor) instead of
letting the broker pick its default:
const consumer = kafka.consumer({
groupId: 'my-group',
groupProtocol: 'consumer',
groupRemoteAssignor: 'range',
});
Subscribing with a RegExp also behaves differently under this protocol.
With the classic protocol, the client fetches topic metadata and matches the
pattern locally, then subscribes to the resolved topic names. Under
groupProtocol: 'consumer', the pattern is sent as-is on
ConsumerGroupHeartbeat (subscribedTopicRegex, KIP-848
SubscriptionPattern) and the broker matches it against topic names itself -
no client-side metadata scan. The broker evaluates this pattern as RE2, which
is not the same syntax as JavaScript’s RegExp; stick to RE2-compatible
patterns (the two overlap for ordinary literal and character-class matching,
but diverge on lookaround, backreferences, and some escapes). Only one
RegExp subscription is supported per consumer group under this protocol
(KIP-848 carries a single pattern per member) - subscribing with a second,
different pattern throws. Server-side regex requires ConsumerGroupHeartbeat
v1 (Kafka 4.1+).
Hooks
hooks is a set of ordered async callbacks, not an interceptor SPI - there is
no ConsumerInterceptor class to implement. onConsume fires immediately
before the user’s handler runs: once per message in eachMessage mode, once
per batch in eachBatch mode (exactly one of message/batch is set on the
event, matching the active mode). onCommit fires once per offset-commit
attempt - auto-commit or a manual commitOffsets() call - after the broker
responds, with error set on failure. Each array runs in registration order,
one hook is always awaited before the next starts, and a hook that throws is
caught, logged, and never affects consumption or the commit - it neither
blocks a message/batch nor turns a successful commit into a failure (or vice
versa).
const consumer = kafka.consumer({
groupId: 'my-group',
hooks: {
onConsume: [({ topic, partition, message }) => console.log('consuming', topic, partition, message?.offset)],
onCommit: [
({ topics, error }) => {
if (error) console.error('commit failed', error);
else console.log('committed', topics.length, 'topic(s)');
},
],
},
});
Rebalance callbacks
run() accepts three optional callbacks for observing group membership
changes. Each is (topicPartitions: { topic: string; partition: number }[]) => void | Promise<void> and is called with only the partitions actually moving
in that rebalance step, not the full assignment:
await consumer.run({
onPartitionsRevoked: async (partitions) => {
// Commit offsets for these partitions before they're fetched by anyone else.
console.log('revoked', partitions);
},
onPartitionsAssigned: async (partitions) => {
console.log('assigned', partitions);
},
onPartitionsLost: async (partitions) => {
console.log('lost', partitions);
},
eachMessage: async ({ topic, partition, message }) => {
/* ... */
},
});
onPartitionsRevokedfires with the partitions this member is giving up, before the consumer fetches from its new assignment (revoke happens before reassignment). For the round-robin default and the other eager assigners, a rebalance revokes the member’s entire prior assignment, so this fires with everything the member held, immediately followed byonPartitionsAssignedwith the entire new assignment. For the cooperative-sticky assigner (KIP-429), only the subset actually being given up this round is reported - partitions the member keeps across the rebalance are never passed to this callback. See Assigners and isolation.onPartitionsAssignedfires with the partitions newly gained once the member has installed its new assignment. Same eager-vs-incremental split as above: the entire new assignment for eager assigners, only the newly gained subset for cooperative-sticky.onPartitionsLostfires instead ofonPartitionsRevokedwhen the member’s assignment was lost without a clean revoke - its session expired, or it was fenced out of the group (UNKNOWN_MEMBER_ID/ broker rejects a stale generation) before it had a chance to leave gracefully. This is the signal to abandon any pending offset commit for those partitions rather than attempt it: a lost partition may already be owned by another member, so committing against it can race the new owner’s progress or simply get rejected.onPartitionsRevoked, by contrast, always fires while the member is still a recognized part of the group, so committing there is safe. A callback never receives both events for the same partitions in the same rebalance.- KIP-848’s
ConsumerGroupHeartbeatprotocol reconciles assignments incrementally at the wire level, soonPartitionsRevoked/onPartitionsAssignedreport the same kind of incremental diff there as they do for cooperative-sticky, regardless ofgroupProtocol. - Each callback is awaited before the consumer proceeds -
onPartitionsRevoked(oronPartitionsLost) completes before the new assignment is installed, andonPartitionsAssignedcompletes before the consumer fetches from it. An error thrown by a callback is logged and does not abort the rebalance or the rejoin; a broken user-supplied callback should not be able to break group membership. - These callbacks are additive:
events.REBALANCINGandevents.GROUP_JOIN(viaconsumer.on(...)) keep firing exactly as before.REBALANCINGsignals “the group needs a rejoin”;GROUP_JOINreports the outcome once membership stabilizes. The callbacks above tell you specifically which partitions moved and why, without parsing group state yourself. Ecosystem term for the same protocol concept: Java’sConsumerRebalanceListener.
Share groups (KIP-932)
kafka.shareConsumer({ groupId }) is a separate API from consumer(). Records
are acquired, processed, then acknowledged (ACCEPT on success, RELEASE if
the handler throws). Classic subscribe remains the default path.
Reach for consumer() when partition ownership matters: you want a fixed
partition assignment per member, seek/pause/resume, offset commits you
control, or eachBatch semantics over a whole partition’s worth of records.
Reach for shareConsumer() (Kafka 4.1+) when you want queue-like
delivery instead: any member in the group can be handed any record, several
members can cooperatively drain the same partition, and a crashed member’s
in-flight records become available for another member to retry automatically.
There is no partition assignment to reason about and no seek.
const share = kafka.shareConsumer({ groupId: 'share-events' });
await share.connect();
share.subscribe({ topics: ['events'] });
await share.run({
eachMessage: async ({ topic, partition, message }) => {
console.log({ topic, partition, offset: message.offset });
},
});
Requires Kafka 4.1+ (stable ShareGroupHeartbeat v1) with share groups enabled
on the broker. Kafka 4.2+ negotiates ShareFetch / ShareAcknowledge v2
(shareAcquireMode for KIP-1206; RENEW acknowledgements for KIP-1222).
Admin helpers: describeShareGroups, listShareGroupOffsets,
alterShareGroupOffsets, deleteShareGroupOffsets, deleteShareGroups.
See KIP-932.
Acknowledgement: implicit vs explicit
KIP-932 calls the two acknowledgement styles implicit and explicit. Both
exist on this client’s run(), chosen per call rather than as a single
broker-wide setting:
- Implicit (the default either way): after
eachMessageresolves without throwing, every message it saw is acknowledgedACCEPTautomatically.eachBatchbehaves the same way as long aseachBatchAutoAckstays at its defaulttrueand the handler does not callacknowledge()itself - once the handler resolves, the whole batch is acknowledgedACCEPTfor you. - Explicit: inside
eachBatch, callacknowledge(type?)yourself to control the outcome per batch -ACCEPT(default),RELEASE(make it available for redelivery),REJECT, orRENEW(Kafka 4.2+, KIP-1222). SeteachBatchAutoAck: falseto make this mandatory: nothing is acknowledged unless the handler callsacknowledge(), so an untouched batch stays acquired until the broker’s own lock duration (group.share.record.lock.duration.ms) expires and releases it.
A handler that throws always queues RELEASE regardless of ack mode, so
another member can retry the acquired range.
await share.run({
eachBatchAutoAck: false,
eachBatch: async ({ batch, acknowledge }) => {
const ok = await process(batch.messages);
acknowledge(ok ? SHARE_ACKNOWLEDGE_TYPE.ACCEPT : SHARE_ACKNOWLEDGE_TYPE.REJECT);
},
});
Instrumentation events
share.on(eventName, listener) mirrors consumer.on(): network requests
(share.events.REQUEST / REQUEST_TIMEOUT / REQUEST_QUEUE_SIZE, the
same shape as the classic consumer’s), plus two share-consumer-specific
events.
| Event | Fires |
|---|---|
share.events.FETCH_START |
Before a ShareFetch round starts for a node |
share.events.FETCH |
After that round completes, with numberOfBatches and duration |
share.events.ACKNOWLEDGE |
When acquired ranges are acknowledged - piggybacked on the next ShareFetch, or via the explicit ShareAcknowledge sent when a session closes |
share.on(share.events.ACKNOWLEDGE, ({ payload }) => {
for (const { topic, partitions } of payload.topics) {
for (const { partition, firstOffset, lastOffset, acknowledgeType } of partitions) {
console.log({ topic, partition, firstOffset, lastOffset, acknowledgeType });
}
}
});