kafka-crab-js 4.1.2 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +317 -1073
  2. package/dist/diagnostics/channels.cjs.map +1 -1
  3. package/dist/index.d.cts +3 -2
  4. package/dist/index.d.ts +3 -2
  5. package/dist/kafka-client.cjs +92 -31
  6. package/dist/kafka-client.cjs.map +1 -1
  7. package/dist/kafka-client.d.cts +20 -8
  8. package/dist/kafka-client.d.ts +20 -8
  9. package/dist/kafka-client.js +92 -32
  10. package/dist/kafka-client.js.map +1 -1
  11. package/dist/send-failure-DMmbzTJn.d.cts +18 -0
  12. package/dist/send-failure-DMmbzTJn.d.ts +18 -0
  13. package/dist/send-failure.cjs +30 -0
  14. package/dist/send-failure.cjs.map +1 -0
  15. package/dist/send-failure.d.cts +2 -0
  16. package/dist/send-failure.d.ts +2 -0
  17. package/dist/send-failure.js +28 -0
  18. package/dist/send-failure.js.map +1 -0
  19. package/dist/streams/base-kafka-stream-readable.cjs +5 -1
  20. package/dist/streams/base-kafka-stream-readable.cjs.map +1 -1
  21. package/dist/streams/base-kafka-stream-readable.js +5 -1
  22. package/dist/streams/base-kafka-stream-readable.js.map +1 -1
  23. package/dist/streams/kafka-batch-stream-readable.cjs +1 -1
  24. package/dist/streams/kafka-batch-stream-readable.cjs.map +1 -1
  25. package/dist/streams/kafka-batch-stream-readable.d.cts +2 -3
  26. package/dist/streams/kafka-batch-stream-readable.d.ts +2 -3
  27. package/dist/streams/kafka-batch-stream-readable.js +1 -1
  28. package/dist/streams/kafka-batch-stream-readable.js.map +1 -1
  29. package/dist/streams/kafka-stream-readable.d.cts +1 -2
  30. package/dist/streams/kafka-stream-readable.d.ts +1 -2
  31. package/js-binding.cjs +81 -82
  32. package/js-binding.d.ts +28 -15
  33. package/js-binding.js +81 -83
  34. package/package.json +10 -10
package/README.md CHANGED
@@ -1,1131 +1,375 @@
1
- # Kafka Crab JS
2
-
3
- Kafka Crab JS is a native Kafka client for Node.js and TypeScript. It uses Rust, NAPI-RS, and librdkafka to reduce
4
- JavaScript heap pressure, expose Kafka's mature native client behavior, and push high-throughput consumer workloads
5
- through a small TypeScript-friendly API.
6
-
7
- [![npm beta version](https://img.shields.io/badge/npm%20beta-v4.0.0--beta.3-blue.svg)](https://www.npmjs.com/package/kafka-crab-js)
8
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
9
-
10
- ## Version 4
11
-
12
- `kafka-crab-js` v4 is the current beta line. It focuses on high-throughput consumers, lower JavaScript heap pressure,
13
- and a cleaner monorepo release model under [`flash-tecnologia/crab-js`](https://github.com/flash-tecnologia/crab-js).
14
-
15
- The main v4 additions are:
16
-
17
- - Native Web Stream consumers through `client.createWebStreamConsumer()`, with explicit `serial` and `batch` modes.
18
- - Direct batch consumption through `consumer.recvBatch(size, timeoutMs)` for workers that can process chunks.
19
- - Compact native batch streaming internally, reducing native-to-JavaScript marshalling overhead before expanding to the
20
- public `Message[]` API.
21
- - Serial Web Stream prefetching, so message-by-message code still benefits from small native batches.
22
- - Diagnostics-channel events in the core package, with OpenTelemetry tracing and metrics moved to the optional
23
- `kafka-crab-js-otel` package.
24
- - Updated TypeScript exports where runtime enum-like values are now string literal types, avoiding missing runtime
25
- exports in ESM/CommonJS consumers.
26
-
27
- For production adoption, treat v4 as a beta release: validate it with your broker settings, payload sizes, partition
28
- count, commit strategy, and shutdown path before replacing an existing Kafka client.
29
-
30
- ## Why Kafka Crab JS?
31
-
32
- KafkaJS is a strong default for many Node.js services. Kafka Crab JS is for the cases where Kafka throughput, memory
33
- pressure, native batching, or librdkafka behavior start to matter.
34
-
35
- | Need | What Kafka Crab JS Provides |
36
- | ----------------------------------- | ---------------------------------------------------------------------------------------- |
37
- | Higher consumer throughput | Native batch receive paths and Web Stream batch consumers. |
38
- | Lower JavaScript heap pressure | Message fetch and Kafka protocol work happen in Rust/librdkafka before crossing into JS. |
39
- | Predictable Kafka offset handling | `commitMessage()` commits `message.offset + 1`, the value Kafka expects. |
40
- | Production Kafka tuning | Advanced librdkafka settings are passed through with their original names. |
41
- | Node.js ecosystem integration | Direct APIs, Node.js `Readable` streams, native Web Streams, and CommonJS/ESM exports. |
42
- | Observability without core coupling | Diagnostics-channel events power the optional `kafka-crab-js-otel` package. |
43
-
44
- In the local isolated consumer benchmark snapshot, `kafka-crab-js v4 (stream, batch)` reached about
45
- `1.17M messages/sec`, ahead of the measured `@platformatic/kafka` and KafkaJS batch scenarios, while using lower RSS
46
- delta and peak heap than both. Treat benchmark numbers as workload-specific; the full methodology and memory/GC
47
- breakdown are documented in [BENCHMARKS.md](../../BENCHMARKS.md) and [Performance Benchmarks](#performance-benchmarks).
48
-
49
- ## Highlights
50
-
51
- - Native librdkafka client exposed through a TypeScript-friendly API.
52
- - Producer, direct consumer, Node.js `Readable` stream, and v4 native Web Stream consumer APIs.
53
- - High-throughput batch receive APIs for workloads that can process more than one message at a time.
54
- - Compact batch streaming and serial prefetching to reduce native boundary crossings.
55
- - Manual commit helpers that commit `message.offset + 1` correctly.
56
- - Optional diagnostics-channel instrumentation for OpenTelemetry through `kafka-crab-js-otel`.
57
- - Advanced librdkafka settings are forwarded through `configuration` without forcing a custom allowlist.
58
-
59
- ## Requirements
60
-
61
- - Node.js `>= 22`.
62
- - A Kafka broker reachable from the Node.js process.
63
- - No separate librdkafka install is required for the published binaries.
64
-
65
- ## Table Of Contents
66
-
67
- 1. [Version 4](#version-4)
68
- 2. [Installation](#installation)
69
- 3. [Module Usage](#module-usage)
70
- 4. [Quick Start](#quick-start)
71
- 5. [Message Model](#message-model)
72
- 6. [Choosing A Consumer API](#choosing-a-consumer-api)
73
- 7. [Producer API](#producer-api)
74
- 8. [Consumer API](#consumer-api)
75
- 9. [Stream Consumers](#stream-consumers)
76
- 10. [Batching, Backpressure, And Tuning](#batching-backpressure-and-tuning)
77
- 11. [Configuration](#configuration)
78
- 12. [OpenTelemetry](#opentelemetry)
79
- 13. [Performance Benchmarks](#performance-benchmarks)
80
- 14. [Migration Notes](#migration-notes)
81
- 15. [Troubleshooting](#troubleshooting)
82
- 16. [Development](#development)
83
- 17. [License](#license)
84
-
85
- ## Installation
86
-
87
- ```bash
1
+ # kafka-crab-js
2
+
3
+ **Native Kafka performance. A JavaScript API.**
4
+
5
+ Build Kafka services in JavaScript and TypeScript with Rust, NAPI-RS, and librdkafka
6
+ handling the native transport. Publish with delivery confirmations, consume through
7
+ Web Streams or Node.js streams, and process batches without writing native code.
8
+
9
+ [![npm](https://img.shields.io/npm/v/kafka-crab-js)](https://www.npmjs.com/package/kafka-crab-js)
10
+ [![Node.js 24](https://img.shields.io/badge/Node.js-24-339933)](https://nodejs.org/)
11
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
12
+
13
+ [Quick start](#quick-start) · [Benchmarks](#benchmarks) · [Choose your API](#choose-your-api) ·
14
+ [API reference](docs/api.md) · [Examples](../../examples/kafka/README.md) ·
15
+ [Production recommendations](#production-recommendations)
16
+
17
+ ## Why kafka-crab-js
18
+
19
+ Kafka should move your events without consuming your entire JavaScript heap.
20
+ `kafka-crab-js` puts Kafka transport and batch collection in native code while
21
+ keeping application logic in familiar async JavaScript.
22
+
23
+ - **Batch throughput that makes a difference.** The current development snapshot
24
+ delivered **1.66 million messages/s**, **1.98× KafkaJS `eachBatch`**, in the
25
+ [reproducible consumer comparison](../../BENCHMARKS.md).
26
+ - **More room in the JavaScript heap.** That batch scenario peaked at **29.0 MiB
27
+ of JS heap versus KafkaJS's 71.7 MiB**. Native memory still contributes to RSS;
28
+ the comparison reports both.
29
+ - **An API for each processing style.** Use `Message[]` Web Stream batches for
30
+ bulk writes, individual messages for sequential handlers, Node streams for
31
+ pipelines, or `recvBatch()` for your own receive loop.
32
+ - **Explicit delivery and offset control.** Await producer results, commit after
33
+ successful processing, and preserve keys, headers, and tombstones across batching.
34
+ - **librdkafka configuration when you need it.** Configure TLS/SASL, compression,
35
+ idempotent production, fetch behavior, and prefetch through the native client.
36
+ - **Observability without bundling an OTEL SDK into the core.** Diagnostic channels
37
+ integrate with the optional [`kafka-crab-js-otel`](../kafka-crab-js-otel/README.md) package.
38
+
39
+ The measurements above describe this source tree's development build, not the
40
+ currently installed npm release. The
41
+ [artifact fingerprints and methodology](../../BENCHMARKS.md#methodology) identify the tested build.
42
+
43
+ ## Install
44
+
45
+ ```sh
88
46
  npm install kafka-crab-js
89
- ```
90
-
91
- ```bash
47
+ # or
92
48
  pnpm add kafka-crab-js
93
49
  ```
94
50
 
95
- ```bash
96
- yarn add kafka-crab-js
97
- ```
98
-
99
- ## Module Usage
100
-
101
- The package publishes both ESM and CommonJS entry points:
102
-
103
- ```ts
104
- import { KafkaClient } from 'kafka-crab-js'
105
- import type { Message, ProducerRecord } from 'kafka-crab-js'
106
- ```
51
+ Use **Node.js 24**. Prebuilt binaries cover macOS x64/arm64 and Linux x64/arm64
52
+ with glibc or musl. Published binaries include librdkafka; supported installations
53
+ do not need a separate Rust toolchain or librdkafka installation. ESM, CommonJS,
54
+ and TypeScript declarations are included.
107
55
 
108
56
  ```js
109
- const { KafkaClient } = require('kafka-crab-js')
110
- ```
111
-
112
- The public TypeScript types are generated from the native NAPI contract and the JavaScript wrapper. Runtime enum-like
113
- values such as commit modes and security protocols are string literals, not runtime objects:
114
-
115
- ```ts
116
- await consumer.commitMessage(message, 'Sync')
117
-
118
- const client = new KafkaClient({
119
- brokers: 'localhost:9092',
120
- securityProtocol: 'Plaintext',
121
- })
57
+ import { KafkaClient } from "kafka-crab-js";
58
+ // CommonJS: const { KafkaClient } = require('kafka-crab-js')
122
59
  ```
123
60
 
124
- ## Quick Start
61
+ This is a native Node.js client. Web Streams support refers to Node's Web Stream
62
+ API; browser, WASM, and Windows builds are not provided. Documentation on the main
63
+ branch describes the current source; use a matching release tag for older packages.
125
64
 
126
- ### Produce Messages
65
+ ### Upgrading to 5.0
127
66
 
128
- ```ts
129
- import { KafkaClient } from 'kafka-crab-js'
67
+ Version 5.0 requires Node.js 24. The previous release supported Node.js 22 and
68
+ newer; update service runtimes and container images before upgrading.
130
69
 
131
- const client = new KafkaClient({
132
- brokers: 'localhost:9092',
133
- clientId: 'orders-api',
134
- securityProtocol: 'Plaintext',
135
- })
70
+ Producer and consumer entrypoints remain available. Review the stricter
71
+ [commit and shutdown contracts](docs/api.md#commits-and-processing-order): manual
72
+ Async commits require a live event listener, mixed group subscriptions and manual
73
+ assignments are rejected, and Node.js consumer streams require object mode.
74
+ Failed sends expose per-call delivery details through `SendFailureError`; use
75
+ those details when deciding retries after partial delivery.
136
76
 
137
- const producer = client.createProducer()
77
+ ## Quick start
138
78
 
139
- const metadata = await producer.send({
140
- topic: 'orders',
141
- messages: [
142
- {
143
- key: Buffer.from('order-123'),
144
- payload: Buffer.from(JSON.stringify({ id: 'order-123', status: 'created' })),
145
- headers: {
146
- 'content-type': Buffer.from('application/json'),
147
- },
148
- },
149
- ],
150
- })
79
+ Run a Kafka broker and create an `orders` topic before starting these examples.
80
+ Use `KAFKA_BROKERS` for a comma-separated broker list; the examples default to
81
+ `localhost:9092`. Save the following files as `.mjs` and run with Node.js 24.
151
82
 
152
- console.log(metadata)
153
- ```
83
+ ### Publish with confirmation
154
84
 
155
- ### Consume One Message At A Time
85
+ `producer.mjs`:
156
86
 
157
- ```ts
158
- import { KafkaClient } from 'kafka-crab-js'
87
+ ```js
88
+ import { KafkaClient } from "kafka-crab-js";
159
89
 
160
90
  const client = new KafkaClient({
161
- brokers: 'localhost:9092',
162
- clientId: 'orders-worker',
163
- securityProtocol: 'Plaintext',
164
- })
165
-
166
- const consumer = client.createConsumer({
167
- groupId: 'orders-worker',
168
- enableAutoCommit: false,
169
- configuration: {
170
- 'auto.offset.reset': 'earliest',
171
- },
172
- })
173
-
174
- await consumer.subscribe('orders')
175
-
176
- try {
177
- while (true) {
178
- const message = await consumer.recv()
179
- if (!message) {
180
- break
181
- }
182
-
183
- const value = JSON.parse(message.payload.toString('utf8'))
184
- console.log({ value, topic: message.topic, partition: message.partition, offset: message.offset })
185
-
186
- await consumer.commitMessage(message, 'Sync')
187
- }
188
- } finally {
189
- await consumer.disconnect()
190
- }
191
- ```
192
-
193
- ### Consume In Batches
194
-
195
- ```ts
196
- const consumer = client.createConsumer({
197
- groupId: 'orders-batch-worker',
198
- enableAutoCommit: false,
199
- configuration: {
200
- 'auto.offset.reset': 'earliest',
201
- },
202
- })
203
-
204
- await consumer.subscribe('orders')
205
-
206
- try {
207
- while (true) {
208
- const batch = await consumer.recvBatch(500, 50)
209
- if (batch.length === 0) {
210
- continue
211
- }
91
+ brokers: process.env.KAFKA_BROKERS ?? "localhost:9092",
92
+ clientId: "orders-producer",
93
+ });
212
94
 
213
- for (const message of batch) {
214
- await processOrder(message.payload)
215
- await consumer.commitMessage(message, 'Async')
216
- }
217
- }
218
- } finally {
219
- await consumer.disconnect()
220
- }
221
- ```
222
-
223
- ## Message Model
224
-
225
- Messages use `Buffer` values because the Kafka protocol treats keys, payloads, and headers as bytes. Decode them at the
226
- edge of your application instead of assuming text:
227
-
228
- ```ts
229
- import type { Message, MessageProducer, RecordMetadata } from 'kafka-crab-js'
230
-
231
- const outgoing: MessageProducer = {
232
- key: Buffer.from('order-123'),
233
- payload: Buffer.from(JSON.stringify({ id: 'order-123' })),
234
- headers: {
235
- 'content-type': Buffer.from('application/json'),
236
- source: Buffer.from('orders-api'),
237
- },
238
- }
239
-
240
- function decodeJsonMessage(message: Message) {
241
- return JSON.parse(message.payload.toString('utf8')) as unknown
242
- }
243
-
244
- function logDelivery(records: RecordMetadata[]) {
245
- for (const record of records) {
246
- console.log(`${record.topic}[${record.partition}]@${record.offset}`)
247
- }
248
- }
249
- ```
250
-
251
- `Message` values contain the consumed Kafka offset. When committing manually, commit the next offset. Prefer
252
- `commitMessage()` unless you intentionally need to calculate offsets yourself:
253
-
254
- ```ts
255
- await consumer.commitMessage(message, 'Sync')
256
- await consumer.commit(message.topic, message.partition, message.offset + 1, 'Async')
257
- ```
258
-
259
- Keys are optional. An omitted key keeps Kafka producer partitioning semantics for keyless records. An empty
260
- `Buffer.alloc(0)` is still a present key.
261
-
262
- ## Choosing A Consumer API
263
-
264
- | API | Emits | Best For |
265
- | ---------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------ |
266
- | `consumer.recv()` | `Message \| null` | Simple sequential workers and explicit control. |
267
- | `consumer.recvBatch()` | `Message[]` | High-throughput workers that can process chunks. |
268
- | `consumer.recvStream()` | Web `ReadableStream<Message>` | Pull-based Web Stream workflows. |
269
- | `consumer.recvBatchStream()` | Web `ReadableStream<Message[]>` | Native batch streaming with explicit consumer control. |
270
- | `client.createStreamConsumer()` | Node.js `Readable` emitting `Message` | Existing Node stream pipelines. |
271
- | `client.createWebStreamConsumer()` | Web `ReadableStream<Message>` or `ReadableStream<Message[]>` | v4 high-throughput stream consumers. |
272
-
273
- Two details matter for choosing correctly:
274
-
275
- - `createStreamConsumer()` is a Node.js `Readable` compatibility API. Even when `batchSize > 1`, it emits individual
276
- `Message` objects and uses batching internally to reduce native boundary crossings.
277
- - `createWebStreamConsumer()` returns a discriminated object. In serial mode it emits `Message`; in batch mode it emits
278
- `Message[]`. Use this API when your code can process whole batches directly.
279
-
280
- Recommended starting points:
281
-
282
- - Use `consumer.recv()` for straightforward workers where one handler processes one message and then commits.
283
- - Use `consumer.recvBatch()` when the handler can process chunks, flush work in groups, or commit after a batch.
284
- - Use `createStreamConsumer()` when existing code already expects a Node.js `Readable`.
285
- - Use `createWebStreamConsumer()` for new v4 stream code, especially when you can keep batch chunks intact.
286
-
287
- ## Producer API
288
-
289
- Create a producer from a `KafkaClient`:
290
-
291
- ```ts
292
95
  const producer = client.createProducer({
293
- autoFlush: true,
294
- queueTimeout: 5000,
295
96
  configuration: {
296
- 'compression.type': 'lz4',
297
- acks: 'all',
97
+ "enable.idempotence": true,
98
+ acks: "all",
99
+ "compression.type": "lz4",
298
100
  },
299
- })
300
- ```
101
+ });
301
102
 
302
- Send one or more messages:
303
-
304
- ```ts
305
- const records = await producer.send({
306
- topic: 'orders',
103
+ const deliveries = await producer.send({
104
+ topic: "orders",
307
105
  messages: [
308
- { key: Buffer.from('1'), payload: Buffer.from('first') },
309
- { key: Buffer.from('2'), payload: Buffer.from('second') },
106
+ {
107
+ key: Buffer.from("order-42"),
108
+ payload: Buffer.from(
109
+ JSON.stringify({ orderId: "order-42", total: 129.9 }),
110
+ ),
111
+ headers: { "content-type": Buffer.from("application/json") },
112
+ },
310
113
  ],
311
- })
312
- ```
313
-
314
- When `autoFlush` is enabled, `send()` waits for delivery confirmation and returns `RecordMetadata[]`. When `autoFlush`
315
- is disabled, `send()` buffers messages and `flush()` sends pending messages:
114
+ });
316
115
 
317
- ```ts
318
- const producer = client.createProducer({ autoFlush: false })
319
-
320
- await producer.send({
321
- topic: 'orders',
322
- messages: [{ payload: Buffer.from('buffered') }],
323
- })
324
-
325
- const records = await producer.flush()
326
- console.log(records)
116
+ console.log("Delivered:", deliveries);
327
117
  ```
328
118
 
329
- `producer.inFlightCount()` returns the number of messages sent but not yet acknowledged.
330
-
331
- ### Delivery Semantics
119
+ By default, `send()` waits for delivery results and rejects on failure. For
120
+ high-volume publishing, send multiple records together and bound concurrent sends.
121
+ If you opt into `autoFlush: false`, call `flush()` regularly and before shutdown.
122
+ Records can be transmitted before a manual flush; it collects confirmations.
123
+ See [producer delivery semantics](docs/api.md#delivery-and-partial-failures).
332
124
 
333
- `send()` writes records to librdkafka and, with the default `autoFlush: true`, waits for delivery results before
334
- resolving. Delivery metadata can still contain a per-record `error`, so production code should inspect it when failed
335
- records must be retried or reported:
336
-
337
- ```ts
338
- const records = await producer.send({
339
- topic: 'orders',
340
- messages: [{ payload: Buffer.from('created') }],
341
- })
342
-
343
- for (const record of records) {
344
- if (record.error) {
345
- throw new Error(`Kafka delivery failed: ${record.error.message}`)
346
- }
347
- }
348
- ```
125
+ ### Consume batches and commit after processing
349
126
 
350
- With `autoFlush: false`, `send()` returns an empty array and leaves delivery confirmation to the next `flush()` call.
351
- This can improve throughput for bursty producers, but shutdown code must flush before the process exits:
127
+ `consumer.mjs`:
352
128
 
353
- ```ts
354
- import type { RecordMetadata } from 'kafka-crab-js'
355
-
356
- const producer = client.createProducer({ autoFlush: false })
357
-
358
- function assertAllDelivered(records: RecordMetadata[]) {
359
- for (const record of records) {
360
- if (record.error) {
361
- throw new Error(`Kafka delivery failed: ${record.error.message}`)
362
- }
363
- }
364
- }
365
-
366
- try {
367
- await producer.send({ topic: 'orders', messages })
368
- const records = await producer.flush()
369
- assertAllDelivered(records)
370
- } finally {
371
- const remaining = await producer.flush()
372
- assertAllDelivered(remaining)
373
- }
374
- ```
375
-
376
- ## Consumer API
377
-
378
- ### Subscribe
379
-
380
- The simplest subscription accepts a topic string:
381
-
382
- ```ts
383
- await consumer.subscribe('orders')
384
- ```
385
-
386
- Use `TopicPartitionConfig[]` when you need offsets, partition-specific offsets, or topic creation:
387
-
388
- ```ts
389
- await consumer.subscribe([
390
- {
391
- topic: 'orders',
392
- createTopic: true,
393
- numPartitions: 3,
394
- replicas: 1,
395
- allOffsets: { position: 'Beginning' },
396
- },
397
- ])
398
- ```
399
-
400
- For partition-specific offsets:
401
-
402
- ```ts
403
- await consumer.subscribe([
404
- {
405
- topic: 'orders',
406
- partitionOffset: [
407
- {
408
- partition: 0,
409
- offset: { offset: 42 },
410
- },
411
- ],
412
- },
413
- ])
414
- ```
415
-
416
- ### Receive
417
-
418
- `recv()` waits for one message and returns `null` when the consumer is disconnected:
419
-
420
- ```ts
421
- const message = await consumer.recv()
422
- if (message) {
423
- await handleMessage(message)
424
- }
425
- ```
426
-
427
- `recvBatch(size, timeoutMs)` returns up to `size` messages:
428
-
429
- ```ts
430
- const messages = await consumer.recvBatch(1000, 100)
431
- ```
432
-
433
- An empty array means the timeout elapsed without a full batch. It does not mean the consumer is closed.
434
-
435
- ### Commit
436
-
437
- Use `commitMessage()` for normal message processing. It commits `message.offset + 1`, which is the offset Kafka expects:
438
-
439
- ```ts
440
- await consumer.commitMessage(message, 'Sync')
441
- ```
442
-
443
- Use `commit()` when you already calculated the offset:
444
-
445
- ```ts
446
- await consumer.commit(message.topic, message.partition, message.offset + 1, 'Async')
447
- ```
448
-
449
- Commit modes are string literal values:
450
-
451
- ```ts
452
- type CommitMode = 'Sync' | 'Async'
453
- ```
454
-
455
- For at-least-once processing, disable auto commit, process the message successfully, then commit. If processing fails
456
- before the commit, Kafka can redeliver that message after restart or rebalance:
457
-
458
- ```ts
459
- const consumer = client.createConsumer({
460
- groupId: 'orders-worker',
461
- enableAutoCommit: false,
462
- })
463
-
464
- const message = await consumer.recv()
465
- if (message) {
466
- await processOrder(message)
467
- await consumer.commitMessage(message, 'Sync')
468
- }
469
- ```
470
-
471
- `'Sync'` waits for the broker commit response. `'Async'` schedules the commit through librdkafka and returns sooner,
472
- which can be useful in high-throughput workers that tolerate the usual async commit tradeoff.
473
-
474
- ### Pause, Resume, Seek, And Assignment
475
-
476
- ```ts
477
- consumer.pause()
478
- consumer.resume()
479
-
480
- consumer.seek('orders', 0, { position: 'Beginning' })
481
- consumer.seek('orders', 0, { offset: 42 })
482
-
483
- const assignment = consumer.assignment()
484
- const subscription = consumer.getSubscription()
485
- ```
486
-
487
- Consumer events are available through `onEvents()`:
488
-
489
- ```ts
490
- consumer.onEvents((error, event) => {
491
- if (error) {
492
- console.error(error)
493
- return
494
- }
495
-
496
- console.log(event.name, event.payload)
497
- })
498
- ```
499
-
500
- Event names are currently `PreRebalance`, `PostRebalance`, and `CommitCallback`. The payload includes the topic-partition
501
- list associated with the event and may include an `error` string for commit or rebalance failures.
502
-
503
- ### Cleanup
504
-
505
- Always disconnect direct consumers:
506
-
507
- ```ts
508
- try {
509
- await consumer.subscribe('orders')
510
- // consume...
511
- } finally {
512
- await consumer.disconnect()
513
- }
514
- ```
515
-
516
- ## Stream Consumers
517
-
518
- ### Node.js Readable Stream
129
+ ```js
130
+ import { KafkaClient } from "kafka-crab-js";
519
131
 
520
- Use `createStreamConsumer()` when integrating with Node.js stream tooling. It emits `Message` objects.
132
+ const client = new KafkaClient({
133
+ brokers: process.env.KAFKA_BROKERS ?? "localhost:9092",
134
+ clientId: "orders-consumer",
135
+ });
521
136
 
522
- ```ts
523
- const stream = client.createStreamConsumer({
524
- groupId: 'orders-stream',
137
+ const { consumer, stream } = client.createWebStreamConsumer({
138
+ groupId: "orders-workers",
525
139
  enableAutoCommit: false,
526
- batchSize: 256,
527
- batchTimeout: 50,
140
+ batchSize: 64,
141
+ batchTimeout: 5,
528
142
  configuration: {
529
- 'auto.offset.reset': 'earliest',
143
+ "enable.auto.offset.store": false,
144
+ "auto.offset.reset": "earliest",
530
145
  },
531
- })
532
-
533
- await stream.subscribe('orders')
534
-
535
- stream.on('data', async (message) => {
536
- try {
537
- await processOrder(message.payload)
538
- await stream.commitMessage(message, 'Async')
539
- } catch (error) {
540
- stream.destroy(error instanceof Error ? error : new Error(String(error)))
541
- }
542
- })
543
-
544
- stream.on('error', (error) => {
545
- console.error('stream error', error)
546
- })
547
-
548
- stream.on('close', () => {
549
- console.log('stream closed')
550
- })
551
- ```
552
-
553
- Destroy stream consumers instead of calling `disconnect()` directly. The stream cleanup path cancels the source reader,
554
- unsubscribes, and disconnects the native consumer:
555
-
556
- ```ts
557
- stream.destroy()
558
- ```
559
-
560
- ### Native Web Stream
561
-
562
- Use `createWebStreamConsumer()` when you want a Web Stream and clear serial/batch typing.
563
-
564
- Serial mode is selected when `batchSize` is omitted, `0`, or `1`:
565
-
566
- ```ts
567
- const webConsumer = client.createWebStreamConsumer({
568
- groupId: 'orders-web-serial',
569
- serialPrefetchSize: 64,
570
- serialPrefetchTimeout: 1,
571
- configuration: {
572
- 'auto.offset.reset': 'earliest',
573
- },
574
- })
575
-
576
- await webConsumer.consumer.subscribe('orders')
146
+ });
577
147
 
578
- const reader = webConsumer.stream.getReader()
148
+ const reader = stream.getReader();
149
+ const stop = () => {
150
+ void reader.cancel("shutdown").catch(console.error);
151
+ };
152
+ process.once("SIGINT", stop);
153
+ process.once("SIGTERM", stop);
579
154
 
580
155
  try {
581
- const { value: message, done } = await reader.read()
582
- if (!done && message) {
583
- await processOrder(message.payload)
584
- await webConsumer.consumer.commitMessage(message, 'Async')
585
- }
586
- } finally {
587
- await reader.cancel()
588
- await webConsumer.consumer.disconnect()
589
- }
590
- ```
591
-
592
- Batch mode is selected with `batchSize > 1` and emits `Message[]` chunks:
156
+ await consumer.subscribe("orders");
593
157
 
594
- ```ts
595
- const webConsumer = client.createWebStreamConsumer({
596
- groupId: 'orders-web-batch',
597
- batchSize: 1024,
598
- batchTimeout: 10,
599
- configuration: {
600
- 'auto.offset.reset': 'earliest',
601
- },
602
- })
603
-
604
- if (webConsumer.mode === 'batch') {
605
- await webConsumer.consumer.subscribe('orders')
606
- const reader = webConsumer.stream.getReader()
607
-
608
- try {
609
- const { value: batch, done } = await reader.read()
610
- if (!done && batch) {
611
- for (const message of batch) {
612
- await processOrder(message.payload)
158
+ while (true) {
159
+ const { value: messages, done } = await reader.read();
160
+ if (done) break;
161
+
162
+ const processed = new Map();
163
+ for (const message of messages) {
164
+ // Replace this with an awaited, idempotent database write or handler.
165
+ if (message.isTombstone) {
166
+ console.log("Delete:", message.key?.toString());
167
+ } else {
168
+ console.log("Order:", JSON.parse(message.payload.toString()));
613
169
  }
170
+ processed.set(
171
+ JSON.stringify([message.topic, message.partition]),
172
+ message,
173
+ );
614
174
  }
615
- } finally {
616
- await reader.cancel()
617
- await webConsumer.consumer.disconnect()
618
- }
619
- }
620
- ```
621
-
622
- ## Batching, Backpressure, And Tuning
623
-
624
- The main throughput lever is how many messages cross the native-to-JavaScript boundary per call.
625
-
626
- | Knob | Applies To | Effect |
627
- | ----------------------- | ----------------------------------------------- | ---------------------------------------------------------------------- |
628
- | `recvBatch(size, ms)` | Direct consumer | Pulls up to `size` messages, waiting up to `ms` for data. |
629
- | `batchSize` | Node stream and Web Stream batch modes | Sets the maximum native batch size used by the stream. |
630
- | `batchTimeout` | Node stream and Web Stream batch modes | Bounds how long a partially filled batch waits before being emitted. |
631
- | `serialPrefetchSize` | `createWebStreamConsumer()` serial mode | Pulls small native batches and flattens them into individual messages. |
632
- | `serialPrefetchTimeout` | `createWebStreamConsumer()` serial mode | Timeout for the serial-mode prefetch batch. |
633
- | `streamOptions` | `createStreamConsumer()` Node.js `Readable` API | Lets Node stream `highWaterMark` participate in backpressure. |
634
-
635
- Use larger batches when throughput matters and your handler can process arrays efficiently. Use smaller batches and
636
- shorter timeouts when tail latency matters more than total throughput. Very large batches can increase RSS because more
637
- payloads, keys, headers, and metadata must be retained at once.
638
-
639
- The v4 Web Stream serial path defaults to `serialPrefetchSize: 64` and `serialPrefetchTimeout: 1`. This keeps the public
640
- serial API message-by-message while reducing native boundary crossings. Batch mode defaults `batchTimeout` to `1000`
641
- milliseconds when it is omitted.
642
-
643
- For broker fetch tuning, pass librdkafka settings through `configuration`. Keep these values aligned with your expected
644
- message size:
645
-
646
- ```ts
647
- const consumer = client.createConsumer({
648
- groupId: 'orders-worker',
649
- configuration: {
650
- 'fetch.min.bytes': 1,
651
- 'fetch.wait.max.ms': 10,
652
- 'fetch.max.bytes': 1_048_576,
653
- 'max.partition.fetch.bytes': 1_048_576,
654
- 'message.max.bytes': 1_000_000,
655
- },
656
- })
657
- ```
658
-
659
- `fetch.max.bytes` must be greater than or equal to `message.max.bytes`. If this invariant is broken, librdkafka rejects
660
- the consumer configuration before the benchmark or application starts.
661
-
662
- ## Configuration
663
-
664
- ### KafkaClient
665
-
666
- ```ts
667
- const client = new KafkaClient({
668
- brokers: 'localhost:9092',
669
- clientId: 'orders-service',
670
- securityProtocol: 'Plaintext',
671
- logLevel: 'info',
672
- brokerAddressFamily: 'v4',
673
- diagnostics: true,
674
- configuration: {
675
- 'socket.keepalive.enable': true,
676
- },
677
- })
678
- ```
679
-
680
- | Option | Type | Default | Description |
681
- | --------------------- | ------------------------------------------------------ | --------- | ------------------------------------------------------------------------- |
682
- | `brokers` | `string` | required | Comma-separated broker list, for example `localhost:9092,localhost:9093`. |
683
- | `clientId` | `string` | `rdkafka` | Client identifier sent to Kafka. |
684
- | `securityProtocol` | `'Plaintext' \| 'Ssl' \| 'SaslPlaintext' \| 'SaslSsl'` | | Security protocol. |
685
- | `logLevel` | `string` | `error` | librdkafka log level. |
686
- | `brokerAddressFamily` | `string` | `v4` | Address family hint such as `v4` or `any`. |
687
- | `diagnostics` | `boolean` | `true` | Enables diagnostic-channel events used by `kafka-crab-js-otel`. |
688
- | `configuration` | `Record<string, any>` | | Additional librdkafka client settings. |
689
-
690
- `configuration` is passed through to librdkafka after values are converted to strings. Use the original librdkafka
691
- property names, for example `sasl.mechanism`, `queued.min.messages`, or `fetch.wait.max.ms`.
692
-
693
- Common connection examples:
694
-
695
- ```ts
696
- const localClient = new KafkaClient({
697
- brokers: 'localhost:9092',
698
- clientId: 'orders-local',
699
- securityProtocol: 'Plaintext',
700
- brokerAddressFamily: 'v4',
701
- })
702
- ```
703
-
704
- ```ts
705
- const saslClient = new KafkaClient({
706
- brokers: process.env.KAFKA_BROKERS!,
707
- clientId: 'orders-worker',
708
- securityProtocol: 'SaslSsl',
709
- configuration: {
710
- 'sasl.mechanism': 'PLAIN',
711
- 'sasl.username': process.env.KAFKA_USERNAME!,
712
- 'sasl.password': process.env.KAFKA_PASSWORD!,
713
- },
714
- })
715
- ```
716
-
717
- ### ConsumerConfiguration
718
-
719
- ```ts
720
- const consumer = client.createConsumer({
721
- groupId: 'orders-worker',
722
- enableAutoCommit: false,
723
- fetchMetadataTimeout: 5000,
724
- configuration: {
725
- 'auto.offset.reset': 'earliest',
726
- 'enable.auto.commit': false,
727
- 'fetch.min.bytes': 1,
728
- },
729
- })
730
- ```
731
-
732
- | Option | Type | Description |
733
- | ---------------------- | --------------------- | ------------------------------------------ |
734
- | `groupId` | `string` | Kafka consumer group id. |
735
- | `enableAutoCommit` | `boolean` | Convenience flag for auto commit behavior. |
736
- | `fetchMetadataTimeout` | `number` | Metadata fetch timeout in milliseconds. |
737
- | `configuration` | `Record<string, any>` | Additional librdkafka consumer settings. |
738
-
739
- `enableAutoCommit` is a convenience option that sets librdkafka `enable.auto.commit`. You can also pass
740
- `'enable.auto.commit'` in `configuration`, but using both with different values makes the intent hard to read.
741
-
742
- For new services that need explicit processing guarantees, start with manual commits:
743
-
744
- ```ts
745
- const consumer = client.createConsumer({
746
- groupId: 'orders-worker',
747
- enableAutoCommit: false,
748
- configuration: {
749
- 'auto.offset.reset': 'earliest',
750
- 'enable.partition.eof': false,
751
- },
752
- })
753
- ```
754
-
755
- ### ProducerConfiguration
756
-
757
- ```ts
758
- const producer = client.createProducer({
759
- autoFlush: true,
760
- queueTimeout: 5000,
761
- configuration: {
762
- 'compression.type': 'lz4',
763
- },
764
- })
765
- ```
766
-
767
- | Option | Type | Description |
768
- | --------------- | --------------------- | ------------------------------------------------------- |
769
- | `autoFlush` | `boolean` | When enabled, `send()` waits for delivery confirmation. |
770
- | `queueTimeout` | `number` | Queue timeout in milliseconds. |
771
- | `configuration` | `Record<string, any>` | Additional librdkafka producer settings. |
772
-
773
- Producer settings are workload-specific. A low-latency producer might prefer smaller buffering windows, while a
774
- throughput-oriented producer can allow librdkafka to coalesce more work:
775
-
776
- ```ts
777
- const producer = client.createProducer({
778
- autoFlush: false,
779
- queueTimeout: 5000,
780
- configuration: {
781
- acks: 'all',
782
- 'compression.type': 'lz4',
783
- 'queue.buffering.max.ms': 10,
784
- 'queue.buffering.max.messages': 100000,
785
- },
786
- })
787
- ```
788
-
789
- ### TopicPartitionConfig
790
-
791
- | Option | Type | Description |
792
- | ----------------- | ------------------- | ------------------------------------------------ |
793
- | `topic` | `string` | Topic name. |
794
- | `allOffsets` | `OffsetModel` | Offset model applied to all assigned partitions. |
795
- | `partitionOffset` | `PartitionOffset[]` | Partition-specific offset model. |
796
- | `createTopic` | `boolean` | Create the topic before subscribing. |
797
- | `numPartitions` | `number` | Partition count when creating a topic. |
798
- | `replicas` | `number` | Replica count when creating a topic. |
799
-
800
- Offset models:
801
-
802
- ```ts
803
- {
804
- position: 'Beginning'
805
- }
806
- {
807
- position: 'End'
808
- }
809
- {
810
- position: 'Stored'
811
- }
812
- {
813
- position: 'Invalid'
814
- }
815
- {
816
- offset: 42
817
- }
818
- ```
819
-
820
- ### Type-Only Literals
821
-
822
- In v4, these names are TypeScript-only exports:
823
-
824
- ```ts
825
- import type { CommitMode, KafkaEventName, PartitionPosition, SecurityProtocol } from 'kafka-crab-js'
826
- ```
827
-
828
- Use literal values at runtime:
829
-
830
- ```ts
831
- const commitMode: CommitMode = 'Sync'
832
- const securityProtocol: SecurityProtocol = 'Plaintext'
833
- ```
834
-
835
- ## OpenTelemetry
836
-
837
- OpenTelemetry support lives in the separate `kafka-crab-js-otel` package. The core package emits diagnostic-channel
838
- events when `diagnostics !== false`; the OTEL package subscribes to those events.
839
-
840
- Install the instrumentation package and OpenTelemetry dependencies:
841
-
842
- ```bash
843
- npm install kafka-crab-js-otel @opentelemetry/api @opentelemetry/sdk-node
844
- ```
845
-
846
- Enable instrumentation before creating `KafkaClient`:
847
-
848
- ```ts
849
- import { KafkaClient } from 'kafka-crab-js'
850
- import { enableOtelInstrumentation, endSpan } from 'kafka-crab-js-otel'
851
-
852
- enableOtelInstrumentation({
853
- metrics: { enabled: true },
854
- })
855
-
856
- const client = new KafkaClient({
857
- brokers: 'localhost:9092',
858
- clientId: 'orders-worker',
859
- diagnostics: true,
860
- })
861
175
 
862
- const consumer = client.createConsumer({ groupId: 'orders-worker' })
863
- await consumer.subscribe('orders')
864
-
865
- const message = await consumer.recv()
866
- if (message) {
176
+ // Commit once per topic/partition, only after the entire batch succeeds.
177
+ for (const message of processed.values()) {
178
+ await consumer.commitMessage(message, "Sync");
179
+ }
180
+ }
181
+ } finally {
182
+ process.off("SIGINT", stop);
183
+ process.off("SIGTERM", stop);
867
184
  try {
868
- await processOrder(message.payload)
185
+ await reader.cancel();
869
186
  } finally {
870
- endSpan(message)
187
+ reader.releaseLock();
188
+ await consumer.disconnect();
871
189
  }
872
190
  }
873
191
  ```
874
192
 
875
- See the [OpenTelemetry package README](../kafka-crab-js-otel/README.md) for full tracing and metrics setup.
876
-
877
- ## Performance Benchmarks
878
-
879
- The repository includes a benchmark suite that compares `kafka-crab-js` with KafkaJS and `@platformatic/kafka`. The
880
- latest captured run in [BENCHMARKS.md](../../BENCHMARKS.md) was recorded on May 25, 2026 on a MacBook Pro with Apple M1,
881
- Node.js `v24.16.0`, and a local three-broker `apache/kafka:4.0.0` cluster.
882
-
883
- From the repository root:
884
-
885
- ```bash
886
- podman compose up -d
887
-
888
- cd benchmarks/kafka
889
-
890
- # Set up benchmark data. Requires Kafka running locally.
891
- KAFKA_BROKERS=127.0.0.1:9092,127.0.0.1:9093,127.0.0.1:9094 vp run setup:consumer
892
-
893
- # Run the default isolated-process memory benchmark.
894
- KAFKA_BROKERS=127.0.0.1:9092,127.0.0.1:9093,127.0.0.1:9094 vp run benchmark
895
- ```
896
-
897
- The default benchmark runs one child Node.js process per selected scenario. This avoids carrying V8 heap pages, native
898
- allocator arenas, sockets, librdkafka state, and Kafka metadata from one client into the next client's measurement.
899
-
900
- ### Consumer Benchmark Snapshot
901
-
902
- The captured run below is equivalent to:
903
-
904
- ```bash
905
- cd benchmarks/kafka
906
- KAFKA_BROKERS=127.0.0.1:9092,127.0.0.1:9093,127.0.0.1:9094 vp run benchmark
907
- ```
908
-
909
- The run uses the default isolated-process lifecycle-memory benchmark mode.
910
-
911
- ![Consumer benchmark snapshot](./assets/consumer-benchmark-snapshot.svg)
912
-
913
- #### Throughput
914
-
915
- | Rank | Scenario | Result | Relative |
916
- | ---: | ----------------------------------- | --------------------: | -------: |
917
- | 1 | `kafka-crab-js v4 (stream, batch)` | `1,167,396.71 op/sec` | `100.0%` |
918
- | 2 | `@platformatic/kafka` | `653,355.35 op/sec` | `56.0%` |
919
- | 3 | `kafka-crab-js v4 (stream, serial)` | `558,968.71 op/sec` | `47.9%` |
920
- | 4 | `KafkaJS (eachBatch)` | `478,411.67 op/sec` | `41.0%` |
921
- | 5 | `KafkaJS (eachMessage)` | `431,519.93 op/sec` | `37.0%` |
922
- | 6 | `KafkaJS (eachMessage, concurrent)` | `427,265.29 op/sec` | `36.6%` |
923
-
924
- `kafka-crab-js v4 (stream, batch)` is the fastest scenario in this run. It is about `78.7%` faster than
925
- `@platformatic/kafka` and about `144.0%` faster than `KafkaJS (eachBatch)`. Its tolerance was `+/- 4.40%`, so the exact
926
- gap should still be treated as a benchmark snapshot rather than a universal constant.
927
-
928
- For message-by-message consumption, `kafka-crab-js v4 (stream, serial)` is close to KafkaJS throughput while using much
929
- less lifecycle memory. In this run it is about `29.5%` faster than `KafkaJS (eachMessage)` and about `30.8%` faster
930
- than the concurrent KafkaJS `eachMessage` scenario.
931
-
932
- #### Memory And GC
933
-
934
- | Scenario | RSS delta | Peak heap | GC time | GC share | Notes |
935
- | ----------------------------------- | ----------: | ---------: | ---------: | -------: | ------------------------------------------ |
936
- | `kafka-crab-js v4 (stream, serial)` | `64.7 MiB` | `14.6 MiB` | `30.46 ms` | `3.41%` | Lowest lifecycle RSS and heap. |
937
- | `kafka-crab-js v4 (stream, batch)` | `106.0 MiB` | `29.5 MiB` | `21.69 ms` | `5.06%` | Best throughput, efficiency, and GC time. |
938
- | `KafkaJS (eachMessage, concurrent)` | `178.3 MiB` | `72.2 MiB` | `83.77 ms` | `7.16%` | Concurrency did not improve this workload. |
939
- | `KafkaJS (eachMessage)` | `186.3 MiB` | `91.6 MiB` | `90.15 ms` | `7.78%` | Highest peak heap and max GC pause. |
940
- | `KafkaJS (eachBatch)` | `193.4 MiB` | `83.8 MiB` | `63.05 ms` | `6.03%` | KafkaJS batch baseline. |
941
- | `@platformatic/kafka` | `216.0 MiB` | `87.4 MiB` | `74.27 ms` | `9.71%` | Fastest non-crab competitor. |
942
-
943
- Lifecycle memory includes module loading, client creation, subscription, consumption, cleanup, and retained RSS after
944
- cleanup. GC metrics use the narrower first-to-last-message window used for throughput.
945
-
946
- The most important memory result is efficiency, not just raw RSS. `kafka-crab-js v4 (stream, batch)` delivered
947
- `11016 op/sec/MiB`, the best score in the run. `kafka-crab-js v4 (stream, serial)` delivered `8635 op/sec/MiB`, ahead
948
- of `@platformatic/kafka` at `3025 op/sec/MiB` and `KafkaJS (eachBatch)` at `2474 op/sec/MiB`.
949
-
950
- ### Benchmark Interpretation
951
-
952
- - Use `kafka-crab-js v4 (stream, batch)` when raw throughput or memory efficiency is the priority.
953
- - Use `kafka-crab-js v4 (stream, serial)` when message-by-message processing needs the lowest lifecycle RSS and heap
954
- pressure.
955
- - Compare batch scenarios with batch scenarios and message scenarios with message scenarios.
956
- - `@platformatic/kafka` is the strongest non-crab throughput competitor in this run, but it used more RSS, heap, and GC
957
- time than both v4 scenarios.
958
- - KafkaJS `eachMessage` concurrency did not help this workload.
959
-
960
- ### Benchmark Configuration
961
-
962
- Captured benchmark settings:
963
-
964
- ```bash
965
- BENCHMARK_ITERATIONS=100000
966
- BENCHMARK_RUNS=5
967
- BENCHMARK_FETCH_MIN_BYTES=1
968
- BENCHMARK_FETCH_WAIT_MS=10
969
- BENCHMARK_MAX_BYTES=2048
970
- BENCHMARK_BATCH_SIZE=4096
971
- BENCHMARK_BATCH_TIMEOUT_MS=2
972
- BENCHMARK_KAFKAJS_EACH_MESSAGE_CONCURRENCY=3
973
- BENCHMARK_MEMORY=1
974
- BENCHMARK_MEMORY_SAMPLE_MS=100
975
- BENCHMARK_MEMORY_SETTLE_MS=100
976
- BENCHMARK_TOPIC=benchmarks
977
- BENCHMARK_PARTITIONS=3
978
- ```
979
-
980
- See [BENCHMARKS.md](../../BENCHMARKS.md) for the latest captured run and the
981
- [benchmark README](../../benchmarks/kafka/README.md) for the full benchmark methodology and environment variables.
982
-
983
- ## Migration Notes
984
-
985
- ### v4 Type-Only Runtime Exports
986
-
987
- `CommitMode`, `KafkaEventName`, `PartitionPosition`, and `SecurityProtocol` are no longer runtime exports from
988
- `kafka-crab-js`.
989
-
990
- Before:
991
-
992
- ```ts
993
- import { CommitMode, SecurityProtocol } from 'kafka-crab-js'
994
- ```
995
-
996
- After:
997
-
998
- ```ts
999
- import type { CommitMode, SecurityProtocol } from 'kafka-crab-js'
1000
-
1001
- const commitMode: CommitMode = 'Sync'
1002
- const securityProtocol: SecurityProtocol = 'Plaintext'
1003
- ```
1004
-
1005
- ### v3 OpenTelemetry Split
1006
-
1007
- OpenTelemetry instrumentation moved from the core package to `kafka-crab-js-otel` in v3. This keeps the core package
1008
- small and makes tracing/metrics opt-in.
193
+ Run `node consumer.mjs` and then `node producer.mjs` in another terminal. Consumers
194
+ sharing a group split assigned partitions; use another group for an independent
195
+ subscription. `earliest` applies when there is no valid committed offset.
196
+
197
+ A failed handler prevents that batch's commits, so records can be replayed. Make
198
+ side effects idempotent and never commit past unfinished work in the same partition.
199
+ The signal handler cancels future reads while the already handed-out batch finishes;
200
+ cancellation can abandon prefetched work. See [shutdown semantics](docs/api.md#position-flow-control-and-shutdown)
201
+ when the application needs to drain collected work instead.
202
+
203
+ ## Choose your API
204
+
205
+ | Your workload | Start with | Delivered to your code |
206
+ | ------------------------------------------------- | ------------------------------------------------------------------ | ---------------------------------------------- |
207
+ | Bulk database writes, ingestion, analytics | `createWebStreamConsumer({ batchSize: 64, batchTimeout: 5, ... })` | `Message[]` per read |
208
+ | Sequential business logic, message-level handling | `createWebStreamConsumer({ groupId, ... })` | One `Message` per read, with internal prefetch |
209
+ | Existing Node.js pipelines | `createStreamConsumer({ groupId, batchSize: 64, ... })` | Individual `Message` objects in object mode |
210
+ | A custom polling or scheduling loop | `createConsumer()` + `recvBatch(size, timeoutMs)` | Up to `size` messages per call |
211
+ | One direct blocking receive | `createConsumer()` + `recv()` | One message, or `null` after disconnect |
212
+
213
+ Start with a batch size of **64 and a 5 ms timeout** for evaluation, then measure
214
+ with your payload sizes and handler. Larger batches amortize per-delivery work;
215
+ smaller batches reduce collection delay and the amount of work held by the handler.
216
+ The throughput comparison uses 4,096/2 ms for its small-message scenario, which is
217
+ not a universal production preset.
218
+
219
+ For serial Web Streams, `serialPrefetchSize` and `serialPrefetchTimeout` control
220
+ internal collection without changing the one-message public interface. A Node
221
+ batch stream still emits individual messages; choose the Web batch API when your
222
+ application needs arrays. Await handlers with a reader loop, async iteration, or
223
+ a pipeline sink so downstream processing applies backpressure.
224
+
225
+ ## Benchmarks
226
+
227
+ **September 12, 2026 · Apple M4 · Node 24.20.0 · local Kafka · 120 runs per scenario.**
228
+ Two full runs used opposite crab pair orders, fresh processes per block, explicit
229
+ warmup, and all samples. Throughput is total measured messages / total measured
230
+ time. Heap and RSS are maximum sampled lifecycle values across four processes.
231
+
232
+ | Consumer | Interface | Messages/s | Peak JS heap | Peak RSS |
233
+ | ----------------------------- | ---------------------------- | ------------: | -----------: | ------------: |
234
+ | **kafka-crab-js development** | **Web Stream batch** | **1,663,382** | **29.0 MiB** | **266.3 MiB** |
235
+ | @platformatic/kafka 2.11.0 | Message stream | 998,122 | 87.0 MiB | 256.7 MiB |
236
+ | **kafka-crab-js development** | **Web Stream serial** | **915,664** | **21.9 MiB** | **178.9 MiB** |
237
+ | KafkaJS 2.2.4 | `eachBatch` | 841,194 | 71.7 MiB | 238.9 MiB |
238
+ | KafkaJS 2.2.4 | `eachMessage` | 655,365 | 77.8 MiB | 245.6 MiB |
239
+ | KafkaJS 2.2.4 | `eachMessage`, concurrency 3 | 646,613 | 83.2 MiB | 246.7 MiB |
240
+
241
+ For this workload, current batch throughput was **1.98× KafkaJS `eachBatch`**;
242
+ current serial was **1.40× KafkaJS `eachMessage`**. Platformatic's message stream
243
+ was faster than crab serial. Crab batch used less JS heap than the measured JS clients
244
+ but more total RSS; native allocations are outside the JS heap.
245
+
246
+ These are consumer delivery rates for small, already published messages, without
247
+ application processing, manual commit costs, or end-to-end producer latency.
248
+ They are not a universal ranking. The topic has three partitions, two nonempty;
249
+ KafkaJS concurrency 3 therefore does not imply three active message partitions.
250
+ Crab uses its default fetch queue backoff of 20 ms. Cluster observations and other
251
+ measurement limits are recorded in the full report.
252
+
253
+ [Full results, percentiles, configuration, and reproduction](../../BENCHMARKS.md) ·
254
+ [Comparison with the previous release](../../BENCHMARKS.md#comparison-with-the-previous-release) ·
255
+ [Portable evidence: all 960 measurements](docs/rfc/review/evidence/consumer-comparison-2026-09-12.json)
256
+
257
+ ## How it compares
258
+
259
+ Choose the client that matches your processing model and deployment constraints.
260
+
261
+ | Decision | kafka-crab-js | KafkaJS | @platformatic/kafka |
262
+ | -------------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------- | --------------------------------------------------------- |
263
+ | Implementation | Rust + NAPI-RS + librdkafka | JavaScript | TypeScript/JavaScript |
264
+ | Consumer style | Direct receive, Node streams, serial/batch Web Streams | `eachMessage` and `eachBatch` callbacks | Node streams and events |
265
+ | Best reason to evaluate it | Native batch throughput, explicit delivery/offset control, librdkafka tuning | JavaScript-only deployment and callback-oriented processing | JavaScript-only deployment and stream-oriented processing |
266
+ | Configuration model | Typed wrapper plus librdkafka properties | KafkaJS options | Platformatic options |
267
+ | Installation consideration | Prebuilt native targets, Node 24 | No native addon | No native addon |
268
+
269
+ Competitor API descriptions come from the official
270
+ [KafkaJS documentation](https://kafka.js.org/docs/consuming) and
271
+ [Platformatic repository](https://github.com/platformatic/kafka). The comparison
272
+ covers these interfaces, not every feature or every client in the Kafka ecosystem.
273
+
274
+ `kafka-crab-js` is a strong fit for ingestion workers, event-driven services, and
275
+ bulk processing where you can benefit from native batch delivery. If you need Kafka transactions, a built-in schema registry client, or other
276
+ administrative operations beyond record deletion, account for the current public
277
+ API's scope before migrating.
278
+
279
+ ### Coming from KafkaJS
280
+
281
+ | KafkaJS concept | kafka-crab-js equivalent |
282
+ | ------------------------------------------ | -------------------------------------------------------------- |
283
+ | `new Kafka({ brokers: [...] })` | `new KafkaClient({ brokers: 'host:9092,...' })` |
284
+ | `kafka.producer()` | `client.createProducer()` |
285
+ | `producer.send({ messages: [{ value }] })` | `producer.send({ messages: [{ payload: Buffer.from(...) }] })` |
286
+ | `eachMessage` | Serial Web Stream + an awaited message handler |
287
+ | `eachBatch` | Batch Web Stream + an awaited batch handler |
288
+ | Commit after successful work | `consumer.commitMessage(message, 'Sync')` |
289
+
290
+ This is an API migration, not an import-only replacement. Review message encoding,
291
+ partition ordering, commit policy, retries, and shutdown. Start by replaying a
292
+ representative topic into an independent group and compare throughput, latency,
293
+ RSS, and verified processing outcomes. The [API reference](docs/api.md) describes
294
+ individual contracts and partial-failure behavior.
295
+
296
+ ## Production recommendations
297
+
298
+ **Commit after the work succeeds.** Disable automatic commits/offset storage when
299
+ application completion defines delivery. Commit the last successfully processed
300
+ message per topic/partition. Keep side effects idempotent so replay is safe. For
301
+ Async commits, observe `CommitCallback`; resolving the scheduling promise is not
302
+ broker confirmation.
303
+
304
+ **Budget memory across the entire process.** The current native batch queue's
305
+ 32 MiB accounted-byte budget includes headers and keys, with an oversized-batch
306
+ exception. It does not cap RSS or librdkafka's separate prefetch queues. Bound
307
+ application concurrency and retained messages, and monitor RSS alongside JS heap.
308
+
309
+ **Use a smaller prefetch profile for large headers and reader stalls.** Evaluate
310
+ these consumer properties against your own workload:
1009
311
 
1010
- ## Troubleshooting
1011
-
1012
- ### The Consumer Does Not Receive Existing Messages
1013
-
1014
- Use an offset reset policy and a fresh consumer group:
1015
-
1016
- ```ts
1017
- const consumer = client.createConsumer({
1018
- groupId: `orders-worker-${Date.now()}`,
1019
- configuration: {
1020
- 'auto.offset.reset': 'earliest',
1021
- },
1022
- })
1023
- ```
1024
-
1025
- Or subscribe with an explicit starting position:
1026
-
1027
- ```ts
1028
- await consumer.subscribe([{ topic: 'orders', allOffsets: { position: 'Beginning' } }])
1029
- ```
1030
-
1031
- ### The Process Hangs On Shutdown
1032
-
1033
- Direct consumers should call `disconnect()`:
1034
-
1035
- ```ts
1036
- await consumer.disconnect()
1037
- ```
1038
-
1039
- Node stream consumers should call `destroy()`:
1040
-
1041
- ```ts
1042
- stream.destroy()
1043
- ```
1044
-
1045
- Web stream consumers should cancel the reader and disconnect the underlying consumer:
1046
-
1047
- ```ts
1048
- await reader.cancel()
1049
- await webConsumer.consumer.disconnect()
1050
- ```
1051
-
1052
- ### Localhost Resolves To IPv6 But Kafka Listens On IPv4
1053
-
1054
- Set `brokerAddressFamily: 'v4'`:
1055
-
1056
- ```ts
1057
- const client = new KafkaClient({
1058
- brokers: 'localhost:9092',
1059
- brokerAddressFamily: 'v4',
1060
- })
1061
- ```
1062
-
1063
- ### Need Advanced Kafka Settings
1064
-
1065
- Pass librdkafka settings through `configuration`:
1066
-
1067
- ```ts
1068
- const client = new KafkaClient({
1069
- brokers: 'localhost:9092',
1070
- configuration: {
1071
- 'socket.keepalive.enable': true,
1072
- 'metadata.max.age.ms': 300000,
1073
- },
1074
- })
1075
- ```
1076
-
1077
- Consumer and producer configuration objects also accept their own `configuration` maps.
1078
-
1079
- ### `fetch.max.bytes` Must Be At Least `message.max.bytes`
1080
-
1081
- librdkafka validates fetch limits at consumer creation time. If you tune fetch sizes for benchmarks or production and
1082
- set `message.max.bytes` higher than `fetch.max.bytes`, the consumer will fail to start with an error similar to:
1083
-
1084
- ```text
1085
- `fetch.max.bytes` must be >= `message.max.bytes`
1086
- ```
1087
-
1088
- Keep the fetch caps aligned:
1089
-
1090
- ```ts
1091
- const consumer = client.createConsumer({
1092
- groupId: 'orders-worker',
1093
- configuration: {
1094
- 'message.max.bytes': 1_000_000,
1095
- 'fetch.max.bytes': 1_048_576,
1096
- 'max.partition.fetch.bytes': 1_048_576,
1097
- },
1098
- })
1099
- ```
1100
-
1101
- ### Benchmark Numbers Move Between Runs
1102
-
1103
- Kafka benchmarks are sensitive to CPU frequency, power mode, broker state, topic data already in page cache, and V8 heap
1104
- history. The benchmark package defaults to isolated child processes for memory mode to reduce cross-scenario
1105
- contamination, but you should still compare multiple runs and focus on large, repeatable gaps.
1106
-
1107
- ## Development
312
+ ```js
313
+ const configuration = {
314
+ "queued.max.messages.kbytes": 65536,
315
+ "queued.min.messages": 256,
316
+ "fetch.max.bytes": 8 * 1024 * 1024,
317
+ "max.partition.fetch.bytes": 1024 * 1024,
318
+ "fetch.queue.backoff.ms": 20,
319
+ };
320
+ ```
321
+
322
+ In the [concurrent workload](docs/rfc/review/performance.md#concurrent-workload-with-natural-gc--2026-09-12),
323
+ 640,000 new records totaling 12.736 GiB passed byte/order checks and broker-verified
324
+ commits with zero final backlog. Using this profile reduced the worst observed
325
+ consumer RSS by **50.1% serial / 54.3% batch** versus the 100,000-message threshold.
326
+ Normal p95 stayed around 60 ms; p99 and recovery time increased. Each consumer ran
327
+ for two minutes on macOS with natural GC. This validates a specific profile, not
328
+ long-term stability, a hard memory ceiling, or production failure recovery.
329
+
330
+ **Tune for your application.** Larger fetches can help small-message throughput;
331
+ large batches can raise memory and latency. Use the
332
+ [benchmark harness](../../benchmarks/kafka/README.md) and your own handler to find
333
+ the useful tradeoff. Keep confirmed send/commit costs in end-to-end measurements.
334
+
335
+ **Connect and shut down deliberately.** Use TLS/SASL appropriate to your cluster,
336
+ keep credentials in a secret store, and retain certificate validation. Finish
337
+ accepted work before committing/closing. Await sends and manual flushes; cancel
338
+ and release Web readers or destroy Node streams and await close. Details and
339
+ examples are in the [API reference](docs/api.md).
340
+
341
+ ## Observability
342
+
343
+ Install `kafka-crab-js-otel` when you need OpenTelemetry tracing and metrics.
344
+ Configure your SDK/exporter, enable instrumentation before creating clients, and
345
+ keep client diagnostics enabled. Consumer processing spans should end after the
346
+ handler completes.
347
+
348
+ [OTEL setup](../kafka-crab-js-otel/README.md) ·
349
+ [Runnable tracing and metrics examples](../../examples/kafka/README.md)
350
+
351
+ ## Documentation and development
352
+
353
+ - [API reference](docs/api.md): configuration, producer, consumer, streams, commits, and shutdown.
354
+ - [Examples](../../examples/kafka/README.md): direct APIs, streams, retries, and observability.
355
+ - [Benchmarks](../../BENCHMARKS.md): measured comparisons and reproducible commands.
356
+ - [Engineering docs](docs/README.md): RFCs, validation evidence, and remaining release checks.
357
+ - [Issues and feature requests](https://github.com/flash-tecnologia/crab-js/issues): share reproductions and proposals.
1108
358
 
1109
359
  From the repository root:
1110
360
 
1111
- ```bash
1112
- vp install
1113
- vp run build
1114
- vp run test
1115
- vp run test:integration
1116
- vp check
1117
- ```
1118
-
1119
- Useful package-local commands:
1120
-
1121
- ```bash
1122
- vp run --filter kafka-crab-js build
1123
- vp run --filter kafka-crab-js test
1124
- vp run --filter kafka-crab-js test:integration
361
+ ```sh
362
+ pnpm install
363
+ pnpm --filter kafka-crab-js build
364
+ pnpm --filter kafka-crab-js test
365
+ RUN_KAFKA_INTEGRATION=true pnpm --filter kafka-crab-js test:integration
1125
366
  ```
1126
367
 
1127
- Integration tests require Kafka at `localhost:9092` unless `KAFKA_BROKERS` is set.
368
+ Building from source requires Rust and the native build prerequisites. Kafka must
369
+ be running for integration tests. See [test infrastructure](js-tests/integration/README.md)
370
+ for the Compose setup and broker requirements. The [release review](docs/rfc/review/README.md) tracks
371
+ validation separately from the performance claims above.
1128
372
 
1129
373
  ## License
1130
374
 
1131
- MIT
375
+ [MIT](https://opensource.org/licenses/MIT)