?

Day 0143 minPreface+60 XP

Queue vs Stream.

4 stages12 code

watch it first · newThe whole lesson in a 20-minute animated lecture5 quiz checkpoints · ask the AI about any moment · captions, chapters and speed

Preface · 1 min

Preface#

It's 3 AM, and your phone buzzes with a PagerDuty alert. Your e-commerce platform's order processing system is down. Orders are being lost. Customer payments are going through, but confirmations aren't being sent. The support queue is exploding.

You SSH into the server and find the culprit: your synchronous order processing chain has collapsed. The email service is slow, causing timeouts that cascade back through payment processing, inventory updates, and order creation. One slow service brought down everything.

Last week, we learned about building resilient systems—timeouts, retries, circuit breakers, idempotency. But we left a dangerous hole:

DiagramThe synchronous trap
%% title: The synchronous trap
flowchart TD
  U["User Request"]:::client --> CO["Create Order"]:::core
  CO --> RI["Reserve Inventory"]:::service
  RI --> CP["Charge Payment"]:::service
  CP --> SE["Send Email"]:::service
  SE --> SLOW["SLOW!<br/>(5 sec)"]:::bad
  SLOW -.-> CP
  CP -.->|"Timeout propagates back"| RI
  RI -.-> CO
  CO --> ERR["User sees: #quot;Error processing your order#quot;<br/>Reality: Payment charged, but order not created"]:::bad

Question: What went wrong? Answer: Tight coupling. Every service waits for the next.

This is the problem asynchronous messaging solves.

Today, we'll learn the fundamental building blocks of async communication: queues and streams. By the end of this session, you'll know exactly when to reach for Kafka, when RabbitMQ is the right choice, and why "just use Kafka" is often the wrong answer.

ExplainerThe whole lesson: queue vs stream

A queue is work to be done: each message goes to one consumer and is deleted. A stream is events that happened: retained, read by many consumers that each track an offset, and replayable; consumer groups fan out across groups and balance partitions within one. Most systems pair at-least-once delivery with idempotent consumers, key by user_id for per-user order, watch consumer lag (10,000/s in against 1,000/s out is 32.4 million behind in an hour), and pick a stream for replay or many readers, a queue for jobs.

Part I · Stage 1 of 4 · 10 min

Part I: Foundations#

Chapter 1: What Are Queues and Streams?#

1.1 The Simple Definition#

A message queue is a buffer that holds messages between a sender and receiver, allowing them to operate independently.

A stream (or log) is an append-only sequence of messages that multiple readers can consume at their own pace.

EVERYDAY ANALOGIES

QUEUE = Post Office Box

  1. You drop off a letter
  2. Recipient picks it up
  3. Letter is gone after pickup
  • One recipient per letter
  • Letter disappears after reading
  • Can't re-read the letter

STREAM = Newspaper Archive

  1. Publisher adds today's paper to the archive
  2. Anyone can read any past edition
  3. Papers stay in the archive (until cleanup policy)
  • Multiple readers, same content
  • Content persists after reading
  • Can re-read any past edition

1.2 Why Distributed Systems Need Messaging#

In a distributed system, services need to communicate. There are two fundamental approaches:

Synchronous Communication (HTTP/RPC):

  • Service A calls Service B and waits for a response
  • Simple to understand and implement
  • Creates tight coupling between services
  • Cascading failures when downstream services are slow

Asynchronous Communication (Messaging):

  • Service A sends a message and continues immediately
  • Services are decoupled in time and availability
  • More complex to implement correctly
  • Resilient to downstream failures
DiagramSynchronous vs asynchronous
%% title: Synchronous vs asynchronous
flowchart TD
  subgraph SY["SYNCHRONOUS (HTTP/RPC)"]
    direction TB
    A1["Service A<br/>WAIT"]:::client -->|"Request"| B1["Service B<br/>Processing..."]:::service
    B1 -->|"Response"| A1
  end
  subgraph AS["ASYNCHRONOUS (Messaging)"]
    direction TB
    A2["Service A<br/>DONE!"]:::client --> Q2[["Queue/Stream"]]:::queue
    Q2 --> B2["Service B"]:::service
  end
  SY ~~~ AS

Synchronous (HTTP/RPC)

  • Total time = A's time + B's time + network
  • If B is down, A fails immediately

Asynchronous (Messaging)

  • A completes immediately after sending
  • If B is down, messages queue up and wait

1.3 The Formal Definitions#

Let's be precise about terminology:

Term Definition
Producer Service that sends messages
Consumer Service that receives and processes messages
Broker The messaging infrastructure (Kafka, RabbitMQ, etc.)
Topic/Queue Named destination for messages
Partition Subdivision of a topic for parallelism (Kafka)
Consumer Group Set of consumers that share message processing
Offset Position in a stream (how far you've read)
Acknowledgment (Ack) Confirmation that a message was processed

1.4 The Two Mental Models#

This is the most important concept of the day. There are two fundamentally different mental models:

DiagramTwo mental models
%% title: Two mental models
flowchart TD
  subgraph M1["MODEL 1: QUEUE (Job Distribution)"]
    direction TB
    P1@{ icon: "sd:server", label: "Producer" } --> Q1@{ icon: "sd:queue", label: "Task · Task · Task · Task" }
    Q1 --> W1@{ icon: "sd:worker", label: "Worker" }
  end
  subgraph M2["MODEL 2: STREAM/LOG (Event History)"]
    direction TB
    P2@{ icon: "sd:server", label: "Producer" } --> S2@{ icon: "sd:stream", label: "Event · Event · Event · Event · Event ..." }
    S2 -->|"offset 1"| RA["Reader A"]:::service
    S2 -->|"offset 3"| RB["Reader B"]:::service
    S2 -->|"offset 2"| RC["Reader C"]:::service
  end
  M1 ~~~ M2
  class P1,P2 client
  class Q1,S2 queue
  class W1 service

Model 1: Queue (Job Distribution) — Think: "Work to be done"

  • Each task goes to exactly ONE worker
  • Task is REMOVED after processing
  • Workers compete for tasks
  • No history—once done, it's gone

Examples: RabbitMQ, SQS, Redis LPUSH/BRPOP, Celery

Use cases:

  • Background job processing
  • Task distribution across workers
  • Request buffering during traffic spikes

Model 2: Stream/Log (Event History) — Think: "Things that happened"

  • Events are RETAINED (not removed after reading)
  • Multiple readers can read the same events
  • Each reader tracks their own position (offset)
  • Full history available for replay

Examples: Kafka, Kinesis, Redpanda, Pulsar, Redis Streams

Use cases:

  • Event sourcing and audit logs
  • Real-time analytics
  • Multi-consumer fan-out
  • Replay for debugging or reprocessing

Chapter 2: Messaging Patterns#

2.1 Pattern 1: Point-to-Point (Queue)#

In point-to-point messaging, each message is delivered to exactly one consumer.

DiagramPoint-to-point pattern
%% title: Point-to-point pattern
flowchart TD
  P@{ icon: "sd:server", label: "Producer" } --> Q@{ icon: "sd:queue", label: "QUEUE<br/>M1 · M2 · M3" }
  Q -->|"M1 (removed from queue)"| CA@{ icon: "sd:worker", label: "Consumer A" }
  Q -->|"M2 (removed from queue)"| CB@{ icon: "sd:worker", label: "Consumer B" }
  Q -->|"M3 (removed from queue)"| CC@{ icon: "sd:worker", label: "Consumer C" }
  class P client
  class Q queue
  class CA,CB,CC service

Each message is processed by exactly ONE consumer. Consumers compete for messages.

How it works:

  1. Producer sends message to queue
  2. Broker holds message until a consumer is available
  3. One consumer receives and processes the message
  4. Consumer acknowledges completion
  5. Message is removed from queue

Pros:

  • Simple mental model
  • Natural load balancing across consumers
  • Easy to scale by adding consumers
  • Built-in work distribution

Cons:

  • Message is gone after processing (no replay)
  • Only one consumer per message
  • Harder to add new consumers that need historical data

Use when:

  • You have background jobs to process
  • Work needs to be distributed across workers
  • You don't need to replay or re-read messages

2.2 Pattern 2: Publish-Subscribe (Fan-Out)#

In pub-sub, each message is delivered to ALL subscribers.

DiagramPublish-subscribe pattern
%% title: Publish-subscribe pattern
flowchart TD
  P@{ icon: "sd:server", label: "Publisher" } --> T@{ icon: "sd:stream", label: "TOPIC<br/>[Message]" }
  T --> SA@{ icon: "sd:mail", label: "Subscr. A<br/>(Email)" }
  T --> SB@{ icon: "sd:phone", label: "Subscr. B<br/>(Mobile)" }
  T --> SC@{ icon: "sd:chart", label: "Subscr. C<br/>(Analytics)" }
  class P client
  class T queue
  class SA,SB,SC service

Same message delivered to ALL subscribers. Each subscriber gets a copy.

How it works:

  1. Publisher sends message to topic
  2. Each subscriber receives a copy of the message
  3. Subscribers process independently
  4. Message may or may not be retained (depends on implementation)

Pros:

  • Easy to add new subscribers
  • Decouples publishers from subscribers
  • Same event can trigger multiple actions

Cons:

  • All subscribers must handle the load
  • Can't easily distribute work within a subscriber type

Use when:

  • Multiple systems need to react to the same event
  • You're building event-driven architecture
  • Adding new capabilities shouldn't require changing existing systems
Your turn · checkpoint 1not answered

Your design: three teams, one stream of orders

Order processing runs three workers that must share the load, so each order is handled once. Analytics needs every order event, and so does audit. Point-to-point gives each message to one consumer; pub-sub gives every subscriber a copy. Which do you need, and what must the topic have so the three order workers can all be busy?

Think it through, or jot it down. Then compare.

One strong answer

  • Neither alone: point-to-point starves analytics and audit; pub-sub makes all three order workers handle every order.
  • Consumer groups: each group (orders, analytics, audit) gets every message; inside a group, partitions are divided among its consumers.
  • The topic needs at least 3 partitions, because a partition goes to one consumer per group. With fewer, some order workers sit idle.

2.3 Pattern 3: Consumer Groups (Partitioned Stream)#

Consumer groups combine the best of both: fan-out ACROSS groups, load balancing WITHIN groups.

DiagramConsumer groups pattern
%% title: Consumer groups pattern
flowchart TD
  PR@{ icon: "sd:server", label: "Producer" } --> T
  subgraph T["TOPIC"]
    direction TB
    P0@{ icon: "sd:stream", label: "Partition 0: M1 · M4 · M7" }
    P1@{ icon: "sd:stream", label: "Partition 1: M2 · M5 · M8" }
    P2@{ icon: "sd:stream", label: "Partition 2: M3 · M6 · M9" }
  end
  subgraph GA["Consumer Group A (Orders)"]
    A1["C1"]:::core
    A2["C2"]:::core
    A3["C3"]:::core
  end
  subgraph GB["Consumer Group B (Analytics)"]
    B1["C1"]:::service
    B2["C2"]:::service
  end
  subgraph GC["Consumer Group C (Audit)"]
    C1["C1"]:::store
  end
  P0 --> A1
  P1 --> A2
  P2 --> A3
  P0 & P1 --> B1
  P2 --> B2
  P0 & P1 & P2 --> C1
  class PR client
  class P0,P1,P2 queue

This is Kafka's killer feature.

  • Each GROUP gets ALL messages (fan-out)
  • Within a group, messages are DIVIDED (load balance)
  • Each partition goes to exactly one consumer per group

How it works:

  1. Topic is divided into partitions
  2. Each consumer group gets all messages
  3. Within a group, each partition is assigned to one consumer
  4. Messages within a partition are processed in order
  5. Parallelism = number of partitions
ExplainerKafka partitions and a consumer-group rebalance

A topic's partitions keep their messages in order; within a consumer group each partition goes to exactly one consumer. When consumer C joins, a rebalance stops fetching and reassigns partitions (A: 0-1, B: 2-3, C: 4-5); consumers beyond the partition count sit idle.

Pros:

  • Fan-out to multiple consumer groups
  • Load balancing within each group
  • Ordering guaranteed within partition
  • Replay possible (rewind offset)

Cons:

  • More complex to set up and operate
  • Partition count limits parallelism
  • Rebalancing can cause delays

Use when:

  • Multiple systems need the same events
  • You need both fan-out AND parallel processing
  • Ordering within a key (e.g., per user) matters

2.4 Pattern Comparison#

Aspect Point-to-Point Pub-Sub Consumer Groups
Message delivery One consumer All subscribers All groups, one per group
Load balancing Automatic None Within group
Ordering FIFO (usually) Per subscriber Per partition
Replay No Depends Yes
Use case Job queue Event notification Event streaming
Example RabbitMQ queue SNS topic Kafka topic

Chapter 3: Trade-offs and Considerations#

3.1 Trade-off Matrix#

QUEUE VS STREAM TRADE-OFFS

QUEUE (RabbitMQ, SQS)

  • ok — Simpler mental model
  • ok — Message-level acknowledgment
  • ok — Built-in retry and dead-letter support
  • ok — Flexible routing (exchanges, bindings)
  • no — No replay capability
  • no — Single consumer per message
  • no — Message loss if broker crashes before persist

Best for: Job queues, task distribution, RPC patterns

STREAM (Kafka, Kinesis)

  • ok — Replay capability (rewind offset)
  • ok — Multiple consumer groups
  • ok — High throughput (batching, zero-copy)
  • ok — Durable storage (configurable retention)
  • no — More complex operations
  • no — Partition count limits parallelism
  • no — Consumer must track offset

Best for: Event sourcing, analytics, multi-consumer fan-out

Your turn · checkpoint 2not answered

What breaks if the consumer dies before its ack?

A consumer takes an order message, inserts the order and sends the confirmation email, then crashes before the broker hears its ack. What does the broker do next? And what would happen instead if the consumer had acked as soon as it received the message, before doing the work?

Think it through, or jot it down. Then compare.

One strong answer

  • Ack after the work: the broker never got the ack, so it redelivers. The order is inserted twice and two emails go out: at-least-once, with duplicates.
  • Ack before the work: the broker forgets the message, the crash loses it, and the order is never processed: at-most-once.
  • The usual choice: ack after the work and make the consumer idempotent, so a redelivery does no harm.

3.2 Delivery Guarantees#

This is where things get nuanced. What happens if something fails?

DiagramDelivery guarantees
%% title: Delivery guarantees
flowchart TD
  subgraph AMO["AT-MOST-ONCE"]
    direction TB
    P1["Producer"]:::client --> B1[["Broker"]]:::queue
    B1 --> C1["Consumer"]:::service
    C1 --> X1["Crash!<br/>Message lost"]:::bad
  end
  subgraph ALO["AT-LEAST-ONCE"]
    direction TB
    P2["Producer"]:::client --> B2[["Broker"]]:::queue
    B2 --> C2["Consumer"]:::service
    C2 --> X2["Processed but ack lost<br/>Retry = duplicate"]:::bad
  end
  AMO ~~~ ALO

At-most-once

  • Message may be lost
  • Message will NOT be duplicated
  • Implementation: Fire and forget, no ack
  • Use when: Loss acceptable (metrics, logs, non-critical)

At-least-once

  • Message will NOT be lost
  • Message MAY be duplicated
  • Implementation: Ack after processing, retry on failure
  • Use when: Loss unacceptable, can handle duplicates

Exactly-once

  • Message will NOT be lost
  • Message will NOT be duplicated
  • Implementation: Transactional processing + deduplication
  • Reality: Very hard. Usually "effectively once" via idempotency.

This is often a LIE or requires special support from both the broker AND the consumer. Kafka 0.11+ supports it with transactions, but your consumer must participate.

The practical reality:

Most systems use at-least-once delivery with idempotent consumers. This is what you learned in Week 2, Day 2 (Idempotency). The broker guarantees delivery, and your consumer handles duplicates.

3.3 Ordering Guarantees#

When do messages arrive in order?

ORDERING GUARANTEES

GLOBAL ORDERING — all messages in order

[M1] → [M2] → [M3] → [M4] → [M5]

  • Single partition/queue
  • Single consumer
  • No parallelism possible
  • Throughput limited

PARTITION ORDERING — order within partition

  • Partition 0: [M1] → [M3] → [M5] (User A's messages in order)
  • Partition 1: [M2] → [M4] → [M6] (User B's messages in order)

  • Messages with same key go to same partition
  • Order guaranteed within partition
  • Parallelism = number of partitions
  • This is Kafka's model

NO ORDERING

Messages processed in any order

  • Maximum parallelism
  • Consumer must handle out-of-order
  • Simpler infrastructure

Common pattern: Use partition key to ensure related messages stay together.

python9 lines
# Example: Order events for same user stay in order
producer.send(
    topic="orders",
    key=user_id,        # Partition key
    value=order_event
)

# All events for user_123 go to same partition
# Processed in order by one consumer
Your turn · checkpoint 3not answered

Queue or stream? Three jobs to place

Pick a queue or a stream for each, and say why: (1) making image thumbnails, one kind of worker, nobody else cares; (2) order events that email, the warehouse and analytics all react to; (3) rerunning last week's click events through a new analytics model.

Think it through, or jot it down. Then compare.

One strong answer

  • Thumbnails: queue. Each image is a job for one worker, and nobody needs it again once it's done. A stream adds partitions and offsets for nothing.
  • Order events: stream. Several systems need the same events, so each gets its own consumer group.
  • Rerunning last week: stream. Only a log keeps events after they're read; a queue deleted them when they were consumed.

3.4 Decision Framework#

When choosing between queue and stream:

DiagramDecision framework
%% title: Decision framework
flowchart TD
  S(["START HERE"]) --> Q1{"Do you need to<br/>replay messages?"}
  Q1 -->|"YES"| ST1(["STREAM"]):::queue
  Q1 -->|"NO"| Q2{"Do multiple systems<br/>need the same data?"}
  Q2 -->|"YES"| ST2(["STREAM"]):::queue
  Q2 -->|"NO"| Q3{"Is this a job/task<br/>to be processed?"}
  Q3 -->|"YES"| QU(["QUEUE"]):::core
  Q3 -->|"NO"| ST3(["STREAM"]):::queue

Summary:

  • Need replay? → Stream
  • Multiple consumers need same data? → Stream
  • Task/job processing? → Queue
  • Event notification? → Stream (or pub-sub)
  • Uncertain? → Start with queue (simpler), migrate if needed
Quick check4 questions · +5 XP each

Queues, streams and consumer groups

  1. Q1/4 A Kafka topic has 6 partitions. You scale its consumer group from 6 to 8 consumers. What happens?

    Answer & explanation

    Answer: B

    Within a group, parallelism equals the partition count. Extra consumers only help as standbys; you need more partitions to go faster.

  2. Q2/4 The analytics team wants to rerun last week's order events through new logic, while order processing keeps going. Which model fits?

    Answer & explanation

    Answer: A

    Replay and multiple independent readers are exactly what streams are for. A queue removes messages once consumed, so last week's events are gone.

  3. Q3/4 order.created, order.paid and order.shipped must be processed in order for each order. What should the producer do?

    Answer & explanation

    Answer: C

    Same key, same partition, same order. A single partition also preserves order, but it throws away all parallelism.

  4. Q4/4 With at-least-once delivery, a consumer finishes processing a message but its ack never reaches the broker. What happens next?

    Answer & explanation

    Answer: B

    No ack means "not processed" as far as the broker knows, so it redelivers. That is why at-least-once is paired with idempotent consumers.

Part II · Stage 2 of 4 · 17 min

Part II: Implementation#

Chapter 4: Basic Implementation#

4.1 Simple Queue with Redis#

Let's start with the simplest possible queue using Redis. This is educational—not production-ready.

pythonSimpleRedisQueue · 71 lines
# Basic Queue Implementation with Redis
# WARNING: Not production-ready - for learning only

import redis
import json
from typing import Any, Optional

class SimpleRedisQueue:
    """
    Basic queue using Redis lists.
    Messages are pushed to the right, popped from the left (FIFO).
    """
    
    def __init__(self, redis_url: str, queue_name: str):
        self.redis = redis.from_url(redis_url)
        self.queue_name = queue_name
    
    def publish(self, message: dict) -> None:
        """
        Add a message to the queue.
        """
        serialized = json.dumps(message)
        self.redis.rpush(self.queue_name, serialized)
    
    def consume(self, timeout: int = 0) -> Optional[dict]:
        """
        Get and remove a message from the queue.
        Blocks until a message is available or timeout.
        
        Args:
            timeout: Seconds to wait (0 = wait forever)
            
        Returns:
            Message dict or None if timeout
        """
        # BLPOP blocks until message available
        result = self.redis.blpop(self.queue_name, timeout=timeout)
        
        if result is None:
            return None
        
        _, message = result
        return json.loads(message)


# Usage example
def producer_example():
    queue = SimpleRedisQueue("redis://localhost:6379", "orders")
    
    # Publish some orders
    queue.publish({"order_id": "123", "user": "alice", "amount": 99.99})
    queue.publish({"order_id": "124", "user": "bob", "amount": 49.99})
    
    print("Published 2 orders")


def consumer_example():
    queue = SimpleRedisQueue("redis://localhost:6379", "orders")
    
    while True:
        message = queue.consume(timeout=5)
        
        if message is None:
            print("No messages, waiting...")
            continue
        
        print(f"Processing order: {message['order_id']}")
        # Process the order...
        
        # NOTE: If we crash here, message is LOST!
        # No acknowledgment, no retry.

What's wrong with this implementation?

  1. No acknowledgment: If consumer crashes after BLPOP but before processing, message is lost
  2. No retry: Failed messages disappear forever
  3. No dead letter queue: No way to handle poison pills
  4. No visibility timeout: Can't handle slow consumers

4.2 Simple Stream with Kafka (Conceptual)#

Here's a conceptual Kafka producer/consumer:

pythonSimpleKafkaProducer · 80 lines
# Basic Kafka Producer/Consumer
# WARNING: Not production-ready - for learning only

from kafka import KafkaProducer, KafkaConsumer
import json

class SimpleKafkaProducer:
    """
    Basic Kafka producer.
    """
    
    def __init__(self, bootstrap_servers: str):
        self.producer = KafkaProducer(
            bootstrap_servers=bootstrap_servers,
            value_serializer=lambda v: json.dumps(v).encode('utf-8'),
            key_serializer=lambda k: k.encode('utf-8') if k else None
        )
    
    def publish(self, topic: str, message: dict, key: str = None) -> None:
        """
        Send message to topic.
        Key determines which partition receives the message.
        """
        self.producer.send(topic, value=message, key=key)
        self.producer.flush()  # Wait for send to complete


class SimpleKafkaConsumer:
    """
    Basic Kafka consumer.
    """
    
    def __init__(self, bootstrap_servers: str, topic: str, group_id: str):
        self.consumer = KafkaConsumer(
            topic,
            bootstrap_servers=bootstrap_servers,
            group_id=group_id,
            auto_offset_reset='earliest',  # Start from beginning if new
            enable_auto_commit=True,        # Automatically commit offsets
            value_deserializer=lambda v: json.loads(v.decode('utf-8'))
        )
    
    def consume(self):
        """
        Yield messages from the topic.
        """
        for message in self.consumer:
            yield {
                'topic': message.topic,
                'partition': message.partition,
                'offset': message.offset,
                'key': message.key.decode('utf-8') if message.key else None,
                'value': message.value
            }


# Usage example
def kafka_producer_example():
    producer = SimpleKafkaProducer("localhost:9092")
    
    # Messages with same key go to same partition (ordered)
    producer.publish("orders", {"order_id": "123", "action": "created"}, key="user_alice")
    producer.publish("orders", {"order_id": "123", "action": "paid"}, key="user_alice")
    producer.publish("orders", {"order_id": "124", "action": "created"}, key="user_bob")


def kafka_consumer_example():
    consumer = SimpleKafkaConsumer(
        "localhost:9092", 
        "orders", 
        group_id="order-processor"
    )
    
    for message in consumer.consume():
        print(f"Partition {message['partition']}, Offset {message['offset']}")
        print(f"Key: {message['key']}, Value: {message['value']}")
        # Process the message...
        
        # NOTE: With auto_commit=True, offset is committed automatically
        # If we crash, we might reprocess (at-least-once)

4.3 Understanding the Flow#

DiagramKafka message flow
%% title: Kafka message flow
flowchart TD
  S1["Step 1: Producer sends with key<br/>producer.send(#quot;orders#quot;, key=#quot;user_123#quot;, value={...})"]:::client
  S2["Step 2: Key is hashed to determine partition<br/>partition = hash(#quot;user_123#quot;) % num_partitions<br/>partition = 2847293847 % 6 = 3"]:::core
  S3[["Step 3: Message appended to partition log<br/>Partition 3: ... · msg · msg · NEW MSG<br/>NEW MSG at offset 847"]]:::queue
  S4["Step 4: Consumer group assigns partitions<br/>Consumer Group #quot;order-processor#quot;<br/>Consumer A: Partitions 0, 1<br/>Consumer B: Partitions 2, 3 ← Gets partition 3<br/>Consumer C: Partitions 4, 5"]:::service
  S5["Step 5: Consumer reads and commits offset<br/>Consumer B reads offset 847<br/>Processes message<br/>Commits offset 848 (next to read)"]:::ok
  S1 --> S2 --> S3 --> S4 --> S5

Chapter 5: Production-Ready Implementation#

5.1 Requirements for Production#

Our production implementation needs:

  1. Reliable delivery — No message loss
  2. Acknowledgment — Confirm processing before removing
  3. Retry with backoff — Handle transient failures
  4. Dead letter queue — Handle permanent failures
  5. Monitoring — Track lag, errors, throughput
  6. Graceful shutdown — Don't lose in-flight messages

5.2 Production Kafka Consumer#

pythonProcessingResult · 314 lines
# Production-Ready Kafka Consumer
# With proper error handling, retries, and monitoring

import asyncio
from dataclasses import dataclass, field
from typing import Callable, Optional, Dict, Any, List
from datetime import datetime, timedelta, timezone
from enum import Enum
import logging
import json
from contextlib import asynccontextmanager

from aiokafka import AIOKafkaConsumer, AIOKafkaProducer
from aiokafka.errors import KafkaError

logger = logging.getLogger(__name__)


class ProcessingResult(Enum):
    SUCCESS = "success"
    RETRY = "retry"           # Transient failure, retry later
    DEAD_LETTER = "dead_letter"  # Permanent failure, send to DLQ


@dataclass
class ConsumerConfig:
    """Configuration for Kafka consumer."""
    bootstrap_servers: str
    topic: str
    group_id: str
    
    # Retry configuration
    max_retries: int = 3
    retry_backoff_ms: int = 1000
    max_retry_backoff_ms: int = 30000
    
    # Consumer configuration
    auto_offset_reset: str = "earliest"
    enable_auto_commit: bool = False  # Manual commit for reliability
    max_poll_records: int = 100
    session_timeout_ms: int = 30000
    
    # Dead letter topic
    dead_letter_topic: Optional[str] = None


@dataclass
class MessageContext:
    """Context for message processing."""
    topic: str
    partition: int
    offset: int
    key: Optional[str]
    value: Dict[str, Any]
    timestamp: datetime
    headers: Dict[str, str] = field(default_factory=dict)
    retry_count: int = 0


class KafkaConsumerService:
    """
    Production-ready Kafka consumer with:
    - Manual offset commits (at-least-once delivery)
    - Retry with exponential backoff
    - Dead letter queue for failed messages
    - Graceful shutdown
    - Metrics and logging
    """
    
    def __init__(
        self,
        config: ConsumerConfig,
        message_handler: Callable[[MessageContext], ProcessingResult]
    ):
        self.config = config
        self.message_handler = message_handler
        self.consumer: Optional[AIOKafkaConsumer] = None
        self.dlq_producer: Optional[AIOKafkaProducer] = None
        self._running = False
        self._shutdown_event = asyncio.Event()
        
        # Metrics
        self.messages_processed = 0
        self.messages_failed = 0
        self.messages_to_dlq = 0
    
    async def start(self) -> None:
        """Start the consumer."""
        logger.info(f"Starting consumer for topic {self.config.topic}")
        
        self.consumer = AIOKafkaConsumer(
            self.config.topic,
            bootstrap_servers=self.config.bootstrap_servers,
            group_id=self.config.group_id,
            auto_offset_reset=self.config.auto_offset_reset,
            enable_auto_commit=self.config.enable_auto_commit,
            max_poll_records=self.config.max_poll_records,
            session_timeout_ms=self.config.session_timeout_ms,
            value_deserializer=lambda v: json.loads(v.decode('utf-8')),
            key_deserializer=lambda k: k.decode('utf-8') if k else None
        )
        
        await self.consumer.start()
        
        # Start DLQ producer if configured
        if self.config.dead_letter_topic:
            self.dlq_producer = AIOKafkaProducer(
                bootstrap_servers=self.config.bootstrap_servers,
                value_serializer=lambda v: json.dumps(v).encode('utf-8'),
                key_serializer=lambda k: k.encode('utf-8') if k else None
            )
            await self.dlq_producer.start()
        
        self._running = True
        logger.info(f"Consumer started for topic {self.config.topic}")
    
    async def stop(self) -> None:
        """Gracefully stop the consumer."""
        logger.info("Stopping consumer...")
        self._running = False
        self._shutdown_event.set()
        
        if self.consumer:
            await self.consumer.stop()
        
        if self.dlq_producer:
            await self.dlq_producer.stop()
        
        logger.info(f"Consumer stopped. Processed: {self.messages_processed}, "
                   f"Failed: {self.messages_failed}, DLQ: {self.messages_to_dlq}")
    
    async def run(self) -> None:
        """Main consumer loop."""
        try:
            async for message in self.consumer:
                if not self._running:
                    break
                
                ctx = MessageContext(
                    topic=message.topic,
                    partition=message.partition,
                    offset=message.offset,
                    key=message.key,
                    value=message.value,
                    timestamp=datetime.fromtimestamp(message.timestamp / 1000, timezone.utc),
                    headers={k: v.decode('utf-8') for k, v in (message.headers or [])},
                    retry_count=self._get_retry_count(message.headers)
                )
                
                await self._process_message(ctx)
                
        except asyncio.CancelledError:
            logger.info("Consumer loop cancelled")
        except Exception as e:
            logger.error(f"Consumer loop error: {e}", exc_info=True)
            raise
    
    async def _process_message(self, ctx: MessageContext) -> None:
        """Process a single message with retry logic."""
        try:
            result = await self._execute_handler(ctx)
            
            if result == ProcessingResult.SUCCESS:
                # Commit offset on success
                await self.consumer.commit()
                self.messages_processed += 1
                logger.debug(f"Processed message at offset {ctx.offset}")
                
            elif result == ProcessingResult.RETRY:
                await self._handle_retry(ctx)
                
            elif result == ProcessingResult.DEAD_LETTER:
                await self._send_to_dlq(ctx, "Handler returned DEAD_LETTER")
                await self.consumer.commit()
                
        except Exception as e:
            logger.error(f"Error processing message: {e}", exc_info=True)
            await self._handle_retry(ctx)
    
    async def _execute_handler(self, ctx: MessageContext) -> ProcessingResult:
        """Execute the message handler with error handling."""
        try:
            # Handler might be sync or async
            result = self.message_handler(ctx)
            if asyncio.iscoroutine(result):
                result = await result
            return result
        except Exception as e:
            logger.warning(f"Handler raised exception: {e}")
            return ProcessingResult.RETRY
    
    async def _handle_retry(self, ctx: MessageContext) -> None:
        """Handle message retry with exponential backoff."""
        if ctx.retry_count >= self.config.max_retries:
            logger.warning(f"Max retries ({self.config.max_retries}) exceeded "
                          f"for message at offset {ctx.offset}")
            await self._send_to_dlq(ctx, f"Max retries exceeded after {ctx.retry_count} attempts")
            await self.consumer.commit()
            return
        
        # Calculate backoff
        backoff_ms = min(
            self.config.retry_backoff_ms * (2 ** ctx.retry_count),
            self.config.max_retry_backoff_ms
        )
        
        logger.info(f"Retrying message at offset {ctx.offset} "
                   f"(attempt {ctx.retry_count + 1}/{self.config.max_retries}) "
                   f"after {backoff_ms}ms")
        
        await asyncio.sleep(backoff_ms / 1000)
        
        # Re-process with incremented retry count
        ctx.retry_count += 1
        await self._process_message(ctx)
    
    async def _send_to_dlq(self, ctx: MessageContext, reason: str) -> None:
        """Send failed message to dead letter queue."""
        if not self.dlq_producer or not self.config.dead_letter_topic:
            logger.warning(f"No DLQ configured, dropping message at offset {ctx.offset}")
            self.messages_failed += 1
            return
        
        dlq_message = {
            "original_topic": ctx.topic,
            "original_partition": ctx.partition,
            "original_offset": ctx.offset,
            "original_key": ctx.key,
            "original_value": ctx.value,
            "original_timestamp": ctx.timestamp.isoformat(),
            "failure_reason": reason,
            "retry_count": ctx.retry_count,
            "failed_at": datetime.now(timezone.utc).isoformat()
        }
        
        await self.dlq_producer.send_and_wait(
            self.config.dead_letter_topic,
            value=dlq_message,
            key=ctx.key
        )
        
        self.messages_to_dlq += 1
        logger.info(f"Sent message at offset {ctx.offset} to DLQ: {reason}")
    
    def _get_retry_count(self, headers: Optional[List]) -> int:
        """Extract retry count from message headers."""
        if not headers:
            return 0
        for key, value in headers:
            if key == "retry_count":
                return int(value.decode('utf-8'))
        return 0


# =============================================================================
# Example Usage
# =============================================================================

async def order_handler(ctx: MessageContext) -> ProcessingResult:
    """Example message handler for order events."""
    try:
        order = ctx.value
        logger.info(f"Processing order {order.get('order_id')} "
                   f"for user {ctx.key}")
        
        # Simulate processing
        if order.get("amount", 0) < 0:
            # Invalid order - send to DLQ
            logger.warning(f"Invalid order amount: {order.get('amount')}")
            return ProcessingResult.DEAD_LETTER
        
        if order.get("action") == "fail_temporary":
            # Simulate transient failure
            return ProcessingResult.RETRY
        
        # Process the order...
        await asyncio.sleep(0.1)  # Simulate work
        
        return ProcessingResult.SUCCESS
        
    except KeyError as e:
        # Missing required field - permanent failure
        logger.error(f"Missing required field: {e}")
        return ProcessingResult.DEAD_LETTER
    except Exception as e:
        # Unknown error - retry
        logger.error(f"Processing error: {e}")
        return ProcessingResult.RETRY


async def main():
    """Example main function."""
    config = ConsumerConfig(
        bootstrap_servers="localhost:9092",
        topic="orders",
        group_id="order-processor",
        dead_letter_topic="orders-dlq",
        max_retries=3,
        retry_backoff_ms=1000
    )
    
    consumer = KafkaConsumerService(config, order_handler)
    
    try:
        await consumer.start()
        await consumer.run()
    except KeyboardInterrupt:
        pass
    finally:
        await consumer.stop()


if __name__ == "__main__":
    asyncio.run(main())

5.3 Production Kafka Producer#

pythonProducerConfig · 187 lines
# Production-Ready Kafka Producer
# With batching, retries, and delivery confirmation

import asyncio
from dataclasses import dataclass
from typing import Optional, Dict, Any, Callable
import logging
import json
from datetime import datetime, timezone

from aiokafka import AIOKafkaProducer
from aiokafka.errors import KafkaError

logger = logging.getLogger(__name__)


@dataclass
class ProducerConfig:
    """Configuration for Kafka producer."""
    bootstrap_servers: str
    
    # Reliability settings
    acks: str = "all"                    # Wait for all replicas
    request_timeout_ms: int = 30000      # aiokafka retries a send until this timeout
    retry_backoff_ms: int = 100          # Backoff between retries
    
    # Batching settings (for throughput)
    linger_ms: int = 5                   # Wait up to 5ms to batch
    batch_size: int = 16384              # 16KB batch size
    
    # Idempotence (prevent duplicates on retry)
    enable_idempotence: bool = True


class KafkaProducerService:
    """
    Production-ready Kafka producer with:
    - Idempotent delivery (no duplicates)
    - Delivery confirmation
    - Batching for throughput
    - Proper error handling
    """
    
    def __init__(self, config: ProducerConfig):
        self.config = config
        self.producer: Optional[AIOKafkaProducer] = None
        
        # Metrics
        self.messages_sent = 0
        self.messages_failed = 0
    
    async def start(self) -> None:
        """Start the producer."""
        self.producer = AIOKafkaProducer(
            bootstrap_servers=self.config.bootstrap_servers,
            acks=self.config.acks,
            request_timeout_ms=self.config.request_timeout_ms,
            retry_backoff_ms=self.config.retry_backoff_ms,
            linger_ms=self.config.linger_ms,
            max_batch_size=self.config.batch_size,
            enable_idempotence=self.config.enable_idempotence,
            value_serializer=lambda v: json.dumps(v).encode('utf-8'),
            key_serializer=lambda k: k.encode('utf-8') if k else None
        )
        await self.producer.start()
        logger.info("Producer started")
    
    async def stop(self) -> None:
        """Stop the producer, flushing pending messages."""
        if self.producer:
            await self.producer.stop()
        logger.info(f"Producer stopped. Sent: {self.messages_sent}, "
                   f"Failed: {self.messages_failed}")
    
    async def send(
        self,
        topic: str,
        value: Dict[str, Any],
        key: Optional[str] = None,
        headers: Optional[Dict[str, str]] = None
    ) -> bool:
        """
        Send a message to Kafka.
        
        Args:
            topic: Target topic
            value: Message payload
            key: Partition key (messages with same key go to same partition)
            headers: Optional message headers
            
        Returns:
            True if sent successfully, False otherwise
        """
        try:
            # Convert headers to Kafka format
            kafka_headers = None
            if headers:
                kafka_headers = [(k, v.encode('utf-8')) for k, v in headers.items()]
            
            # Send and wait for acknowledgment
            result = await self.producer.send_and_wait(
                topic,
                value=value,
                key=key,
                headers=kafka_headers
            )
            
            self.messages_sent += 1
            logger.debug(f"Sent message to {topic}:{result.partition}@{result.offset}")
            return True
            
        except KafkaError as e:
            self.messages_failed += 1
            logger.error(f"Failed to send message: {e}")
            return False
    
    async def send_batch(
        self,
        topic: str,
        messages: list[tuple[Optional[str], Dict[str, Any]]]
    ) -> int:
        """
        Send multiple messages efficiently.
        
        Args:
            topic: Target topic
            messages: List of (key, value) tuples
            
        Returns:
            Number of successfully sent messages
        """
        tasks = []
        for key, value in messages:
            # Don't wait for each message individually
            future = await self.producer.send(topic, value=value, key=key)
            tasks.append(future)
        
        # Wait for all to complete
        results = await asyncio.gather(*tasks, return_exceptions=True)
        
        success_count = sum(1 for r in results if not isinstance(r, Exception))
        self.messages_sent += success_count
        self.messages_failed += len(results) - success_count
        
        return success_count


# =============================================================================
# Example Usage
# =============================================================================

async def producer_example():
    """Example of using the producer."""
    config = ProducerConfig(
        bootstrap_servers="localhost:9092",
        acks="all",
        enable_idempotence=True
    )
    
    producer = KafkaProducerService(config)
    await producer.start()
    
    try:
        # Send single message
        await producer.send(
            topic="orders",
            key="user_123",
            value={
                "order_id": "order_456",
                "action": "created",
                "amount": 99.99,
                "timestamp": datetime.now(timezone.utc).isoformat()
            },
            headers={"source": "api", "version": "1"}
        )
        
        # Send batch
        messages = [
            ("user_123", {"order_id": "order_457", "action": "created"}),
            ("user_456", {"order_id": "order_458", "action": "created"}),
            ("user_789", {"order_id": "order_459", "action": "created"}),
        ]
        sent = await producer.send_batch("orders", messages)
        print(f"Sent {sent} messages")
        
    finally:
        await producer.stop()

Chapter 6: Edge Cases and Error Handling#

6.1 Edge Case 1: Consumer Crashes Mid-Processing#

DiagramConsumer crashes after reading but before committing
%% title: Consumer crashes after reading but before committing
flowchart TD
  R["Consumer reads message at offset 100"]:::service --> SP["Consumer starts processing"]:::service
  SP --> CR["CRASH!<br/>Process dies"]:::bad
  CR --> NC["Offset 100 NOT committed<br/>(committed position still 100)"]:::bad
  NC --> NS["New consumer starts, reads offset 100 again"]:::service
  NS --> RP["MESSAGE IS REPROCESSED<br/>(at-least-once)"]:::hot

Solution: Consumer must be IDEMPOTENT

  • Option 1: Deduplicate by message ID
  • Option 2: Use database transaction with offset
  • Option 3: Make processing naturally idempotent

Solution: Idempotent Consumer

pythonIdempotentOrderProcessor · 44 lines
class IdempotentOrderProcessor:
    """
    Process orders idempotently using a processed_ids table.
    """
    
    def __init__(self, db_pool):
        self.db = db_pool
    
    async def process_order(self, ctx: MessageContext) -> ProcessingResult:
        """Process order with idempotency check."""
        order = ctx.value
        message_id = f"{ctx.topic}:{ctx.partition}:{ctx.offset}"
        
        async with self.db.acquire() as conn:
            async with conn.transaction():
                # Check if already processed
                existing = await conn.fetchval(
                    "SELECT 1 FROM processed_messages WHERE message_id = $1",
                    message_id
                )
                
                if existing:
                    logger.info(f"Message {message_id} already processed, skipping")
                    return ProcessingResult.SUCCESS
                
                # Process the order
                await conn.execute(
                    """
                    INSERT INTO orders (order_id, user_id, amount, status)
                    VALUES ($1, $2, $3, 'created')
                    """,
                    order['order_id'], order['user_id'], order['amount']
                )
                
                # Mark as processed (in same transaction!)
                await conn.execute(
                    """
                    INSERT INTO processed_messages (message_id, processed_at)
                    VALUES ($1, NOW())
                    """,
                    message_id
                )
        
        return ProcessingResult.SUCCESS
Your turn · checkpoint 4not answered

What breaks when a third consumer joins?

Consumers A and B split six partitions, and A is halfway through a batch from partition 2 that it hasn't committed. Consumer C joins the group, and partition 2 moves to B. What does B read first, and what happens to A's half-done batch?

Think it through, or jot it down. Then compare.

One strong answer

  • B starts from the last committed offset of partition 2, because that's all the group knows about it.
  • A's uncommitted work is done again by B: duplicates if A's writes landed, more lost time if they didn't.
  • The fixes: commit often, commit in a listener just before partitions are revoked, and use cooperative rebalancing so fewer partitions move at once.

6.2 Edge Case 2: Consumer Rebalance#

SCENARIO: Partition reassignment during processing
Consumer A Consumer B Consumer C
BEFORE Partitions 0, 1, 2 Partitions 3, 4, 5 —
AFTER Partitions 0, 1 Partitions 2, 3 Partitions 4, 5

Consumer C joins the group... REBALANCE TRIGGERED:

  1. All consumers stop fetching
  2. Partitions are reassigned
  3. Uncommitted offsets may be lost

PROBLEM: Consumer A was processing partition 2 during rebalance. Work may be lost or duplicated.

SOLUTIONS
  1. Commit offsets frequently
  2. Use cooperative rebalancing (Kafka 2.4+)
  3. Implement rebalance listener to commit before revoke

Solution: Rebalance Listener

pythonGracefulRebalanceListener · 31 lines
from aiokafka import ConsumerRebalanceListener

class GracefulRebalanceListener(ConsumerRebalanceListener):
    """
    Handle partition rebalance gracefully.
    Commit offsets before partitions are revoked.
    """
    
    def __init__(self, consumer: AIOKafkaConsumer, processor):
        self.consumer = consumer
        self.processor = processor
    
    async def on_partitions_revoked(self, revoked):
        """Called before partitions are taken away."""
        logger.info(f"Partitions being revoked: {revoked}")
        
        # Finish any in-progress work
        await self.processor.finish_current_batch()
        
        # Commit current offsets
        await self.consumer.commit()
        
        logger.info("Committed offsets before rebalance")
    
    async def on_partitions_assigned(self, assigned):
        """Called after new partitions are assigned."""
        logger.info(f"Partitions assigned: {assigned}")
        
        # Optionally seek to specific offsets
        # for tp in assigned:
        #     await self.consumer.seek(tp, specific_offset)
Your turn · checkpoint 5not answered

Estimate: how far behind after an hour?

Producers write 10,000 messages a second; your consumers manage 1,000. How many messages behind are you after one hour? If each consumer handles 1,000 a second, how many consumers do you need to keep up, and what else must the topic have?

Think it through, or jot it down. Then compare.

One strong answer

  • Lag grows by 10,000 − 1,000 = 9,000 a second, so after 3,600 s you're 32.4 million messages behind.
  • Keeping up takes at least 10 consumers, and more to ever catch up on the backlog.
  • The topic needs at least 10 partitions, because one partition goes to one consumer in a group. Extra consumers beyond the partition count sit idle.

6.3 Edge Case 3: Slow Consumer (Consumer Lag)#

SCENARIO: Consumer can't keep up with producer
  • Producer rate: 10,000 messages/sec
  • Consumer rate: 1,000 messages/sec
  • Lag grows by 9,000 messages every second!
  • After 1 hour: 32,400,000 messages behind
SYMPTOMS
  • Consumer lag metric increasing
  • Lagging reads fall out of the page cache onto disk (and unread data can age out of retention)
  • Data becoming "stale" before processing
SOLUTIONS
  1. Add more consumers (up to partition count)
  2. Increase partition count (possible in place, but keys remap to new partitions, so per-key ordering breaks during the change)
  3. Optimize consumer processing
  4. Use batch processing
  5. Implement backpressure on producer

Solution: Monitor and Alert on Lag

pythonLagMetrics · 74 lines
from dataclasses import dataclass
from typing import Dict
import asyncio

@dataclass
class LagMetrics:
    partition: int
    current_offset: int
    end_offset: int
    lag: int


class ConsumerLagMonitor:
    """Monitor consumer lag and alert when thresholds exceeded."""
    
    def __init__(
        self,
        consumer: AIOKafkaConsumer,
        warning_threshold: int = 10000,
        critical_threshold: int = 100000
    ):
        self.consumer = consumer
        self.warning_threshold = warning_threshold
        self.critical_threshold = critical_threshold
    
    async def get_lag(self) -> Dict[int, LagMetrics]:
        """Get lag for all assigned partitions."""
        lag_metrics = {}
        
        # Get end offsets (latest)
        partitions = self.consumer.assignment()
        end_offsets = await self.consumer.end_offsets(partitions)
        
        for tp in partitions:
            current = await self.consumer.position(tp)
            end = end_offsets[tp]
            lag = end - current
            
            lag_metrics[tp.partition] = LagMetrics(
                partition=tp.partition,
                current_offset=current,
                end_offset=end,
                lag=lag
            )
        
        return lag_metrics
    
    async def check_lag(self) -> None:
        """Check lag and log warnings/errors."""
        lag_metrics = await self.get_lag()
        
        total_lag = sum(m.lag for m in lag_metrics.values())
        
        if total_lag >= self.critical_threshold:
            logger.error(f"CRITICAL: Consumer lag is {total_lag:,} messages!")
            # Send alert to PagerDuty/Slack
        elif total_lag >= self.warning_threshold:
            logger.warning(f"WARNING: Consumer lag is {total_lag:,} messages")
        else:
            logger.debug(f"Consumer lag: {total_lag:,} messages")
        
        # Log per-partition metrics
        for partition, metrics in lag_metrics.items():
            logger.debug(f"Partition {partition}: lag={metrics.lag:,}")
    
    async def monitor_loop(self, interval_seconds: int = 30):
        """Continuously monitor lag."""
        while True:
            try:
                await self.check_lag()
            except Exception as e:
                logger.error(f"Error checking lag: {e}")
            
            await asyncio.sleep(interval_seconds)

6.4 Error Handling Matrix#

Error Type Cause Handling Retry?
Deserialization error Malformed message Send to DLQ, skip No
Validation error Missing required field Send to DLQ, skip No
Database timeout DB overloaded Retry with backoff Yes
Network error Connection lost Retry with backoff Yes
Out of memory Message too large Send to DLQ, skip No
Rate limited Downstream limit hit Retry with backoff Yes
Consumer crash Bug in handler Restart, reprocess Auto
Broker unavailable Kafka down Retry until available Yes
Quick check4 questions · +5 XP each

Reliable consumers and producers

  1. Q1/4 The simple Redis queue uses BLPOP, and a consumer crashes right after popping a message. What happens to that message?

    Answer & explanation

    Answer: C

    BLPOP removes the message immediately. Without acks or a visibility timeout, a crash between pop and finish loses the work, which is why it is marked not production-ready.

  2. Q2/4 A Kafka consumer crashes after processing offset 100 but before committing it, so a new consumer reads offset 100 again. How does the idempotent processor avoid a duplicate order?

    Answer & explanation

    Answer: A

    Writing the business change and the "processed" marker atomically means a replay finds the marker and skips. Committing before processing would turn crashes into lost messages instead.

  3. Q3/4 Producers write 10,000 messages/s, but the consumer group only handles 1,000 messages/s. How far behind is it after 10 minutes?

    Answer & explanation

    Answer: C

    Lag grows by 9,000 messages every second: 9,000 x 600 = 5.4 million. Lag that only grows means you need more consumers (up to the partition count) or faster processing.

  4. Q4/4 Which failure should a consumer send straight to the DLQ instead of retrying?

    Answer & explanation

    Answer: B

    A malformed message will fail the same way every time, so retries just waste time. Timeouts and rate limits are transient, so retry them with backoff.

Part III · Stage 3 of 4 · 5 min

Part III: Real-World Application#

Chapter 7: How Big Tech Does It#

7.1 Case Study: LinkedIn — The Birthplace of Kafka#

LINKEDIN'S KAFKA ARCHITECTURE
SCALE
  • 7+ trillion messages per day
  • 100+ clusters and 4,000+ brokers (2019 figures)
  • Thousands of producers and consumers
USE CASES
  • Activity Tracking — Page views, searches, profile views · Billions of events per day · Powers recommendations and analytics
  • Data Pipeline — Stream data from OLTP to OLAP · Real-time ETL · Feed machine learning models
  • Metrics and Monitoring — Application metrics piped through Kafka · Aggregated in real-time · Powers dashboards and alerts
KEY DECISIONS
  • Chose stream (Kafka) over queue for replay capability
  • Partition by a key where per-key ordering matters
  • Multiple consumer groups for different use cases
  • 7-day retention for replay, then archive to HDFS
LESSONS
  • If in doubt, log it: capture everything, decide later
  • Kafka became the "central nervous system" of LinkedIn
  • Consumer groups allow adding new features without changes

Reference: "The Log: What every software engineer should know about real-time data's unifying abstraction" — Jay Kreps (LinkedIn, co-creator of Kafka)

7.2 Case Study: Uber — Kafka for Real-Time Matching#

Challenge:

  • Match riders with drivers in real-time
  • Millions of events per second
  • Strong ordering requirements per trip
DiagramUber-style messaging architecture (illustrative)
%% title: Uber-style messaging architecture (illustrative)
flowchart TD
  RA@{ icon: "sd:phone", label: "Rider App" } --> K
  DA@{ icon: "sd:phone", label: "Driver App" } --> K
  DS@{ icon: "sd:server", label: "Dispatch Service" } --> K
  subgraph K["KAFKA"]
    direction TB
    TE@{ icon: "sd:stream", label: "Trip Events Topic<br/>Key: trip_id<br/>All events for same trip<br/>go to same partition" }
    LU@{ icon: "sd:stream", label: "Location Updates Topic<br/>Key: driver_id<br/>(very high volume)" }
  end
  K --> MS["Matching Service"]:::core
  K --> PS["Pricing Service"]:::core
  K --> AP@{ icon: "sd:chart", label: "Analytics Pipeline" }
  class RA,DA client
  class DS,AP service
  class TE,LU queue

Key decisions (illustrative: Uber hasn't published this exact design):

  • Partition by trip_id: All trip events in order
  • Consumer groups: Matching, pricing, analytics read same data
  • Exactly-once via idempotent writes to database
  • Multi-region Kafka for disaster recovery

Scale:

  • Trillions of messages per day across all topics (per Uber's engineering blog)
  • Low-latency delivery for trip updates
  • High availability across regions

7.3 Case Study: Webhook Delivery — A Queue, Not a Stream#

Stripe documents retrying failed webhook deliveries with exponential backoff for up to three days. Its internal stack is not public, so this case study shows how you would build that behaviour on a managed queue such as SQS.

Challenge:

  • Deliver webhooks reliably to millions of merchants
  • Retry failed deliveries with exponential backoff
  • Handle merchants with slow/broken endpoints

Why SQS (Queue) NOT Kafka (Stream)?

  • Each webhook is a "job to be done" (queue mental model)
  • Need per-message acknowledgment
  • Need visibility timeout for slow processing
  • Built-in dead letter queue
  • Don't need replay (webhooks are delivered, not replayed)
DiagramWebhook delivery on SQS (illustrative)
%% title: Webhook delivery on SQS (illustrative)
flowchart TD
  PS@{ icon: "sd:server", label: "Payment Service" } --> WQ
  subgraph SQS["SQS"]
    direction TB
    WQ@{ icon: "sd:queue", label: "Webhook Queue<br/>hook · hook · hook · hook" }
    VT["Visibility Timeout: 30s<br/>(Message invisible while<br/>being processed)"]:::core
    DLQ@{ icon: "sd:queue", label: "Dead Letter Queue<br/>(After 5 retries)" }
    WQ --> VT
    VT -->|"Failed?"| DLQ
  end
  class PS service
  class WQ queue
  class DLQ bad

Key decisions:

  • SQS for simple job queue semantics
  • Visibility timeout hides an in-flight message from other workers (SQS is still at-least-once, so send an event ID for dedup)
  • Dead letter queue for investigation
  • Exponential backoff in application code

Lessons:

  • Not everything needs Kafka
  • Queue semantics fit webhook delivery perfectly
  • Managed service (SQS) reduces operational burden

7.4 Comparison: When Each Company Chose What#

Company Use Case Choice Why
LinkedIn Activity tracking Kafka Replay, multiple consumers
Uber Trip events Kafka Ordering per trip, fan-out
Webhook senders (e.g., Stripe) Webhooks Job queue (e.g., SQS) Per-message retries, visibility timeout
Netflix Video encoding Priority work queue (in-house Timestone) Job distribution to workers
Shopify Event collection, log aggregation Kafka Billions of events a day to the data warehouse
Slack Job queue Kafka in front of Redis Durable buffer for bursts
Segment Event routing Kafka Fan-out to many destinations

Chapter 8: Common Mistakes to Avoid#

8.1 Mistake 1: Using Kafka When a Queue Would Do#

Using Kafka when a queue would do

WRONG: Using Kafka for simple background jobs

"We need to process image thumbnails. Let's use Kafka!"

Problems:

  • Kafka complexity not needed
  • Partition count limits parallelism
  • Operational overhead (brokers, ZooKeeper or KRaft controllers)
  • No built-in retry/DLQ support

CORRECT: Use a simple queue (Redis, SQS, Celery)

  • Jobs processed by available workers
  • Easy to scale workers up/down
  • Built-in retry support
  • Simpler operations

Rule: If you don't need replay or multiple consumers, a queue is probably simpler.

8.2 Mistake 2: Forgetting Consumer Idempotency#

Forgetting consumer idempotency

WRONG: Assuming exactly-once delivery

pythonprocess_order · 4 lines
async def process_order(message):
    order = message.value
    db.execute("INSERT INTO orders VALUES (...)")  # Duplicate!
    send_email(order.user, "Order confirmed")       # Duplicate!

If consumer crashes and restarts, message is redelivered. Order inserted twice. Two emails sent.

CORRECT: Make consumer idempotent

pythonprocess_order · 15 lines
async def process_order(message):
    order = message.value
    message_id = f"{message.partition}:{message.offset}"

    # Check if already processed
    if await is_processed(message_id):
        return  # Skip duplicate

    # Process in transaction
    async with db.transaction():
        db.execute("INSERT INTO orders VALUES (...)")
        db.execute("INSERT INTO processed_ids VALUES (...)")

    # Idempotent email (check if sent)
    await send_email_if_not_sent(order.id, order.user)

8.3 Mistake 3: Wrong Partition Key#

Wrong partition key

WRONG: Random or no partition key

python2 lines
# No key = spread across partitions (sticky/round-robin)
producer.send("orders", value=order)

Problem: Order created → Order paid → Order shipped. These might go to different partitions! Consumer might process "shipped" before "created".

CORRECT: Key by the entity that needs ordering

python5 lines
# All events for same order go to same partition
producer.send("orders", key=order.id, value=order)

# OR key by user if user-level ordering needed
producer.send("orders", key=order.user_id, value=order)

Rule: Key by whatever entity needs events processed in order

8.4 Mistake 4: Too Few Partitions#

Too few partitions

WRONG: Creating topic with 1 partition

  • Only 1 consumer can process at a time
  • Maximum throughput = 1 consumer's speed

Partition 0: [msg][msg][msg][msg][msg][msg]... ← Only Consumer

CORRECT: Plan partition count for growth

  • Rule of thumb: partitions >= expected max consumers
  • Can always have more partitions than consumers
  • Cannot easily reduce partitions later
Partition Messages Consumer
Partition 0 [msg][msg] → Consumer A
Partition 1 [msg][msg] → Consumer B
Partition 2 [msg][msg] → Consumer C
Partition 3 [msg][msg] → Consumer D
...

Start with 6-12 partitions for new topics. High-volume topics: 50-100+ partitions.

8.5 Mistake Checklist#

Before deploying a messaging system, verify:

Before deploying a messaging system, verify0/8 ready
  • Consumer is idempotent — Can handle duplicate messages
  • Partition key chosen correctly — Related messages stay together
  • Enough partitions — Can scale consumers as needed
  • Dead letter queue configured — Failed messages go somewhere
  • Monitoring in place — Consumer lag, error rates visible
  • Retention configured — Data kept long enough (but not forever)
  • Serialization tested — Schema evolution won't break consumers
  • Graceful shutdown — In-flight messages not lost on deploy
Quick check2 questions · +5 XP each

Choosing in practice

  1. Q1/2 Why is a queue like SQS a better fit than Kafka for delivering webhooks?

    Answer & explanation

    Answer: A

    Webhook delivery is work distribution, the queue mental model. Kafka's strengths (replay, many consumer groups) are not needed here.

  2. Q2/2 A team wants Kafka to process image thumbnails: one kind of worker, no replay, no other consumers. What does the lesson advise?

    Answer & explanation

    Answer: A

    If you do not need replay or multiple consumers, a queue is simpler. Kafka adds partition limits and operational overhead for no benefit here.

Part IV · Stage 4 of 4 · 10 min

Part IV: Interview Preparation#

Chapter 9: Interview Tips and Phrases#

9.1 When to Bring Up Queues/Streams#

Bring up messaging when:

  • System has components that can operate independently
  • You need to handle traffic spikes (buffer)
  • Multiple services need to react to the same event
  • You need audit/replay capabilities
  • Synchronous calls would create tight coupling
❯ Say this

9.2 Key Phrases to Use#

INTERVIEW PHRASES
When proposing async architecture

"Rather than having the API call these services synchronously, I'd publish an event to a message queue. This decouples the services and allows us to handle spikes by buffering requests."

When choosing between queue and stream

"For this use case, I'd use Kafka rather than a traditional queue because we need multiple consumer groups to process the same events—analytics needs the data, and so does the notification service. With a queue, once a message is consumed, it's gone."

When discussing ordering

"I'd partition by user_id to ensure all events for a given user are processed in order. Kafka guarantees ordering within a partition, so this gives us user-level ordering while allowing parallelism across users."

When discussing reliability

"The consumer needs to be idempotent because Kafka provides at-least-once delivery. I'd use the message offset as a deduplication key, storing it in the same transaction as the business operation."

When discussing scale

"With 6 partitions, we can have up to 6 consumers processing in parallel. If we need more throughput, we'd increase the partition count, though this requires careful planning since you can't easily reduce partitions later."

9.3 Questions to Ask Interviewer#

  • "What are the ordering requirements? Do events need to be processed in a specific order?"
  • "How many consumers will need this data? Just one system, or multiple?"
  • "Do we need replay capability? If something goes wrong, can we reprocess?"
  • "What's the acceptable latency for message delivery?"
  • "How bursty is the traffic? Are there predictable spikes?"

9.4 Common Follow-up Questions#

Question Good Answer
"What if Kafka is down?" "Producers would buffer locally and retry. We'd have monitoring to alert immediately. For critical paths, we might have a fallback to synchronous calls with circuit breaker."
"How do you handle poison pills?" "Messages that fail repeatedly go to a dead letter queue after N retries. We have tooling to inspect, fix, and replay them. The key is capturing why they failed."
"What about message ordering?" "Kafka guarantees ordering within a partition. I'd partition by the entity that needs ordering—like user_id or order_id. Across partitions, we'd either design for out-of-order or use timestamps."
"How do you scale consumers?" "Add more consumer instances up to the partition count. If we need more parallelism, increase partitions. We'd monitor consumer lag to know when to scale."

Chapter 10: Practice Problems#

Practice · problem 1

Problem 1: Order Processing System#

Setup: Design an order processing system for an e-commerce platform. When a user places an order:

  1. Validate inventory
  2. Process payment
  3. Update inventory
  4. Send confirmation email
  5. Notify warehouse

Requirements:

  • 10,000 orders per minute at peak
  • Orders for same user must be processed in order
  • No order can be lost
  • Email and warehouse notification can be delayed

Questions to consider:

  1. Would you use a queue or stream? Why?
  2. How would you partition the messages?
  3. What happens if payment processing fails?
  4. How do you ensure emails aren't sent twice?
Click for hints
  • Think about which operations MUST happen together vs can be async
  • Consider using multiple topics for different stages
  • Payment and inventory are critical; email is not
  • Partition by user_id for user-level ordering
Click for solution outline

Architecture:

  1. API writes order to database + outbox in one transaction
  2. Outbox publisher sends to Kafka topic "orders" (key: user_id)
  3. Order processor validates inventory, processes payment
  4. On success, publishes to "orders.completed" topic
  5. Email service and warehouse service consume from "orders.completed"

Key decisions:

  • Kafka for replay and multiple consumers
  • Partition by user_id for ordering
  • Transactional outbox to avoid dual-write
  • Separate topics for different stages
  • Idempotent consumers with deduplication

Practice · problem 2

Problem 2: Real-Time Analytics Pipeline#

Setup: Build a system to track user activity on a website. Events include page views, clicks, searches, and purchases. The data feeds into:

  • Real-time dashboard (< 5 second delay)
  • Daily reports
  • ML recommendation engine

Requirements:

  • 100,000 events per second at peak
  • Events must not be lost
  • Multiple consumers need the same data
  • Need to replay last 7 days of data

Questions to consider:

  1. Why is this a stream (not queue) use case?
  2. How would you handle the different latency requirements?
  3. What partition strategy would you use?
Click for hints
  • Stream is essential: multiple consumers, replay needed
  • Consider consumer groups for each use case
  • Think about what needs ordering vs what doesn't
  • Consider data volume for partitioning decisions
Click for solution outline

Architecture:

  • Kafka with 100+ partitions for throughput
  • Partition by user_id (enables user-level analysis)
  • Three consumer groups:
    • dashboard-consumer: Aggregates in real-time
    • daily-reports-consumer: Batch processes at end of day
    • ml-consumer: Feeds recommendation model
  • 7-day retention, then archive to S3

Key decisions:

  • High partition count for parallelism
  • Consumer groups for isolation
  • Each consumer tracks its own offset
  • Replay by resetting consumer offset

Practice · problem 3

Problem 3: Notification Service#

Setup: Design a notification service that sends emails, SMS, and push notifications. Notifications come from various services (orders, marketing, security alerts).

Requirements:

  • Marketing can trigger 5 million notifications at once
  • Security alerts (password reset) must be delivered within 30 seconds
  • Email provider allows 1,000 sends/second
  • Must handle provider outages gracefully

Questions to consider:

  1. How do you prioritize security alerts over marketing?
  2. How do you handle the rate limit to external providers?
  3. Queue or stream? Why?
Click for hints
  • Consider multiple queues with different priorities
  • Rate limiting needs to happen at the consumer level
  • Queue fits better: job processing, no replay needed
  • Think about circuit breakers for provider outages
Click for solution outline

Architecture:

  • Priority queues: HIGH (security), MEDIUM (transactional), LOW (marketing)
  • Separate queues per channel (email, SMS, push)
  • Rate limiter before external provider calls
  • Circuit breaker per provider with fallback
  • Dead letter queue for failed deliveries

Key decisions:

  • Queue (not stream): job processing, no replay needed
  • Priority queues ensure security alerts not delayed
  • Rate limiting at consumer level
  • Provider abstraction allows fallback

Chapter 11: Mock Interview Dialogue#

Scenario: Design an Event-Driven Order System#

Transcript
Interviewer

"We need to design an order processing system. When a user places an order, several things need to happen: validate inventory, charge payment, reserve inventory, send confirmation email, and notify the warehouse. How would you approach this?"

You

"Great question. Before I dive into the design, let me make sure I understand the requirements.

First, what's the expected scale? How many orders per second?"

Interviewer

"Let's say 1,000 orders per second at peak, with average around 100."

You

"And for reliability—is it acceptable to lose orders, or must every order be processed?"

Interviewer

"No order loss is acceptable. Payments are involved."

You

"Got it. Last question: do all these operations need to happen synchronously, or can some be delayed? For example, must the email be sent before we return a response to the user?"

Interviewer

"The user just needs to know their order was received. Email can be delayed by a few seconds, same with warehouse notification."

You

"Perfect. Based on these requirements, I'd use an event-driven architecture with a message stream—specifically Kafka. Let me draw this out.

DiagramProposed architecture
%% title: Proposed architecture
flowchart TD
  U@{ icon: "sd:user", label: "User" } --> API["API"]:::core
  API --> DB@{ icon: "sd:database", label: "Database + Outbox<br/>(single transaction)" }
  DB --> K@{ icon: "tech:kafka", label: "Kafka<br/>#quot;orders#quot;" }
  K --> OP["Order Processor"]:::core
  K --> ES@{ icon: "sd:mail", label: "Email Service" }
  K --> WS@{ icon: "sd:warehouse", label: "Warehouse Service" }
  OP --> V["Validate"]:::service
  V --> PAY@{ icon: "sd:card", label: "Payment" }
  PAY --> RI["Reserve Inventory"]:::service
  class U client
  class DB store
  class K queue
  class ES,WS,PAY service

Here's why I chose this approach:

  1. Transactional outbox for the API: When the user submits an order, we write to our orders table AND an outbox table in a single database transaction. This avoids the dual-write problem where we might save to the database but fail to publish.

  2. Kafka over a simple queue because multiple services need the same events—order processor, email, warehouse. With Kafka consumer groups, each service gets every message.

  3. Partition by user_id so all orders from the same user go to the same partition. This ensures ordering per user."

Transcript
Interviewer

"What happens if the payment processing fails?"

You

"Good question. The order processor would implement a saga pattern with compensation.

If payment fails:

  1. The order is marked as 'payment_failed' in the database
  2. Any reserved inventory is released (compensation)
  3. User is notified of the failure

For transient failures—like the payment service timing out—we'd retry with exponential backoff. After 3 retries, the message goes to a dead letter queue for manual investigation.

The key is that each step must be idempotent. If we retry payment and it already succeeded, the payment service should recognize the idempotency key and return the existing result rather than charging twice."

Interviewer

"How do you ensure the email isn't sent multiple times?"

You

"Two approaches:

First, the email service consumer is idempotent. It tracks which order IDs have been emailed using a simple database table. Before sending, it checks if a confirmation was already sent.

Second, the email service provider probably supports idempotency keys. We'd include the order ID in that key so even if we call the API twice, only one email goes out.

This is important because Kafka provides at-least-once delivery. Messages can be redelivered if a consumer crashes after processing but before committing the offset."

Interviewer

"How would you handle scaling this system?"

You

"A few dimensions to scale:

  1. More partitions: Currently with 10 partitions, we can have 10 consumers per consumer group processing in parallel. At 1,000 orders/second, that's 100/second per consumer—very manageable. If we need more, we'd increase partition count.

  2. More consumer instances: Within each consumer group, we can add instances up to the partition count. Kubernetes would auto-scale based on consumer lag metrics.

  3. Separate topics by priority: If we later have high-priority orders (like same-day delivery), we might route those to a separate topic with more consumers.

The nice thing about this architecture is that each component scales independently. Email service slow? Add more email consumers. They don't affect order processing."

Interviewer

"Great. What metrics would you monitor?"

You

"Critical metrics:

  1. Consumer lag — Are we falling behind? If lag grows, we need more consumers.
  2. Processing latency — p99 of time from message publish to processing complete.
  3. Error rate — What percentage of messages fail and go to DLQ?
  4. DLQ depth — Growing DLQ means something systematic is wrong.

I'd have alerts on all of these. Consumer lag above 10,000 messages would page someone. DLQ growth of more than 100 messages in 5 minutes would alert immediately."

WorksheetSizing the orders topic (event size, retention, consumer speed and the stall are assumptions)
Value
Orders (the interviewer's numbers) 1,000/s at peak, 100/s on average
Orders a day at the average 100 × 86,400 ≈ 8.6 million
Event size (assumed) 1 KB → ~8.6 GB a day
Retention 7 days, replication factor 3 (assumed) 8.6 GB × 7 × 3 ≈ ~180 GB of disk
One consumer's speed (assumed) 200 orders/s → 5 consumers keep up at peak
Partitions (the 10 from the dialogue) up to 10 consumers, 2,000 orders/s: 2× headroom
Consumers stalled for 10 minutes at peak (assumed) 1,000 × 600 = 600,000 orders behind
Catching up with 10 consumers while 1,000/s still arrive 600,000 ÷ (2,000 − 1,000) = 10 minutes

Conclusion: the bytes are trivial; what sets the partition count is consumer speed plus room to catch up. Ten partitions give twice the peak rate, so a 10-minute stall clears in about 10 minutes. With exactly 5 partitions the backlog would never shrink at peak.

Summary#

DAY 1 KEY TAKEAWAYS
CORE CONCEPT
  • QUEUE = Work to be done (consumed and deleted)
  • STREAM = Events that happened (retained, multiple readers)
  • Choose based on whether you need replay and multiple consumers
PATTERNS
  • Point-to-Point: One consumer per message (job queues)
  • Pub-Sub: All subscribers get all messages (notifications)
  • Consumer Groups: Fan-out + load balance (Kafka's model)
IMPLEMENTATION
  • Producers need acks=all for durability
  • Consumers must be idempotent (at-least-once delivery)
  • Partition key determines ordering and parallelism
  • Dead letter queues for failed messages
TRADE-OFFS
  • Queue: Simpler, but no replay. Use for job processing.
  • Stream: Replay + fan-out, but more complex. Use for events.
INTERVIEW TIPS
  • Always ask about ordering requirements
  • Clarify if multiple systems need the same data
  • Mention idempotency when discussing reliability
DEFAULT CHOICE
  • Uncertain? Start with a queue (simpler)
  • Need replay or multiple consumers? Use a stream

Further Reading#

Official Documentation#

Engineering Blogs#

Books#

  • Book

    "Designing Data-Intensive Applications" by Martin Kleppmann — Chapter 11 covers stream processing

  • Book

    "Kafka: The Definitive Guide" by Neha Narkhede, Gwen Shapira and Todd Palino — Comprehensive Kafka reference

  • Book

    "Enterprise Integration Patterns" by Hohpe & Woolf — Classic patterns book

Papers#

  • Paper

    "Kafka: a Distributed Messaging System for Log Processing" — Original Kafka paper from LinkedIn

this lesson in shorts · 2

play them all →

One idea from this lesson in about a minute each, made for your phone.

End of Day 01 · Queue vs Stream
Finish Day 1Stages clear as you read them (0/4 so far). Skipping ahead doesn't count: marking complete unlocks after ~8 more min of reading.

Go deeper · reading trail

all reading →

Real write-ups from the teams that built these systems. Each opens on the publisher's site in a new tab.

LinkedInPaper · ~25 min

Kafka: a Distributed Messaging System for Log Processing

Jay Kreps, Neha Narkhede, Jun Rao · 2011
What
The original LinkedIn paper introducing Kafka.
Key idea
A topic is a partitioned, append-only log; consumers pull and track their own offsets, so messages stay readable after delivery and can be replayed.
Why here
The defining difference in this lesson: a queue deletes a message once handled, a stream keeps an ordered log that many consumer groups read independently.
taught here: Log-based streams
SlackBlog post · ~14 min

Scaling Slack's Job Queue

Saroj Yadav, Matthew Smillie et al. · 2017
What
How Slack rebuilt the Redis-based job queue that runs billions of background tasks.
Key idea
They put durable Kafka in front of Redis: a Go service (Kafkagate) enqueues to Kafka and a relay moves jobs into Redis for workers to execute.
Why here
A hybrid of both models from this lesson: a durable log absorbs bursts and outages, a work queue hands out tasks to workers.
taught here: Queue vs stream trade-offs
RazorpayBlog post · ~10 min

Five Years of Kafka at Razorpay's UPI Switch

Kshitij Nawandar · 2026
What
Razorpay traces its UPI Switch from a monolith on SQS to a Kafka-based platform.
Key idea
SNS and SQS fan-out grew messy, Kafka fixed it, but one topic per event type plus retry and DLQ topics exploded partition counts until topics were consolidated.
Why here
Shows when a queue is enough and when a stream is worth it, and the operational cost (partitions per broker) this lesson's trade-off section warns about.
taught here: Choosing a messaging system

New lessons by email

New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.

Discussion · …

Public: everyone reading this lesson sees comments. Be kind and keep it on the material.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…