Kafka with Node.js
Apache Kafka is a distributed event-streaming platform built around a persistent, ordered, partitioned log. Unlike RabbitMQ, where messages are deleted after acknowledgement, Kafka retains messages for a configurable retention period — any consumer can replay the log from any offset. This makes Kafka the right choice for event sourcing, audit logs, stream processing, high-throughput pipelines, and scenarios where multiple independent consumer groups need to process the same events at their own pace. This page explains the Kafka model (brokers, topics, partitions, offsets, consumer groups) and then covers the KafkaJS Node client.
Core model: topics, partitions, offsets
Topic: "orders" (retention: 7 days — messages are NOT deleted on consume) │ ├─ Partition 0: offset 0 → offset 1 → offset 2 → offset 3 … ├─ Partition 1: offset 0 → offset 1 → offset 2 … └─ Partition 2: offset 0 → offset 1 … Consumer Group A (email service): worker-1 ← reads Partition 0 worker-2 ← reads Partition 1 worker-3 ← reads Partition 2 (each worker reads its partitions at its OWN offset — independent of other groups) Consumer Group B (analytics service): worker-1 ← reads Partition 0, 1, 2 (can lag behind Group A independently)
Kafka vs RabbitMQ
Dimension | RabbitMQ | Kafka |
|---|---|---|
Message retention | Deleted after ack | Retained for configurable duration (days/weeks) |
Delivery model | Push to consumers | Consumers pull at their own pace |
Ordering | Per-queue FIFO | Per-partition strict order |
Throughput | Hundreds of thousands/sec | Millions of messages/sec |
Consumer groups | Competing consumers share a queue | Each group reads all messages independently |
Replay | Not possible (message gone after ack) | Yes — seek to any offset |
Routing | Exchanges, bindings, routing keys | Topic + partition key |
Operational complexity | Moderate (single broker for dev) | High (ZooKeeper/KRaft, replication, retention config) |
Best for | Task queues, RPC-style work, flexible routing | Event streams, audit logs, high throughput, replay |
Installing KafkaJS
npm install kafkajs
Connecting and creating a topic
import { Kafka } from 'kafkajs'
// One Kafka instance per application — reuse it across producers and consumers
export const kafka = new Kafka({
clientId: 'my-app',
brokers: [process.env.KAFKA_BROKER ?? 'localhost:9092'],
// In production: SSL + SASL credentials:
// ssl: true,
// sasl: { mechanism: 'plain', username: process.env.KAFKA_USER, password: process.env.KAFKA_PASS },
})
// Create topics (usually done by ops/Terraform, but useful for dev setup):
async function createTopics() {
const admin = kafka.admin()
await admin.connect()
await admin.createTopics({
topics: [
{ topic: 'orders', numPartitions: 3, replicationFactor: 1 },
],
})
await admin.disconnect()
}Producer — publishing events
import { kafka } from './kafka'
const producer = kafka.producer()
export async function publishOrderEvent(order: { id: string; userId: string; total: number }) {
await producer.connect()
await producer.send({
topic: 'orders',
messages: [
{
// key determines partition — same userId always goes to same partition,
// guaranteeing order of events for that user
key: order.userId,
value: JSON.stringify({ type: 'order.created', data: order }),
// optional headers for metadata:
headers: { 'content-type': 'application/json' },
},
],
})
}
// In a real app, connect the producer once at startup, not per message:
async function startProducer() {
await producer.connect()
// producer is now ready; publishOrderEvent() can be called without reconnecting
}Consumer — reading events
import { kafka } from './kafka'
const consumer = kafka.consumer({ groupId: 'email-service' })
export async function startConsumer() {
await consumer.connect()
await consumer.subscribe({ topic: 'orders', fromBeginning: false })
// fromBeginning: true → replay all retained messages from offset 0
// fromBeginning: false → start from latest (new messages only)
await consumer.run({
// eachMessage is called once per message, in partition order
eachMessage: async ({ topic, partition, message }) => {
const value = message.value?.toString()
if (!value) return
try {
const event = JSON.parse(value)
await handleOrderEvent(event)
// KafkaJS auto-commits the offset after eachMessage resolves successfully
} catch (err) {
console.error('Failed to process message:', { partition, offset: message.offset, err })
// Don't throw — KafkaJS will stop processing and retry from this offset on throw.
// Log it and move on, or route to a dead-letter topic.
}
},
})
}
async function handleOrderEvent(event: { type: string; data: unknown }) {
if (event.type === 'order.created') {
// send confirmation email...
}
}Consumer with manual offset commit
await consumer.run({
autoCommit: false, // take control of offset commits
eachMessage: async ({ topic, partition, message, heartbeat, commitOffsets }) => {
await processMessage(message)
// Commit after successful processing — ensures at-least-once delivery
await commitOffsets([{ topic, partition, offset: (Number(message.offset) + 1).toString() }])
// offset to commit = current offset + 1 (next message to read)
},
})Seeking and replaying
// Seek to a specific offset — useful for replay or recovery:
consumer.on(consumer.events.GROUP_JOIN, async () => {
// Seek to offset 0 on all partitions to replay the entire topic:
const topicMetadata = await kafka.admin().fetchTopicMetadata({ topics: ['orders'] })
const partitions = topicMetadata.topics[0].partitions.map(p => p.partitionId)
for (const partition of partitions) {
consumer.seek({ topic: 'orders', partition, offset: '0' })
}
})Graceful shutdown
process.on('SIGTERM', async () => {
console.log('Shutting down Kafka consumer...')
await consumer.disconnect() // commits pending offsets, closes gracefully
await producer.disconnect()
process.exit(0)
})