Kafka share groups with Lambda solve a problem I keep running into in Kafka projects: concurrency is tied to the number of partitions. If a topic has six partitions, at most six consumers work on it at the same time. When a traffic spike hits, you either over-partition up front or reroute the messages into a second queue. Both are annoying. Moreover, both are things you have to operate forever.
Since November 2, 2026, AWS Lambda offers a queue mode for self-managed Kafka clusters (and services such as Confluent Cloud). It is built on the share groups from Apache Kafka 4.2 and decouples concurrency from partitions. In this article, therefore, we look at what is behind it, when the mode pays off and where it can hurt you. After that, you build a Lambda function with AWS CDK, step by step, that processes ride-ended events from a fictional bike rental. It does so idempotently, because in queue mode that is no longer optional.
In short
In queue mode, Lambda reads a Kafka topic through a share group: several pollers share the same partitions, each with up to ten concurrent invocations. However, you lose ordering, and duplicates become more likely. The biggest pitfall: without an on-failure destination, the broker drops a record after several failed deliveries.
What you will learn in this article
- How share groups differ from classic consumer groups
- When queue mode fits and when you should stay with stream mode
- How to try share groups locally with Docker in five minutes
- How to create the event source mapping in queue mode with AWS CDK
- What an idempotent TypeScript handler with DynamoDB looks like
What is new with Kafka share groups for Lambda?
Lambda reads Kafka through an event source mapping (ESM). That is the AWS-managed poller that fetches records, bundles them into batches and invokes your function with them. Previously, this ESM always used the classic consumer group protocol. The SelfManagedKafkaEventSourceConfig now has a ConsumptionMode field with two values:
- Stream (default): consumer group, each partition belongs to exactly one poller, ordering per partition is preserved.
- Queue: share group, all pollers read together from all partitions, without ordering but with more throughput.
One thing matters for planning: according to the AWS documentation, queue mode only works with self-managed Kafka clusters on version 4.2 or newer that have share groups switched on. Amazon MSK does not support it yet; AWS plans to follow once MSK offers Kafka 4.2 with share groups. Furthermore, you do not have to change producers, topic configuration or function code. What you do have to change is your assumptions about ordering and duplicates. More on that in a moment.
Why partitions used to cap concurrency
In a classic consumer group, Kafka assigns partitions exclusively to members. One member can own several partitions, but a partition never has several members. That is great when ordering matters, for example with account transactions. However, it gets in the way when every event is an independent task: resizing an image, generating an invoice, sending a push notification. As a result, a single slow record holds up everything behind it in the same partition.
Stream mode with fixed assignment on the left, queue mode with shared partitions on the right.
How share groups work in detail
Kafka share groups come from the improvement proposal KIP-932 (“Queues for Kafka”); Lambda requires a broker on version 4.2 or later. The broker keeps a state for every record. Initially, a free record is Available. When a consumer fetches it, it becomes Acquired and is locked for that one consumer for a limited time. Finally, once the consumer confirms processing, it becomes Acknowledged. If the lock expires or the consumer releases it on purpose, it is available again. Each fetch raises its delivery count by one.
Two broker settings are worth knowing. group.share.record.lock.duration.ms sets the lock duration; the default is 30 seconds. group.share.delivery.count.limit caps the delivery attempts; the default is 5. Once the limit is reached, the record moves to the Archived state and is not delivered again. For poison messages, that is what you want. However, for records that only failed because a mail server had a short hiccup, probably not.
🎬 Queue mode: records from one partition run in parallel on several Lambda invocations – this animated diagram only runs on my site: watch it on sascha-ms.de
For Kafka share groups, Lambda uses the share consumer with explicit acknowledgement. A poller fetches a batch, invokes your function and acknowledges only afterwards. Consequently, this leads to two restrictions: there is no batching window (MaximumBatchingWindowInSeconds) and no starting position (StartingPosition). Reading always resumes from the position the share group has already acknowledged.
Stream or queue: when does queue mode pay off?
Here are the limits from the AWS documentation side by side:
| Feature | Stream (default) | Queue |
|---|---|---|
| Ordering | per partition | none |
| Group model | consumer group, exclusive partitions | share group, shared access |
| Concurrent invocations per poller | up to 5 | up to 10 |
| Throughput per poller (provisioned) | up to 5 MB/s | up to 5 MB/s |
| On-demand ceiling in queue mode | – | 5 pollers, 5 MB/s or 50 concurrent invocations |
| Batching window, starting position | yes | no |
| Cluster | MSK and self-managed | self-managed only, Kafka 4.2+ |
Lambda with Kafka share groups fits when every event stands on its own and speed matters more than ordering. Typical candidates are, for example, image and video processing, notifications, document generation or collecting telemetry. In addition, queue mode helps with topics that have few partitions and that you cannot rebuild because other teams depend on them.
Stay with stream mode as soon as a later event depends on an earlier one. For example, take “ride started” and “ride ended” for the same ride. In queue mode, the end can therefore arrive before the start. Similarly, counters and sums are tricky: if a record is processed twice, you count twice. AWS explicitly warns that unordered processing combined with duplicates can lead to wrong results.
Try Kafka share groups locally with Docker
Before you deploy anything to AWS, it is worth seeing the behavior for yourself. With the official Docker image, a Kafka broker runs in KRaft mode (without ZooKeeper) within seconds. From Kafka 4.2 on, share groups are enabled by default. Besides that, you only need Docker and three terminal windows.
Step 1: Start the broker and create a topic with just one partition. One partition is on purpose: this is exactly where a consumer group would let only a single consumer work.
docker run -d --name kafka -p 9092:9092 apache/kafka:4.4.0
docker exec kafka /opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 \
--create --topic ride-ended --partitions 1
Step 2: In two terminals, start one share consumer each in the same group.
docker exec -it kafka /opt/kafka/bin/kafka-console-share-consumer.sh \
--bootstrap-server localhost:9092 --topic ride-ended --group invoice-workers
Step 3: In the third terminal, write a few lines to the topic. Each line is one record.
docker exec -it kafka /opt/kafka/bin/kafka-console-producer.sh \
--bootstrap-server localhost:9092 --topic ride-ended
You will see both consumers splitting the lines between them, even though there is only one partition. In contrast, with a consumer group, one of them would sit idle. Finally, take a look at the group:
docker exec kafka /opt/kafka/bin/kafka-share-groups.sh --bootstrap-server localhost:9092 \
--describe --group invoice-workers --members
The output lists both members with the same assignment ride-ended:0. That is the core idea of share groups. Clean up with docker rm -f kafka. If a command is named differently on your machine, --help shows the options of your version.
Step by step: Lambda with Kafka share groups in CDK
Now it gets concrete. The fictional bike rental writes a ride-ended event to Kafka after every ride. A Lambda function reads the topic through Kafka share groups and is supposed to generate and send an invoice from it. Ordering does not matter, but every ride must be billed only once. Therefore, it is clear what the code has to do: work in parallel and reliably detect duplicates.
Prerequisites
- A self-managed Kafka cluster on version 4.2 or later (or a provider such as Confluent Cloud) that Lambda can reach, here via SASL/SCRAM over public endpoints
- A secret in AWS Secrets Manager with
usernameandpasswordfor Kafka access - Node.js 22 or later, the AWS CLI and an AWS account with permissions for CDK deployments
- Basic knowledge of TypeScript and AWS CDK
Step 1: Create the project
mkdir ride-invoices && cd ride-invoices
npm init -y
npm install aws-cdk-lib constructs @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
npm install -D aws-cdk typescript tsx esbuild @types/node @types/aws-lambda
mkdir bin lib src test
Add a tsconfig.json and a cdk.json. I checked the code with TypeScript 7.0.2, aws-cdk-lib 2.275.0 and Node.js 22.
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
},
"include": ["bin", "lib", "src", "test"]
}
// cdk.json
{
"app": "npx tsx bin/app.ts"
}
The comment lines above the two JSON blocks are only there for orientation; leave them out of the real files, since JSON does not support comments.
Step 2: Understand and decode the Kafka event
The event format is the same in both modes. records is grouped by “topic-partition”, and key and value arrive base64-encoded. The only new thing is that in queue mode, records from several partitions land in one batch in any order. That is why the helper simply flattens the batch. It flags broken messages with a dedicated error type so that we can skip them on purpose later.
// src/kafka-event.ts
// Minimal types for the Kafka event that Lambda delivers (Stream and Queue mode share the format).
export interface KafkaHeader {
[name: string]: number[];
}
export interface KafkaRecord {
topic: string;
partition: number;
offset: number;
timestamp: number;
timestampType: string;
key?: string; // base64
value?: string; // base64
headers: KafkaHeader[];
}
export interface KafkaEvent {
eventSource: string; // "SelfManagedKafka"
bootstrapServers?: string;
records: Record<string, KafkaRecord[]>; // key: "<topic>-<partition>"
}
export interface RideEnded {
rideId: string;
bikeId: string;
customerEmail: string;
minutes: number;
}
// Flattens the batch. In Queue mode the order across and within partitions is not guaranteed.
export function flattenRecords(event: KafkaEvent): KafkaRecord[] {
return Object.values(event.records ?? {}).flat();
}
export function decodeValue(record: KafkaRecord): RideEnded {
if (!record.value) {
throw new PoisonMessageError(`Record ${record.topic}/${record.partition}/${record.offset} has no value`);
}
let parsed: unknown;
try {
parsed = JSON.parse(Buffer.from(record.value, 'base64').toString('utf8'));
} catch {
throw new PoisonMessageError(`Record ${record.topic}/${record.partition}/${record.offset} is not valid JSON`);
}
const ride = parsed as Partial<RideEnded>;
if (typeof ride.rideId !== 'string' || typeof ride.minutes !== 'number') {
throw new PoisonMessageError(`Record ${record.topic}/${record.partition}/${record.offset} is missing rideId or minutes`);
}
return ride as RideEnded;
}
// Errors that will never succeed on retry (broken JSON, missing fields).
export class PoisonMessageError extends Error {
override name = 'PoisonMessageError';
}
Step 3: Secure idempotency with DynamoDB
Idempotent means: no matter how often the same record arrives, the result is the same. The key for that is the business ID, here rideId, not the Kafka offset. Consequently, you also catch duplicates the producer created itself. I explained the pattern in detail in “Idempotency in AWS Lambda”. Here is a variant with a “claim”: an invocation reserves the ride with a conditional write, does the work and then marks it as done.
// src/idempotency.ts
import { ConditionalCheckFailedException, DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DeleteCommand, DynamoDBDocumentClient, PutCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
export type ClaimResult = 'claimed' | 'already-done' | 'in-progress';
// Small interface so the handler can be tested without AWS.
export interface IdempotencyStore {
claim(key: string, nowMs: number, lockMs: number): Promise<ClaimResult>;
complete(key: string, nowMs: number): Promise<void>;
release(key: string): Promise<void>;
}
const DAY_SECONDS = 24 * 60 * 60;
export class DynamoIdempotencyStore implements IdempotencyStore {
private readonly doc: DynamoDBDocumentClient;
constructor(private readonly tableName: string, client = new DynamoDBClient({})) {
this.doc = DynamoDBDocumentClient.from(client);
}
async claim(key: string, nowMs: number, lockMs: number): Promise<ClaimResult> {
try {
await this.doc.send(
new PutCommand({
TableName: this.tableName,
Item: {
pk: key,
status: 'IN_PROGRESS',
lockedUntil: nowMs + lockMs,
expiresAt: Math.floor(nowMs / 1000) + 7 * DAY_SECONDS, // TTL attribute
},
// New key, or an abandoned claim whose lock has expired.
ConditionExpression: 'attribute_not_exists(pk) OR (#s = :inProgress AND lockedUntil < :now)',
ExpressionAttributeNames: { '#s': 'status' },
ExpressionAttributeValues: { ':inProgress': 'IN_PROGRESS', ':now': nowMs },
ReturnValuesOnConditionCheckFailure: 'ALL_OLD',
}),
);
return 'claimed';
} catch (err) {
if (err instanceof ConditionalCheckFailedException) {
return err.Item?.status?.S === 'DONE' ? 'already-done' : 'in-progress';
}
throw err;
}
}
async complete(key: string, nowMs: number): Promise<void> {
await this.doc.send(
new UpdateCommand({
TableName: this.tableName,
Key: { pk: key },
UpdateExpression: 'SET #s = :done, doneAt = :now REMOVE lockedUntil',
ExpressionAttributeNames: { '#s': 'status' },
ExpressionAttributeValues: { ':done': 'DONE', ':now': nowMs },
}),
);
}
async release(key: string): Promise<void> {
await this.doc.send(new DeleteCommand({ TableName: this.tableName, Key: { pk: key } }));
}
}
// In-memory variant for tests and local experiments.
export class MemoryIdempotencyStore implements IdempotencyStore {
readonly items = new Map<string, { status: 'IN_PROGRESS' | 'DONE'; lockedUntil: number }>();
async claim(key: string, nowMs: number, lockMs: number): Promise<ClaimResult> {
const existing = this.items.get(key);
if (existing?.status === 'DONE') return 'already-done';
if (existing && existing.lockedUntil >= nowMs) return 'in-progress';
this.items.set(key, { status: 'IN_PROGRESS', lockedUntil: nowMs + lockMs });
return 'claimed';
}
async complete(key: string): Promise<void> {
this.items.set(key, { status: 'DONE', lockedUntil: 0 });
}
async release(key: string): Promise<void> {
this.items.delete(key);
}
}
The condition in the PutCommand is the most important part. It only allows the write if the key does not exist yet, or if an earlier claim was abandoned because its invocation crashed. On a conflict, ReturnValuesOnConditionCheckFailure returns the old item, so we know without a second read whether the ride is already done or currently being processed. Additionally, the TTL cleans up old items after seven days.
Step 4: Process the batch
Now for the actual logic. It knows three cases. A ride is new, so do the work. It is already done, so skip it. Or another invocation is holding it right now, in which case the batch should come back later. Poison messages such as broken JSON block nothing; they are logged and skipped.
// src/process-batch.ts
import { decodeValue, flattenRecords, KafkaEvent, PoisonMessageError, RideEnded } from './kafka-event';
import { IdempotencyStore } from './idempotency';
export interface Dependencies {
store: IdempotencyStore;
sendInvoice: (ride: RideEnded) => Promise<void>;
now?: () => number;
log?: (msg: string, data?: Record<string, unknown>) => void;
}
export interface BatchSummary {
processed: number;
skippedDuplicates: number;
poison: number;
}
// Longer than the function timeout: an abandoned claim only expires once the invocation is surely gone.
export const CLAIM_LOCK_MS = 60_000;
export class RetryLaterError extends Error {
override name = 'RetryLaterError';
}
export async function processBatch(event: KafkaEvent, deps: Dependencies): Promise<BatchSummary> {
const now = deps.now ?? Date.now;
const log = deps.log ?? ((msg, data) => console.log(JSON.stringify({ msg, ...data })));
const summary: BatchSummary = { processed: 0, skippedDuplicates: 0, poison: 0 };
let retryNeeded = false;
for (const record of flattenRecords(event)) {
let ride: RideEnded;
try {
ride = decodeValue(record);
} catch (err) {
if (err instanceof PoisonMessageError) {
// Retrying will not help. Log it and move on so it does not block the batch.
summary.poison++;
log('poison message skipped', { reason: err.message });
continue;
}
throw err;
}
const key = `ride#${ride.rideId}`; // business key, not the offset
const claim = await deps.store.claim(key, now(), CLAIM_LOCK_MS);
if (claim === 'already-done') {
summary.skippedDuplicates++;
continue;
}
if (claim === 'in-progress') {
// Another invocation is working on the same ride right now.
retryNeeded = true;
continue;
}
try {
await deps.sendInvoice(ride);
await deps.store.complete(key, now());
summary.processed++;
} catch (err) {
await deps.store.release(key);
throw err; // the whole batch is retried; finished rides are skipped next time
}
}
if (retryNeeded) {
throw new RetryLaterError('At least one ride is being processed by another invocation');
}
log('batch done', { ...summary });
return summary;
}
If sending fails, the code releases the claim and rethrows the error. Then Lambda retries the batch. Rides that were already finished earlier in the same batch are recognized by the second run through their DONE status and skipped. The AWS documentation does not mention per-record responses for queue mode, like the ones you know from the SQS partial batch response. Therefore, I rely on idempotency here instead of partial acknowledgements.
Step 5: The handler
This handler just wires the pieces together. The sendInvoice function is a placeholder; in real life you would generate a PDF here and send an email.
// src/handler.ts
import { DynamoIdempotencyStore } from './idempotency';
import { KafkaEvent, RideEnded } from './kafka-event';
import { processBatch } from './process-batch';
const store = new DynamoIdempotencyStore(process.env.IDEMPOTENCY_TABLE ?? 'missing-table');
// Placeholder for the real work (render the PDF invoice, send the email ...).
async function sendInvoice(ride: RideEnded): Promise<void> {
console.log(JSON.stringify({ msg: 'invoice sent', rideId: ride.rideId, minutes: ride.minutes }));
}
export const handler = async (event: KafkaEvent) => processBatch(event, { store, sendInvoice });
Step 6: The CDK stack with queue mode
The L2 construct SelfManagedKafkaEventSource requires a starting position, which queue mode rejects. That is why I use the L1 construct CfnEventSourceMapping, which maps the CloudFormation properties directly and already knows consumptionMode. After three retries, failed batches end up in an SQS queue instead of being silently archived by the broker.
// lib/ride-invoice-stack.ts
import { Duration, RemovalPolicy, Stack, StackProps } from 'aws-cdk-lib';
import { AttributeType, BillingMode, Table } from 'aws-cdk-lib/aws-dynamodb';
import { CfnEventSourceMapping, Runtime } from 'aws-cdk-lib/aws-lambda';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import { Secret } from 'aws-cdk-lib/aws-secretsmanager';
import { Queue } from 'aws-cdk-lib/aws-sqs';
import { Construct } from 'constructs';
export interface RideInvoiceStackProps extends StackProps {
bootstrapServers: string[]; // e.g. ["broker1.example.com:9096"]
kafkaSecretArn: string; // Secrets Manager secret with {"username": "...", "password": "..."}
topic: string;
}
export class RideInvoiceStack extends Stack {
constructor(scope: Construct, id: string, props: RideInvoiceStackProps) {
super(scope, id, props);
const idempotencyTable = new Table(this, 'IdempotencyTable', {
partitionKey: { name: 'pk', type: AttributeType.STRING },
billingMode: BillingMode.PAY_PER_REQUEST,
timeToLiveAttribute: 'expiresAt',
removalPolicy: RemovalPolicy.DESTROY, // demo only
});
const failedRides = new Queue(this, 'FailedRides', {
retentionPeriod: Duration.days(14),
});
const invoiceFn = new NodejsFunction(this, 'RideInvoiceFn', {
entry: 'src/handler.ts',
runtime: Runtime.NODEJS_24_X,
memorySize: 512,
// Clearly below the share group's acquisition lock (default 30 s).
timeout: Duration.seconds(20),
environment: { IDEMPOTENCY_TABLE: idempotencyTable.tableName },
});
idempotencyTable.grantReadWriteData(invoiceFn);
failedRides.grantSendMessages(invoiceFn);
const kafkaSecret = Secret.fromSecretCompleteArn(this, 'KafkaSecret', props.kafkaSecretArn);
kafkaSecret.grantRead(invoiceFn);
// L1 construct: ConsumptionMode is available directly in CloudFormation.
new CfnEventSourceMapping(this, 'RideEndedQueueMapping', {
functionName: invoiceFn.functionName,
topics: [props.topic],
selfManagedEventSource: {
endpoints: { kafkaBootstrapServers: props.bootstrapServers },
},
sourceAccessConfigurations: [{ type: 'SASL_SCRAM_512_AUTH', uri: kafkaSecret.secretArn }],
selfManagedKafkaEventSourceConfig: { consumptionMode: 'Queue' },
batchSize: 50,
// No StartingPosition and no MaximumBatchingWindowInSeconds: Queue mode rejects both.
maximumRetryAttempts: 3,
bisectBatchOnFunctionError: true,
destinationConfig: { onFailure: { destination: failedRides.queueArn } },
provisionedPollerConfig: { minimumPollers: 1, maximumPollers: 10 },
});
}
}
// bin/app.ts
import { App } from 'aws-cdk-lib';
import { RideInvoiceStack } from '../lib/ride-invoice-stack';
const app = new App();
const servers = (app.node.tryGetContext('bootstrapServers') as string | undefined) ?? 'broker1.example.com:9096';
new RideInvoiceStack(app, 'RideInvoiceStack', {
bootstrapServers: servers.split(','),
kafkaSecretArn:
(app.node.tryGetContext('kafkaSecretArn') as string | undefined) ??
'arn:aws:secretsmanager:eu-central-1:111111111111:secret:kafka-ride-invoices-AbCdEf',
topic: (app.node.tryGetContext('topic') as string | undefined) ?? 'ride-ended',
});
Two details matter to me. The 20-second timeout is deliberately below the share group lock of 30 seconds. However, the documentation does not describe how the Lambda poller handles this lock internally. So I plan defensively, so that a record is not handed to another invocation while it is still being processed. And provisionedPollerConfig switches on provisioned mode, which allows more throughput but costs extra. For first experiments, you can drop that line and start with on-demand.
Step 7: Test without AWS
Thanks to the in-memory variant, you can check the logic with the built-in Node.js test runner. The tests cover duplicates, poison messages, failed sends, competing invocations and abandoned claims.
// test/process-batch.test.ts
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { MemoryIdempotencyStore } from '../src/idempotency';
import { KafkaEvent, KafkaRecord, RideEnded } from '../src/kafka-event';
import { processBatch, RetryLaterError } from '../src/process-batch';
function record(partition: number, offset: number, body: unknown): KafkaRecord {
return {
topic: 'ride-ended',
partition,
offset,
timestamp: 1_767_000_000_000,
timestampType: 'CREATE_TIME',
value: Buffer.from(typeof body === 'string' ? body : JSON.stringify(body)).toString('base64'),
headers: [],
};
}
function event(...records: KafkaRecord[]): KafkaEvent {
const grouped: Record<string, KafkaRecord[]> = {};
for (const r of records) (grouped[`${r.topic}-${r.partition}`] ??= []).push(r);
return { eventSource: 'SelfManagedKafka', records: grouped };
}
const ride = (id: string): RideEnded => ({ rideId: id, bikeId: 'b-7', customerEmail: '[email protected]', minutes: 42 });
const silent = () => {};
test('processes records from several partitions', async () => {
const sent: string[] = [];
const result = await processBatch(event(record(0, 10, ride('r1')), record(3, 7, ride('r2'))), {
store: new MemoryIdempotencyStore(),
sendInvoice: async (r) => void sent.push(r.rideId),
log: silent,
});
assert.deepEqual(result, { processed: 2, skippedDuplicates: 0, poison: 0 });
assert.deepEqual(sent.sort(), ['r1', 'r2']);
});
test('skips a redelivered ride that is already done', async () => {
const store = new MemoryIdempotencyStore();
const sent: string[] = [];
const deps = { store, sendInvoice: async (r: RideEnded) => void sent.push(r.rideId), log: silent };
await processBatch(event(record(0, 10, ride('r1'))), deps);
const second = await processBatch(event(record(0, 10, ride('r1'))), deps);
assert.equal(second.skippedDuplicates, 1);
assert.deepEqual(sent, ['r1']);
});
test('skips poison messages instead of blocking the batch', async () => {
const result = await processBatch(event(record(1, 1, 'not json'), record(1, 2, { bikeId: 'x' }), record(1, 3, ride('r3'))), {
store: new MemoryIdempotencyStore(),
sendInvoice: async () => {},
log: silent,
});
assert.deepEqual(result, { processed: 1, skippedDuplicates: 0, poison: 2 });
});
test('releases the claim when sending fails so a retry can process it', async () => {
const store = new MemoryIdempotencyStore();
let fail = true;
const deps = {
store,
sendInvoice: async () => {
if (fail) throw new Error('mail server timeout');
},
log: silent,
};
await assert.rejects(processBatch(event(record(2, 5, ride('r4'))), deps), /mail server timeout/);
assert.equal(store.items.has('ride#r4'), false);
fail = false;
const retry = await processBatch(event(record(2, 5, ride('r4'))), deps);
assert.equal(retry.processed, 1);
});
test('asks for a retry when another invocation holds the claim', async () => {
const store = new MemoryIdempotencyStore();
await store.claim('ride#r5', Date.now(), 60_000);
await assert.rejects(
processBatch(event(record(0, 1, ride('r5')), record(0, 2, ride('r6'))), { store, sendInvoice: async () => {}, log: silent }),
RetryLaterError,
);
assert.equal(store.items.get('ride#r6')?.status, 'DONE');
});
test('takes over an abandoned claim after its lock expired', async () => {
const store = new MemoryIdempotencyStore();
await store.claim('ride#r7', 1_000, 60_000);
const result = await processBatch(event(record(0, 9, ride('r7'))), {
store,
sendInvoice: async () => {},
now: () => 1_000 + 61_000,
log: silent,
});
assert.equal(result.processed, 1);
});
npx tsc --noEmit -p .
npx tsx --test test/*.test.ts
Expected result: tsc reports nothing, and the test runner shows pass 6 and fail 0.
Step 8: Deploy and verify
npx cdk bootstrap
npx cdk deploy \
-c bootstrapServers=broker1.example.com:9096,broker2.example.com:9096 \
-c kafkaSecretArn=arn:aws:secretsmanager:eu-central-1:111111111111:secret:kafka-ride-invoices-AbCdEf \
-c topic=ride-ended
Next, check that the ESM really runs in queue mode. You can find the UUID with aws lambda list-event-source-mappings --function-name <function-name>.
aws lambda get-event-source-mapping --uuid <uuid> \
--query "{state: State, mode: SelfManagedKafkaEventSourceConfig.ConsumptionMode}"
You should see "state": "Enabled" and "mode": "Queue". Then write a test event such as {"rideId":"r-1001","bikeId":"b-7","customerEmail":"[email protected]","minutes":42} to the topic and look at the function’s CloudWatch logs. There should be exactly one invoice sent entry, even if you send the same event twice.
Common mistakes with Kafka share groups and Lambda
Validation errors on creation
If you set StartingPosition or MaximumBatchingWindowInSeconds, Lambda rejects the ESM in queue mode. For instance, that happens quickly when you copy an existing stream configuration. A provisioned configuration also fails if you reuse a PollerGroupName that stream-mode ESMs already use. Queue ESMs only share event poller units with each other, so give them their own group name.
Warning during cdk synth
With aws-cdk-lib 2.275.0, cdk synth prints a warning that ConsumptionMode is an unexpected property. The local schema check in the CDK is simply behind here; the AWS documentation shows the property in its official CloudFormation example. Nevertheless, the warning does not block the deployment.
Wrong assumptions about MSK and ordering
If you use Amazon MSK, you cannot use Kafka share groups with Lambda yet. And if you carry over code that silently relies on ordering within a partition, you will get sporadic, hard-to-reproduce bugs in queue mode. Therefore, check every consumer for such assumptions before you switch. Finally, retries: MaximumRetryAttempts defaults to 0. Without an on-failure destination, the broker then takes over the retries, and once the delivery limit is reached, the record is gone. So always configure an onFailure destination.
Cost and security
When Lambda reads Kafka share groups in on-demand mode, you pay the regular Lambda prices for invocations and duration. In provisioned mode, event poller units (EPU) are added and billed by usage. Several ESMs in the same VPC can share EPUs through a common PollerGroupName, which saves money with many small topics. Moreover, more concurrency means more simultaneous invocations. So set reserved concurrency or MaximumPollers, so that a spike in Kafka does not eat your account’s concurrency quota or overwhelm downstream systems. The quickest way to estimate what the invocations cost is the Lambda cost calculator.
My take
I consider Kafka share groups in Lambda one of the more useful releases this fall, precisely because queue mode is so unspectacular. It removes an architecture decision that many teams only made for lack of alternatives: dumping Kafka messages into SQS just to get more concurrency. If the data lives in Kafka anyway, one less infrastructure layer is a real gain.
At the same time, I see a risk. The switch is so small that people will flip it without touching the consumer code. Nevertheless, that is exactly what you should not do. Stream consumers are often quietly built around ordering and “exactly once per partition”, without that being written down anywhere. I would therefore treat queue mode like a new integration: prove idempotency, configure an on-failure destination, run a load test. Admittedly, the missing MSK support will slow adoption for now. If you run Confluent Cloud or your own clusters on 4.2 or later, you can start today.
FAQ
What are Kafka share groups?
Kafka share groups are a group type in Apache Kafka (KIP-932) that Lambda reads in queue mode and in which several consumers share the same partitions. The broker locks individual records, counts delivery attempts and expects an acknowledgement per record, similar to a classic message queue.
Which Kafka version does Lambda queue mode require?
Lambda needs a cluster on Apache Kafka 4.2 or newer where share groups are turned on. Share groups have existed since Kafka 4.0, but the Lambda poller requires 4.2.
Does queue mode work with Amazon MSK?
Not yet. At launch, Lambda supports the mode only for self-managed Kafka clusters and services such as Confluent Cloud. AWS intends to add MSK as soon as MSK ships Kafka 4.2 with share groups.
Does queue mode cost extra?
In on-demand mode, you only pay the regular Lambda costs. If you use provisioned mode, you also pay for the event poller units you use.
Your next step
Run the Docker experiment from above and let two share consumers work on a single partition. Before you use Kafka share groups with Lambda in production, go through your existing consumers and mark the ones that really need ordering. I bet the list is shorter than you think. What does it look like for you: which of your Kafka consumers would be a candidate for queue mode, and which one absolutely not?
Sources
- AWS What’s New: AWS Lambda now supports queues for Apache Kafka in self-managed Kafka event sources (November 2, 2026)
- Developer Guide: Polling Kafka topics in Queue mode (AWS Lambda)
- Lambda Developer Guide: Using Lambda with self-managed Apache Kafka
- Pricing: AWS Lambda
- Apache Kafka: KIP-932 Queues for Kafka
- Apache Kafka 4.4: Broker Configs (group.share.*)
- Apache Kafka 4.4: Basic Kafka Operations, Managing share groups
- Apache Kafka 4.4: Quick Start with the Docker image
This post was created with AI assistance.
👉 Next step: Curious what your own Lambda functions cost? Try my Lambda cost calculator – it runs right in your browser.
Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

Top comments (0)