MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Introduction to Apache Kafka for Beginners

Understand Kafka fundamentals: topics, partitions, consumer groups, and producers.

KafkaMessage QueueEvent StreamingBackend

By MinhVo

Introduction

Apache Kafka has become the backbone of modern data infrastructure, powering real-time streaming pipelines at companies like LinkedIn, Netflix, Uber, and Airbnb. Originally developed at LinkedIn in 2010 and open-sourced in 2011, Kafka was designed to handle trillions of events per day with low latency and high throughput. Its distributed architecture makes it uniquely suited for building event-driven systems, log aggregation, stream processing, and microservice communication.

Unlike traditional message brokers such as RabbitMQ or ActiveMQ, Kafka treats messages as an immutable, append-only log. This fundamental design choice gives Kafka its extraordinary performance characteristics: producers can write millions of messages per second, consumers can read at their own pace without affecting other consumers, and data is retained for configurable periods regardless of whether it has been consumed. For developers building modern applications, understanding Kafka is no longer optional—it's essential.

In this comprehensive guide, we'll explore Kafka's core architecture, understand how topics and partitions work, learn about consumer groups and offset management, build practical producer and consumer applications, and examine real-world patterns that make Kafka indispensable in production environments.

Kafka distributed streaming platform architecture

Understanding Kafka: Core Concepts and Architecture

Kafka operates as a distributed commit log, which fundamentally differs from traditional message queues. In a typical message queue, messages are deleted after consumption. Kafka retains messages based on configurable retention policies—by time (e.g., 7 days), by size (e.g., 50 GB per partition), or indefinitely. This means consumers can replay messages, reprocess historical data, and join streams retroactively.

The Kafka cluster consists of multiple brokers (servers), each responsible for storing a subset of the data. Each broker handles read and write requests from producers and consumers, replicates data across brokers for fault tolerance, and participates in cluster coordination through Apache ZooKeeper (or KRaft in newer versions). A typical production cluster contains 3 to 50 brokers, each managing hundreds of gigabytes to terabytes of data.

The core abstraction in Kafka is the topic, which is a named stream of records. Topics are partitioned, meaning the data is split across multiple segments called partitions. Each partition is an ordered, immutable sequence of records that is continually appended to. Partitions are the unit of parallelism in Kafka—more partitions allow more consumers to read in parallel, but they also increase the overhead of leader election and metadata management.

Topics and Partitions

When a producer sends a message to a topic, Kafka determines which partition to write the message to. By default, Kafka uses a round-robin strategy if no key is provided. If a key is present, Kafka hashes the key to determine the partition, ensuring all messages with the same key end up in the same partition. This guarantees ordering per key, which is critical for use cases like user event tracking where events for a specific user must be processed in order.

Each partition has a leader broker and zero or more follower brokers. The leader handles all reads and writes for the partition, while followers replicate the leader's data asynchronously. When a leader broker fails, one of the in-sync replicas (ISR) is promoted to leader automatically. The acks producer configuration controls durability: acks=0 (fire and forget), acks=1 (leader acknowledgment), or acks=all (all ISR acknowledgment).

Consumer Groups

Consumer groups are Kafka's mechanism for horizontal scaling of message consumption. A consumer group is a set of consumers that cooperatively consume from a topic. Each partition is assigned to exactly one consumer within a group, ensuring no duplicate processing. If a consumer fails, its partitions are rebalanced among the remaining consumers automatically.

The number of consumers in a group cannot exceed the number of partitions. If you have 10 partitions, adding an 11th consumer to the group results in that consumer sitting idle. Therefore, partition count is a critical capacity planning decision—it determines the maximum parallelism for consumption.

Event streaming and message processing pipeline

Architecture and Design Patterns

Kafka's architecture follows a publish-subscribe model with strong ordering guarantees at the partition level. The key design patterns that make Kafka powerful include event sourcing, CQRS (Command Query Responsibility Segregation), and the outbox pattern.

Event Sourcing with Kafka

Event sourcing stores the state of an entity as a sequence of events rather than the current state. Kafka's append-only log naturally supports this pattern—each event is a record in a topic partition, and the current state can be reconstructed by replaying events from the beginning. This enables temporal queries ("what was the state at time T?"), complete audit trails, and the ability to rebuild read models from scratch.

The Log Compaction Pattern

Kafka supports log compaction, which retains only the latest value for each key within a partition. This is powerful for maintaining a full snapshot of the latest state for each key—essentially a changelog topic. When a consumer starts from the beginning of a compacted topic, it receives the full current state of every key, making it ideal for building materialized views and caches.

Exactly-Once Semantics

Kafka provides exactly-once semantics (EOS) through idempotent producers and transactional APIs. An idempotent producer assigns a sequence number to each message, and the broker deduplicates messages with the same producer ID and sequence number. Transactions allow atomic writes across multiple partitions, ensuring that a batch of messages is either fully committed or fully rolled back.

Step-by-Step Implementation

Let's build a practical Kafka application with a producer and consumer in Node.js using the kafkajs library.

Setting Up Kafka with Docker Compose

First, let's set up a local Kafka environment:

# docker-compose.yml
version: '3.8'
services:
  zookeeper:
    image: confluentinc/cp-zookeeper:7.5.0
    environment:
      ZOOKEEPER_CLIENT_PORT: 2181
    ports:
      - "2181:2181"
 
  kafka:
    image: confluentinc/cp-kafka:7.5.0
    depends_on:
      - zookeeper
    environment:
      KAFKA_BROKER_ID: 1
      KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_NUM_PARTITIONS: 3
    ports:
      - "9092:9092"

Creating a Producer

import { Kafka, logLevel } from 'kafkajs';
 
const kafka = new Kafka({
  clientId: 'my-app',
  brokers: ['localhost:9092'],
  logLevel: logLevel.INFO,
});
 
const producer = kafka.producer({
  allowAutoTopicCreation: true,
  transactionTimeout: 30000,
});
 
async function sendMessage(topic: string, key: string, value: string) {
  await producer.connect();
  const result = await producer.send({
    topic,
    messages: [
      {
        key,
        value,
        timestamp: Date.now().toString(),
        headers: {
          'source': 'my-app',
          'version': '1.0',
        },
      },
    ],
  });
  console.log(`Sent message to ${topic}:`, result);
}
 
// Batch produce for high throughput
async function sendBatch(topic: string, messages: Array<{ key: string; value: string }>) {
  await producer.connect();
  const batch = messages.map(msg => ({
    key: msg.key,
    value: msg.value,
    timestamp: Date.now().toString(),
  }));
 
  const result = await producer.send({
    topic,
    messages: batch,
    compression: 1, // GZIP compression
  });
  console.log(`Batch sent ${batch.length} messages:`, result);
}
 
await sendMessage('user-events', 'user-123', JSON.stringify({
  event: 'page_view',
  page: '/products',
  timestamp: new Date().toISOString(),
}));

Creating a Consumer

const consumer = kafka.consumer({
  groupId: 'analytics-group',
  sessionTimeout: 30000,
  heartbeatInterval: 3000,
});
 
async function consumeMessages() {
  await consumer.connect();
  await consumer.subscribe({
    topic: 'user-events',
    fromBeginning: true,
  });
 
  await consumer.run({
    eachBatchAutoResolve: true,
    eachBatch: async ({ batch, resolveOffset, heartbeat, commitOffsetsIfNecessary }) => {
      console.log(`Processing batch of ${batch.messages.length} messages from ${batch.topic}[${batch.partition}]`);
 
      for (const message of batch.messages) {
        const event = JSON.parse(message.value!.toString());
        console.log({
          key: message.key?.toString(),
          value: event,
          offset: message.offset,
          timestamp: message.timestamp,
        });
 
        // Process the event (e.g., write to database)
        await processEvent(event);
 
        resolveOffset(message.offset);
        await heartbeat();
      }
 
      await commitOffsetsIfNecessary();
    },
  });
}
 
async function processEvent(event: any) {
  // Business logic: aggregate page views, update analytics, etc.
  if (event.event === 'page_view') {
    await updatePageViewCount(event.page);
  }
}
 
consumeMessages().catch(console.error);

Using Transactions for Exactly-Once Semantics

async function processAndProduce(inputTopic: string, outputTopic: string) {
  const consumer = kafka.consumer({ groupId: 'processor-group' });
  const producer = kafka.producer({ idempotent: true });
 
  await consumer.connect();
  await producer.connect();
  await consumer.subscribe({ topic: inputTopic, fromBeginning: false });
 
  await consumer.run({
    eachMessage: async ({ topic, partition, message }) => {
      const transaction = await producer.transaction();
 
      try {
        const input = JSON.parse(message.value!.toString());
        const enriched = {
          ...input,
          processedAt: new Date().toISOString(),
          source: `${topic}:${partition}:${message.offset}`,
        };
 
        await transaction.send({
          topic: outputTopic,
          messages: [{
            key: message.key,
            value: JSON.stringify(enriched),
          }],
        });
 
        await transaction.sendOffsets({
          consumerGroupId: 'processor-group',
          offsets: [{
            topic,
            partition,
            offset: (Number(message.offset) + 1).toString(),
          }],
        });
 
        await transaction.commit();
      } catch (error) {
        await transaction.abort();
        throw error;
      }
    },
  });
}

Distributed systems data flow architecture

Real-World Use Cases

Use Case 1: Real-Time Analytics Pipeline

E-commerce platforms use Kafka to track user behavior in real time. Every page view, click, search query, and purchase is published to Kafka topics. Downstream systems—including real-time dashboards, recommendation engines, and fraud detection—consume these events independently. Netflix processes over 700 billion events daily through Kafka for its recommendation and analytics systems.

Use Case 2: Microservice Communication

Kafka serves as the communication backbone for microservices architectures. Instead of synchronous REST calls between services, services publish events to Kafka topics. This decouples services temporally—producers don't need to know about consumers, and consumers can process events at their own pace. Uber uses Kafka to coordinate between its ride-matching, pricing, payment, and notification services.

Use Case 3: Log Aggregation and Monitoring

Organizations aggregate application logs, system metrics, and audit trails through Kafka. Each service writes structured logs to a dedicated topic, and monitoring tools consume from these topics to detect anomalies, trigger alerts, and populate dashboards. This approach replaces traditional log shipping with a more reliable, scalable pipeline.

Use Case 4: Database Change Data Capture (CDC)

Kafka Connect's CDC connectors capture row-level changes from databases like PostgreSQL, MySQL, and MongoDB, publishing them as events to Kafka topics. This enables real-time data synchronization between systems, cache invalidation, and building read-optimized views of operational data without impacting the source database.

Best Practices for Production

  1. Set acks=all for critical data: This ensures messages are replicated to all in-sync replicas before acknowledgment, preventing data loss during broker failures. The trade-off is slightly higher latency.

  2. Use compression: Enable compression.type=gzip or compression.type=lz4 on producers to reduce network bandwidth and storage costs. LZ4 offers the best throughput-to-compression ratio for most workloads.

  3. Monitor consumer lag: Consumer lag (the difference between the latest offset and the consumer's current offset) indicates whether consumers are keeping up with producers. Use Kafka's built-in metrics or tools like Burrow to monitor lag.

  4. Right-size partitions: Start with 2-3x the number of expected consumers. Too few partitions limit parallelism; too many increase metadata overhead and leader election time. 12-30 partitions per broker is a good guideline.

  5. Implement graceful shutdown: Always handle SIGTERM signals and call consumer.disconnect() and producer.disconnect() to commit offsets and flush pending messages before exiting.

  6. Use schema registry: Enforce data contracts using Avro or JSON Schema with a schema registry (Confluent Schema Registry). This prevents producers from publishing incompatible messages and allows safe schema evolution.

  7. Idempotent consumers: Design consumers to handle duplicate messages gracefully. Kafka guarantees at-least-once delivery by default, so consumers must be idempotent to prevent double-processing.

  8. Set appropriate retention policies: Configure retention.ms and retention.bytes based on your use case. Real-time pipelines might need only hours of retention, while audit trails might require months or years.

Common Pitfalls and Solutions

PitfallImpactSolution
Too many partitionsSlow leader election, increased metadata overheadKeep partitions under 200 per topic; scale horizontally with more brokers
Not handling rebalancesDropped messages during consumer group changesUse cooperative rebalancing (partitionAssigners) and implement rebalanceListener
Consumer group stuckData pipeline stalls, lag grows unboundedImplement health checks, use dead-letter queues for poison messages
Unbounded message sizeBroker OOM, slow processingSet max.message.bytes on topic and max.request.size on producer
No monitoring in productionSilent failures, data loss undetectedMonitor broker health, consumer lag, under-replicated partitions, and produce/consume rates

Performance Optimization

Kafka's performance depends on producer batching, consumer prefetching, and hardware configuration:

// High-throughput producer configuration
const producer = kafka.producer({
  allowAutoTopicCreation: false,
  idempotent: true,
  transactionTimeout: 60000,
});
 
// Configure batching for throughput
await producer.send({
  topic: 'high-volume-events',
  messages: batch,
  acks: -1,            // All replicas
  timeout: 30000,      // 30s timeout
  compression: 2,      // LZ4 compression
});
 
// Producer tuning via KafkaJS configuration
const tunedKafka = new Kafka({
  clientId: 'high-throughput-app',
  brokers: ['localhost:9092'],
  producer: {
    maxInFlightRequests: 5,
    idempotent: true,
    transactionalId: 'my-tx-id',
  },
});

Key performance optimizations include: adjusting batch.size and linger.ms on producers to increase batching efficiency; using fetch.min.bytes and fetch.max.wait.ms on consumers to reduce polling overhead; enabling zero-copy transfers between broker and consumer; and using SSD storage for brokers with high I/O requirements.

Comparison with Alternatives

FeatureApache KafkaRabbitMQAmazon SQSRedis Streams
ThroughputMillions/sec50K-100K/sec3K-300K/sec1M+/sec
Message RetentionConfigurable (days/forever)Until consumed14 days maxConfigurable
OrderingPer partitionPer queueBest-effort (FIFO)Per stream
Consumer ModelPull (consumer groups)Push/PullPullConsumer groups
Exactly-OnceSupported (transactions)Not nativeNot nativeNot native
ReplayFull replay capabilityNot supportedNot supportedBy ID/timestamp
Schema EvolutionVia Schema RegistryN/AN/AN/A
Operational ComplexityHigh (ZooKeeper/KRaft)Low-MediumManagedLow

Kafka excels when you need high throughput, message retention, replay capability, and stream processing. RabbitMQ is better for traditional message queuing with complex routing. SQS is ideal for simple, managed queuing. Redis Streams suits lightweight, in-memory streaming with low latency.

Advanced Patterns and Techniques

Kafka Streams for Real-Time Processing

Kafka Streams is a client library for building stream processing applications:

// Pseudo-code for stream processing concept
// In a real Java/Kotlin Kafka Streams app:
 
// 1. Read from input topic
// 2. Filter, map, aggregate events
// 3. Write results to output topic
 
// With Node.js, use kafkajs + custom processing:
async function streamProcessor() {
  const consumer = kafka.consumer({ groupId: 'stream-processor' });
  const producer = kafka.producer();
 
  await consumer.subscribe({ topic: 'raw-events' });
 
  // Windowed aggregation: count events per user per 5-minute window
  const windowMap = new Map<string, { count: number; windowStart: number }>();
 
  await consumer.run({
    eachMessage: async ({ message }) => {
      const event = JSON.parse(message.value!.toString());
      const windowKey = `${event.userId}:${Math.floor(Date.now() / 300000)}`;
 
      const current = windowMap.get(windowKey) || { count: 0, windowStart: Math.floor(Date.now() / 300000) * 300000 };
      current.count++;
      windowMap.set(windowKey, current);
 
      if (current.count === 10) { // Threshold alert
        await producer.send({
          topic: 'anomaly-alerts',
          messages: [{
            key: event.userId,
            value: JSON.stringify({ userId: event.userId, count: current.count, window: current.windowStart }),
          }],
        });
      }
    },
  });
}

Dead Letter Queue Pattern

When a message cannot be processed after a configured number of retries, route it to a dead letter topic for manual inspection:

async function consumeWithDLQ(inputTopic: string, dlqTopic: string, maxRetries: number = 3) {
  const consumer = kafka.consumer({ groupId: 'dlq-aware-group' });
  const producer = kafka.producer();
 
  await consumer.subscribe({ topic: inputTopic });
 
  await consumer.run({
    eachMessage: async ({ topic, partition, message }) => {
      const retryHeader = message.headers?.['retry-count'];
      const retryCount = retryHeader ? parseInt(retryHeader.toString()) : 0;
 
      try {
        const event = JSON.parse(message.value!.toString());
        await processEvent(event);
      } catch (error) {
        if (retryCount >= maxRetries) {
          // Send to dead letter queue
          await producer.send({
            topic: dlqTopic,
            messages: [{
              key: message.key,
              value: message.value,
              headers: {
                ...message.headers,
                'original-topic': topic,
                'original-partition': partition.toString(),
                'original-offset': message.offset,
                'error': (error as Error).message,
              },
            }],
          });
          console.error(`Message sent to DLQ after ${maxRetries} retries`);
        } else {
          // Retry with incremented count
          await producer.send({
            topic: inputTopic,
            messages: [{
              key: message.key,
              value: message.value,
              headers: {
                ...message.headers,
                'retry-count': (retryCount + 1).toString(),
              },
            }],
          });
        }
      }
    },
  });
}

Testing Strategies

Testing Kafka applications requires a combination of unit tests, integration tests, and end-to-end tests:

import { Kafka, logLevel } from 'kafkajs';
 
// Test helper: create a test Kafka instance
function createTestKafka() {
  return new Kafka({
    clientId: 'test-app',
    brokers: ['localhost:9092'],
    logLevel: logLevel.NOTHING,
  });
}
 
describe('Kafka Producer', () => {
  let producer: any;
 
  beforeAll(async () => {
    const kafka = createTestKafka();
    producer = kafka.producer();
    await producer.connect();
  });
 
  afterAll(async () => {
    await producer.disconnect();
  });
 
  test('sends message to topic', async () => {
    const result = await producer.send({
      topic: 'test-topic',
      messages: [{ key: 'test-key', value: 'test-value' }],
    });
    expect(result).toHaveLength(1);
    expect(result[0].errorCode).toBe(0);
  });
 
  test('sends batch of messages', async () => {
    const messages = Array.from({ length: 100 }, (_, i) => ({
      key: `key-${i}`,
      value: JSON.stringify({ id: i, data: `test-${i}` }),
    }));
 
    const result = await producer.send({
      topic: 'test-topic',
      messages,
      compression: 1,
    });
    expect(result).toHaveLength(1);
  });
});
 
// Integration test with Testcontainers
describe('Kafka Integration', () => {
  test('end-to-end message flow', async () => {
    // Use Testcontainers to spin up Kafka in Docker
    // Produce a message, consume it, verify content
    const kafka = createTestKafka();
    const consumer = kafka.consumer({ groupId: 'test-group' });
    const producer = kafka.producer();
 
    await producer.connect();
    await consumer.connect();
 
    const testMessage = { event: 'test', timestamp: new Date().toISOString() };
    await producer.send({
      topic: 'integration-test',
      messages: [{ key: 'test', value: JSON.stringify(testMessage) }],
    });
 
    const consumed: any[] = [];
    await consumer.subscribe({ topic: 'integration-test', fromBeginning: true });
    await consumer.run({
      eachMessage: async ({ message }) => {
        consumed.push(JSON.parse(message.value!.toString()));
      },
    });
 
    // Wait for consumption
    await new Promise(resolve => setTimeout(resolve, 5000));
    expect(consumed).toContainEqual(expect.objectContaining({ event: 'test' }));
  });
});

Future Outlook

Kafka continues to evolve with significant developments. KRaft mode (Kafka Raft) removes the dependency on ZooKeeper, simplifying operations and improving scalability by using an internal Raft-based consensus protocol. This is production-ready in Kafka 3.5+ and represents the future of Kafka deployments.

Kafka Streams and ksqlDB are making stream processing more accessible, allowing SQL-like queries over real-time data streams. Kafka Connect continues to expand its connector ecosystem, with over 200 connectors available for databases, cloud services, and file systems.

The rise of event-driven architectures and microservices ensures Kafka's relevance for years to come. Cloud-managed Kafka services like Confluent Cloud, Amazon MSK, and Azure Event Hubs are reducing operational overhead, making Kafka accessible to teams without dedicated infrastructure engineers. The convergence of streaming and batch processing through technologies like Apache Flink and Kafka's own Tiered Storage is blurring the lines between real-time and historical data processing.

Conclusion

Apache Kafka is a foundational technology for modern data infrastructure, offering unmatched throughput, durability, and flexibility for event streaming. The key takeaways from this guide are: Kafka's append-only log design provides unique advantages over traditional message queues, including message retention and replay capability. Topics and partitions are the core abstractions that determine data organization and parallelism.

Consumer groups enable horizontal scaling of consumption while maintaining ordering guarantees within partitions. Exactly-once semantics through idempotent producers and transactions prevent data duplication in critical pipelines. Schema management and monitoring are essential for production reliability—implement them from day one, not as an afterthought.

Start with a local Docker setup, build a simple producer-consumer application, and gradually explore advanced patterns like event sourcing, CDC, and stream processing. The Kafka documentation at kafka.apache.org is excellent, and the Confluent Developer site offers free courses and tutorials. As you scale, consider managed Kafka services to reduce operational complexity and focus on building value from your event streams.