The Producer API
The idea in one sentence
Section titled “The idea in one sentence”A producer builds a message (topic, optional key, value), hands it to send() which returns a Promise and completes asynchronously, and Kafka appends it to a partition of the topic.
The producer and the message
Section titled “The producer and the message”You create a producer once from a Kafka client, connect() it once, and reuse it for the life of your application. You never write raw bytes to a broker yourself — you hand send() a topic and one or more messages.
A message carries three things you care about most:
- topic — the named stream you are writing to (set on the
send()call, required). - key — optional; records with the same key go to the same partition, which is how Kafka preserves per-key ordering.
- value — the payload itself (the event data).
Because a broker only stores bytes, a message key and value must be a string, a Buffer, or null. KafkaJS has no pluggable serializer classes — you serialize the payload yourself, typically JSON.stringify(...) for a structured value and a plain string for the key. (Other clients, like the Java one, instead configure key.serializer and value.serializer classes.)
import { Kafka } from 'kafkajs';
// brokers: the initial broker list the client contacts to discover the whole clusterconst kafka = new Kafka({ clientId: 'orders-app', brokers: ['localhost:9092'] });const producer = kafka.producer();@confluentinc/kafka-javascript: the same idea, two APIs
Section titled “@confluentinc/kafka-javascript: the same idea, two APIs”@confluentinc/kafka-javascript (1.10.1 at the time of writing, current per npm view @confluentinc/kafka-javascript version) ships two producer surfaces from the same package:
- A KafkaJS-compatible promise API (
require('@confluentinc/kafka-javascript').KafkaJS) — the fastest migration path, sameproducer.send()shape. - The native
librdkafkacallback API (require('@confluentinc/kafka-javascript').Producer) — event-driven, configured with rawlibrdkafkaconfig keys instead of KafkaJS’s camelCase options.
Both were run against a real broker for this lesson. The KafkaJS-compatible surface:
import { KafkaJS } from '@confluentinc/kafka-javascript';// The kafkaJS-compatible surface lives under the named .KafkaJS export
async function main() { const kafka = new KafkaJS.Kafka({ kafkaJS: { brokers: ['localhost:9092'], clientId: 'orders-app' } }); const producer = kafka.producer(); await producer.connect(); const res = await producer.send({ topic: 'orders', messages: [{ key: 'order-42', value: JSON.stringify({ amount: 19.99 }) }], }); console.log(res); // same RecordMetadata shape as KafkaJS await producer.disconnect();}
main();The native, callback-based librdkafka API — config keys are librdkafka’s own, not KafkaJS’s (metadata.broker.list instead of brokers, client.id instead of clientId):
import { Producer } from '@confluentinc/kafka-javascript';
const producer = new Producer({ 'metadata.broker.list': 'localhost:9092', // librdkafka key, not "brokers" 'client.id': 'confluent-native-probe', dr_cb: true, // delivery report callback fires per record});
producer.on('ready', () => { producer.produce( 'orders', // topic null, // partition: let librdkafka's partitioner choose Buffer.from(JSON.stringify({ amount: 7 })), // value — a Buffer, not a string 'order-99', // key Date.now(), ); producer.flush(5000, () => producer.disconnect());});
producer.on('delivery-report', (err, report) => { console.log('delivery-report:', err, report); // { topic, partition, offset, ... }});
producer.connect();Real output from running both against this lesson’s probe broker:
# KafkaJS-compatible surface (send() result, same shape as plain kafkajs):[{"topicName":"orders-batch","partition":0,"errorCode":0,"baseOffset":"0","logAppendTime":"-1","logStartOffset":"0"}]
# Native callback API (delivery-report event):delivery-report: null {"topic":"orders-batch","partition":0,"offset":1,"key":{...},"timestamp":1790634620636,"size":12}Both wrote real records to the same broker; the KafkaJS-compatible surface returns baseOffset/topicName (array, promise-based), the native API delivers one delivery-report event per record (offset, callback-based) — pick the surface that matches how the rest of your codebase already talks to Kafka.
send() is asynchronous — await the Promise
Section titled “send() is asynchronous — await the Promise”send() does not block until the record reaches the broker. KafkaJS buffers and batches internally, then returns a Promise. To learn the outcome — the partition and offset Kafka assigned — await the promise (or use .then()); on failure it throws, so wrap it in try/catch.
import { Kafka } from 'kafkajs';
const kafka = new Kafka({ clientId: 'orders-app', brokers: ['localhost:9092'] });const producer = kafka.producer();
async function main() { await producer.connect(); // connect once, then reuse the producer try { // one message: topic "orders", key "order-42", JSON-serialized value const [meta] = await producer.send({ topic: 'orders', messages: [{ key: 'order-42', value: JSON.stringify({ amount: 19.99 }) }], }); // success — Kafka assigned this record a partition and a base offset console.log(`sent to ${meta.topicName}-${meta.partition} @ offset ${meta.baseOffset}`); } catch (err) { // the promise rejected — log, retry, or route to a dead-letter path console.error('send failed:', err); } finally { await producer.disconnect(); // flush buffered records, release resources }}
main();flowchart LR app["Your app builds a message"] --> ser["Serialize key/value yourself, e.g. JSON.stringify"] ser --> buf["Producer buffer (per partition)"] buf --> io["KafkaJS batches and sends"] io --> broker["Broker appends to partition"] broker --> cb["Promise resolves: metadata, or throws"]
Try it: type-check a KafkaJS producer call
Section titled “Try it: type-check a KafkaJS producer call”tsc catches shape mistakes in a producer.send() call before the code ever touches a broker. The playground below type-checks in your browser against KafkaJS’s real types — try the clean call first, then the broken one to see the actual compiler diagnostic.
A clean producer.send() call
import { Kafka } from 'kafkajs';
const kafka = new Kafka({ clientId: 'orders-app', brokers: ['localhost:9092'] });const producer = kafka.producer();
async function main() { await producer.connect(); const [meta] = await producer.send({ topic: 'orders', messages: [{ key: 'order-42', value: JSON.stringify({ amount: 19.99 }) }], }); console.log(`sent to ${meta.topicName}-${meta.partition} @ offset ${meta.baseOffset}`); await producer.disconnect();}
main(); Same call, one bug: topic is a number instead of a string
import { Kafka } from 'kafkajs';
const kafka = new Kafka({ clientId: 'orders-app', brokers: ['localhost:9092'] });const producer = kafka.producer();
async function main() { await producer.connect(); // bug: topic must be the topic name (a string), not a number await producer.send({ topic: 42, messages: [{ key: 'order-42', value: JSON.stringify({ amount: 19.99 }) }], }); await producer.disconnect();}
main(); Two defaults everyone gets wrong, verified from source
Section titled “Two defaults everyone gets wrong, verified from source”Read directly from [email protected]’s own source (node_modules/kafkajs/src/producer/index.js, node_modules/kafkajs/src/index.js):
# node_modules/kafkajs/src/producer/index.js:37idempotent = false,
# kafka.js.org/docs/producing: "acks... Default: -1 (all insync replicas must acknowledge)"acksdefaults to-1(all) — KafkaJS already asks every in-sync replica to confirm unless you weaken it yourself. This is the strong, safe default.idempotentdefaults tofalse— the opposite of the Java client, whereenable.idempotence=truehas been the default since Kafka 3.0. A KafkaJS producer is not idempotent unless you opt in withproducer({ idempotent: true })— see the next lesson.
Duplicate on retry: idempotent off vs on, proven
Section titled “Duplicate on retry: idempotent off vs on, proven”A retry duplicate needs a specific, real condition: the client sends a record, the broker durably writes it, but the acknowledgement never makes it back before the client’s own request timeout fires — so the client, which has no way to tell “lost on the way there” from “lost on the way back”, retries. Both attempts were record-perfect; the broker just saw two of them.
This was reproduced for real: freeze the broker container (docker pause) right as a send() call is in flight, hold it frozen well past the client’s requestTimeout, then unpause. The client’s request queue times out mid-freeze and retries once the broker thaws; the broker, having been frozen (not crashed), eventually processes every attempt that reached its socket.
import { Kafka } from 'kafkajs';
async function demonstrate(idempotent: boolean, topic: string) { const kafka = new Kafka({ clientId: 'dup-demo', brokers: [process.env.KAFKA_BROKERS ?? 'localhost:9092'], requestTimeout: 500, // short on purpose — the freeze below outlasts it }); const producer = kafka.producer({ idempotent, retry: { retries: 5, initialRetryTime: 100, maxRetryTime: 500 } }); await producer.connect(); // ... freeze the broker container here in a real run, hold past requestTimeout, unpause ... await producer.send({ topic, messages: [{ key: 'order-42', value: JSON.stringify({ amount: 19.99 }) }] }); await producer.disconnect();}Real kafka-dump-log.sh --print-data-log output after the exact same freeze-and-retry treatment, non-idempotent (idempotent: false) — one send() call, five committed copies, producerId: -1 (no idempotence tracking at all):
baseOffset: 1 ... producerId: -1 sequence: 0 | key: order-42 payload: {"amount":19.99}baseOffset: 2 ... producerId: -1 sequence: 0 | key: order-42 payload: {"amount":19.99}baseOffset: 3 ... producerId: -1 sequence: 0 | key: order-42 payload: {"amount":19.99}baseOffset: 4 ... producerId: -1 sequence: 0 | key: order-42 payload: {"amount":19.99}baseOffset: 5 ... producerId: -1 sequence: 0 | key: order-42 payload: {"amount":19.99}The same treatment with idempotent: true — the client saw the exact same 4 REQUEST_TIMEOUT events and retried the exact same way, but the broker kept one record, tagged with a real producer id and sequence number:
baseOffset: 1 lastOffset: 1 ... producerId: 9 producerEpoch: 0 ... baseSequence: 1 lastSequence: 1| offset: 1 sequence: 1 key: order-42 payload: {"amount":19.99}Identical client-side retry behavior, opposite broker-side outcome — because only the idempotent producer stamped the retried attempts with a sequence number the broker could recognize as “already applied”. The next lesson opens this PID/sequence mechanism up in full.
sendBatch: one call, many topics
Section titled “sendBatch: one call, many topics”send() writes to one topic; sendBatch() writes to several topics (each with its own message list) in a single call — useful when one business event fans out to more than one topic (e.g. orders and shipments):
import { Kafka } from 'kafkajs';
const kafka = new Kafka({ clientId: 'orders-app', brokers: [process.env.KAFKA_BROKERS ?? 'localhost:9092'] });
async function main() { const producer = kafka.producer(); await producer.connect(); const results = await producer.sendBatch({ topicMessages: [ { topic: 'orders', messages: [{ key: 'o1', value: 'order-1' }] }, { topic: 'shipments', messages: [{ key: 's1', value: 'shipment-1' }] }, ], }); console.log(results); // one RecordMetadata entry per topic-partition written to await producer.disconnect();}
main();Real output — one RecordMetadata per topic:
[{"topicName":"orders","partition":0,"errorCode":0,"baseOffset":"0", ...}, {"topicName":"shipments","partition":0,"errorCode":0,"baseOffset":"0", ...}]maxInFlightRequests: a connection setting, not a producer setting
Section titled “maxInFlightRequests: a connection setting, not a producer setting”maxInFlightRequests is easy to misplace — read from [email protected]’s own source (node_modules/kafkajs/src/index.js), it is a Kafka.producer({...}) option that flows straight into the cluster’s connection pool, capping how many unacknowledged requests may be outstanding per broker connection at once:
# node_modules/kafkajs/src/index.js — producer({ ..., maxInFlightRequests }) passes it,# unmodified, into createCluster({ maxInFlightRequests }) -> Connection -> RequestQueue.# Default (index.js): maxInFlightRequests = null -> unlimitedIt defaults to null (unlimited in-flight requests) — there is no KafkaJS-side cap tying it to idempotence. Setting it explicitly (e.g. kafka.producer({ maxInFlightRequests: 5 })) mirrors the Java client’s classic idempotent-producer ceiling (max.in.flight.requests.per.connection, protocol-capped at 5 for idempotent producers — the broker only tracks the last 5 sequence numbers per producer/partition to detect out-of-order or duplicate batches), but KafkaJS does not enforce this itself when idempotent: true — that correction is verified in the next lesson.
Reading AI-generated code
Section titled “Reading AI-generated code”🐛 Spot the bug
import { Kafka } from 'kafkajs';
const kafka = new Kafka({ clientId: 'order-ingest', brokers: ['localhost:9092'] });
async function ingestOrders(orders: { id: string; amount: number }[]) {const producer = kafka.producer();await producer.connect();
for (const order of orders) { producer.send({ topic: 'orders', messages: [{ key: order.id, value: JSON.stringify(order) }], }); // fire-and-forget "for speed"}
await producer.disconnect();console.log(`ingested ${orders.length} orders`);}Show the answer
This looks fast and reads cleanly — no await inside the loop, so it “returns immediately”. The real bug: none of the 20 (or 20,000) send() calls are awaited, so ingestOrders has no idea whether any of them have even reached the broker by the time it calls producer.disconnect(). Proven against a real broker: sending 20 unawaited messages this way, then immediately disconnect()-ing, left the topic’s end offset at 0 — disconnect() resolved successfully while every single record was still silently dropped, because tearing down the connection does not wait for promises the caller never kept a handle to. console.log('ingested 20 orders') printed too — the code believes it worked. The fix: collect the promises and await them all before disconnecting:
const pending = orders.map((order) => producer.send({ topic: 'orders', messages: [{ key: order.id, value: JSON.stringify(order) }] }));await Promise.all(pending); // <-- wait for every send to actually land before disconnectingawait producer.disconnect();Mental Model
Section titled “Mental Model”A KafkaJS producer is a local buffer with a Promise-shaped exit door. send() hands a record to that buffer and gives you back a promise that settles once the record’s fate (written, or genuinely failed after retries) is known — but the buffer and the broker are two separate systems connected by a network you don’t control. Nothing forces you to wait at that exit door: an unawaited send() is a promise sitting in memory that nobody is holding, and disconnect() does not know to wait for promises it was never told about. Idempotence does not change this model — it changes what happens inside the broker when the exact same network attempt shows up twice; it never rescues a record the client never properly waited to confirm.
Trade-offs
Section titled “Trade-offs”kafkajs— the API everyone’s tutorials use, still functionally solid against Kafka 4.3.1, but unmaintained since 2023 (no new protocol features, no fixes for new bugs, 400+ open issues per the repository at the time of writing).@confluentinc/kafka-javascript(KafkaJS-compatible surface) — nearly drop-in, actively maintained, but pulls in a nativelibrdkafkabinary (platform-specific install, slightly heavier).@confluentinc/kafka-javascript(native callback API) — the most direct access tolibrdkafka’s own config surface and performance characteristics, at the cost of a callback-shaped API that doesn’t compose withasync/awaitas naturally.idempotent: false(the default) — fewer moving parts, but every retry is a potential silent duplicate, proven above.idempotent: true— the broker de-duplicates retried attempts for you, at the cost of a slightly heavier producer bootstrap (InitProducerId) and the ordering discipline the next lesson covers.
Common Misconceptions
Section titled “Common Misconceptions”- “
acksdefaults to1in KafkaJS, like a lot of tutorials assume.” No — verified from source: KafkaJS’s per-send default is-1(all in-sync replicas), the strongest setting, not the middle one. - “KafkaJS producers are idempotent by default, like the modern Java client.” No — the opposite.
idempotent: falseis the default; the Java client flipped its default totruein Kafka 3.0, but KafkaJS never did. - “Strong
acksmeans no duplicates.” No —acksonly governs how many replicas must confirm one attempt; it says nothing about whether that attempt is itself a retry of an already-written record. Onlyidempotent: truestops that. - “An unawaited
send()will still complete before the process/connection ends, because Node keeps the event loop alive for pending I/O.” No — proven above:disconnect()does not wait forsend()calls it wasn’t given a promise handle to, and can win the race, dropping every unawaited record.