00:00

ZooKeeper vs KRaft in Apache Kafka

Last updated:

ZooKeeper vs KRaft is about where a Kafka cluster keeps its metadata and who manages it. In ZooKeeper mode, the metadata lives in a separate Apache ZooKeeper service. In KRaft mode, a small group of Kafka controllers keeps it in Kafka's own replicated log, using the Raft consensus protocol. Since Kafka 4.0, KRaft is the only mode.

You still need to know both: many production clusters were built on ZooKeeper and must be migrated, and the difference explains why KRaft clusters recover from failures so much faster.

1. What Is Cluster Metadata?

Besides messages, every Kafka cluster tracks facts about itself, called metadata:

  • which brokers (Kafka servers) are alive
  • which topics and partitions exist, and where their replicas live
  • the leader of each partition and its ISR (in-sync replicas, the copies that are fully caught up)
  • topic configs, quotas and access rules

One node, the controller, changes this metadata, for example by picking new leaders when a broker dies. So "ZooKeeper or KRaft" really asks: where does the metadata live, and how does the controller get it?

2. How ZooKeeper Mode Works

ZooKeeper is a separate coordination service with its own cluster of servers, called an ensemble. It stores small pieces of data called znodes in a tree, like files in folders.

  • Each broker registers by creating an ephemeral znode under /brokers/ids. ZooKeeper deletes an ephemeral znode automatically when the broker's session expires.
  • Brokers race to create the /controller znode. The winner becomes the controller.
  • The controller keeps a copy of all metadata in memory and pushes changes to every broker with LeaderAndIsr and UpdateMetadata requests.

The weak spot is failover. A new controller starts with empty memory, so it must read every partition's state from ZooKeeper before electing a single leader. With hundreds of thousands of partitions, this could take minutes.

3. How KRaft Mode Works

KRaft (Kafka Raft) removes ZooKeeper. A few nodes run with process.roles=controller and form a controller quorum (a group that decides by majority vote).

  • Every metadata change is appended as a record to an internal topic, __cluster_metadata. It has a single partition, so all changes have one strict order.
  • The controllers replicate this log with Raft. The Raft leader is the active controller. The others are hot standbys that apply each record as it arrives.
  • Brokers pull new records from the active controller and send it a heartbeat every 2 seconds (broker.heartbeat.interval.ms). If no heartbeat arrives for 9 seconds (broker.session.timeout.ms), the controller fences the broker, which means it stops counting it as a partition leader or an in-sync replica.
  • Controllers and brokers write periodic snapshots of the metadata so the log does not grow forever.
ZooKeeper mode vs KRaft mode architectureLeft, ZooKeeper mode: three brokers keep sessions with a separate ZooKeeper ensemble that stores the /controller, /brokers/ids and /brokers/topics znodes. Broker 1 is the controller and pushes LeaderAndIsr and UpdateMetadata requests to brokers 2 and 3. Right, KRaft mode: a quorum of three controllers shares the __cluster_metadata log through Raft. Controller 1 is the active leader and replicates the log to two standby controllers. Brokers 4, 5 and 6 fetch the metadata log from the active controller and send it heartbeats. ZooKeeper mode (Kafka 3.x and older) KRaft mode (Kafka 3.3+, only mode in 4.x) ZooKeeper ensemble (separate service) /controller /brokers/ids /brokers/topics Broker 1controller Broker 2 Broker 3 sessions,reads, watches controller pushes LeaderAndIsr / UpdateMetadata Controller quorum: __cluster_metadata log Controller 1active leader Controller 2standby Controller 3standby Raft replication Broker 4 Broker 5 Broker 6 brokers fetch the metadata log and send heartbeats to the active controller (no ZooKeeper)
In ZooKeeper mode, the controller is a broker that loads metadata from a separate ensemble. In KRaft mode, the metadata lives in a Raft log inside Kafka itself.

Because standbys already hold the metadata in memory, a new active controller only needs the few records it was missing.

3.1 Configuration Side by Side

server.properties
# ZooKeeper mode (Kafka 3.x and older)
broker.id=4
zookeeper.connect=zk1:2181,zk2:2181,zk3:2181

# KRaft mode: dedicated controller
process.roles=controller
node.id=1
controller.quorum.voters=1@c1:9093,2@c2:9093,3@c3:9093
listeners=CONTROLLER://c1:9093
controller.listener.names=CONTROLLER
listener.security.protocol.map=CONTROLLER:PLAINTEXT

# KRaft mode: broker
process.roles=broker
node.id=4
controller.quorum.voters=1@c1:9093,2@c2:9093,3@c3:9093
listeners=PLAINTEXT://b4:9092
controller.listener.names=CONTROLLER
listener.security.protocol.map=CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT

Before a KRaft node starts for the first time, its storage must be formatted with the cluster ID using kafka-storage.sh format. Kafka 3.9 added controller.quorum.bootstrap.servers for a dynamic quorum, where controllers can be added and removed later.

4. Code Examples

Example 1 is a plain Java model. Examples 2 and 3 use kafka-clients 4.1.0 against a real Kafka 4.1.0 cluster (Docker image apache/kafka:4.1.0) with three controllers, c1 to c3 (node IDs 1–3), and two brokers, b4 and b5. All run on Java 21.

4.1 Example 1: What a New Controller Must Load

ControllerFailoverModel.java
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;

public class ControllerFailoverModel {

    // Metadata changes, in the form KRaft appends them to __cluster_metadata
    sealed interface MetadataRecord permits BrokerRegistered, TopicCreated, LeaderChanged {}
    record BrokerRegistered(int brokerId) implements MetadataRecord {}
    record TopicCreated(String topic, int partitions) implements MetadataRecord {}
    record LeaderChanged(String topic, int partition, int newLeader) implements MetadataRecord {}

    // In-memory metadata that a controller builds by replaying the log
    static final class MetadataImage {
        final List<Integer> brokers = new ArrayList<>();
        final Map<String, Integer> leaders = new TreeMap<>(); // "topic-partition" -> leader
        int appliedRecords = 0;

        void apply(MetadataRecord rec) {
            switch (rec) {
                case BrokerRegistered b -> brokers.add(b.brokerId());
                case TopicCreated t -> {
                    for (int p = 0; p < t.partitions(); p++) {
                        leaders.put(t.topic() + "-" + p, brokers.get(p % brokers.size()));
                    }
                }
                case LeaderChanged c -> leaders.put(c.topic() + "-" + c.partition(), c.newLeader());
            }
            appliedRecords++;
        }
    }

    // ZooKeeper mode: a new controller reads the broker list, every broker znode,
    // the topic list, every topic znode and every partition state znode
    static long zooKeeperReads(int brokers, int topics, long partitions) {
        return 1 + brokers + 1 + topics + partitions;
    }

    public static void main(String[] args) {
        List<MetadataRecord> log = List.of(
                new BrokerRegistered(101), new BrokerRegistered(102), new BrokerRegistered(103),
                new TopicCreated("orders", 6), new TopicCreated("payments", 4),
                new TopicCreated("audit", 2),
                new LeaderChanged("orders", 0, 102), new LeaderChanged("payments", 3, 103));

        // A standby controller follows the leader; here it is 2 records behind
        MetadataImage standby = new MetadataImage();
        log.subList(0, log.size() - 2).forEach(standby::apply);
        System.out.println("Standby has applied " + standby.appliedRecords + " of " + log.size() + " records");

        // Active controller fails: the standby only replays the missing tail
        List<MetadataRecord> tail = log.subList(standby.appliedRecords, log.size());
        tail.forEach(standby::apply);
        System.out.println("KRaft failover: replayed " + tail.size() + " records -> "
                + standby.leaders.size() + " partitions ready");
        System.out.println("  orders-0 leader = " + standby.leaders.get("orders-0")
                + ", payments-3 leader = " + standby.leaders.get("payments-3"));

        System.out.println("ZooKeeper failover: " + zooKeeperReads(3, 3, 12)
                + " znode reads before the new controller can act");

        // Scale up: ZooKeeper work grows with partitions, KRaft work does not
        System.out.println("At 200,000 partitions: ZooKeeper reads = "
                + zooKeeperReads(30, 2_000, 200_000) + ", KRaft replay = " + tail.size() + " records");
    }
}
Output
Standby has applied 6 of 8 records
KRaft failover: replayed 2 records -> 12 partitions ready
  orders-0 leader = 102, payments-3 leader = 103
ZooKeeper failover: 20 znode reads before the new controller can act
At 200,000 partitions: ZooKeeper reads = 202032, KRaft replay = 2 records

The ZooKeeper count (broker list, each broker, topic list, each topic, each partition's state) grows with every partition. The KRaft count only depends on how far behind the standby was, which is rarely more than a few records.

4.2 Example 2: Finding the Real Active Controller

ControllerIdCheck.java
import java.util.Map;
import java.util.stream.Collectors;

import org.apache.kafka.clients.admin.Admin;
import org.apache.kafka.clients.admin.AdminClientConfig;
import org.apache.kafka.clients.admin.DescribeClusterResult;
import org.apache.kafka.common.Node;

public class ControllerIdCheck {

    public static void main(String[] args) throws Exception {
        // Normal clients talk to brokers
        Map<String, Object> viaBrokers = Map.of(
                AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, "b4:9092,b5:9092");
        // Admin clients can also talk to the controllers directly (Kafka 3.7+)
        Map<String, Object> viaControllers = Map.of(
                AdminClientConfig.BOOTSTRAP_CONTROLLERS_CONFIG, "c1:9093,c2:9093,c3:9093");

        try (Admin brokers = Admin.create(viaBrokers);
             Admin controllers = Admin.create(viaControllers)) {

            int raftLeader = brokers.describeMetadataQuorum().quorumInfo().get().leaderId();
            System.out.println("Raft leader (real active controller): " + raftLeader);

            print("bootstrap.servers    ", brokers.describeCluster());
            print("bootstrap.controllers", controllers.describeCluster());
        }
    }

    private static void print(String label, DescribeClusterResult cluster) throws Exception {
        Node controller = cluster.controller().get();
        String nodes = cluster.nodes().get().stream()
                .map(Node::id).sorted().map(String::valueOf)
                .collect(Collectors.joining(",", "[", "]"));
        System.out.println(label + " -> controller()=" + controller.id() + ", nodes()=" + nodes);
    }
}
Output
Raft leader (real active controller): 2
bootstrap.servers     -> controller()=5, nodes()=[4,5]
bootstrap.controllers -> controller()=2, nodes()=[1,2,3]

Asked through the brokers, describeCluster().controller() returns broker 5, which is not a controller at all. In KRaft mode, brokers report an alive broker here and forward "controller" requests to the real one. Only describeMetadataQuorum() or a client using bootstrap.controllers (Kafka 3.7+) reports node 2.

4.3 Example 3: Watching a KRaft Failover

FailoverWatch.java
import java.util.Map;

import org.apache.kafka.clients.admin.Admin;
import org.apache.kafka.clients.admin.AdminClientConfig;
import org.apache.kafka.clients.admin.QuorumInfo;

public class FailoverWatch {

    public static void main(String[] args) throws Exception {
        Map<String, Object> config = Map.of(
                AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, "b4:9092,b5:9092",
                AdminClientConfig.REQUEST_TIMEOUT_MS_CONFIG, 2000,
                AdminClientConfig.DEFAULT_API_TIMEOUT_MS_CONFIG, 2000);

        try (Admin admin = Admin.create(config)) {
            long start = System.currentTimeMillis();
            int lastLeader = -2;

            // Poll the quorum for 20 seconds and print every leader change
            while (System.currentTimeMillis() - start < 20_000) {
                long t = System.currentTimeMillis() - start;
                try {
                    QuorumInfo q = admin.describeMetadataQuorum().quorumInfo().get();
                    if (q.leaderId() != lastLeader) {
                        System.out.printf("t=%5d ms  leader=%d  epoch=%d  highWatermark=%d%n",
                                t, q.leaderId(), q.leaderEpoch(), q.highWatermark());
                        lastLeader = q.leaderId();
                    }
                } catch (Exception e) {
                    System.out.printf("t=%5d ms  no answer: %s%n", t, e.getCause().getClass().getSimpleName());
                    lastLeader = -1;
                }
                Thread.sleep(200);
            }
        }
    }
}
Output
t=    0 ms  leader=2  epoch=3  highWatermark=248
t= 6958 ms  no answer: TimeoutException
t= 9180 ms  leader=3  epoch=5  highWatermark=262

During the run, controller 2 was killed with docker kill, a hard crash. The others noticed when the leader went silent for controller.quorum.fetch.timeout.ms (default 2 seconds) and held an election. The epoch jumped from 3 to 5 because, as the controller logs showed, the round at epoch 4 split the votes and a second round elected node 3. Within seconds there was a new active controller. Your timings will differ.

5. ZooKeeper vs KRaft Comparison

ZooKeeper modeKRaft mode
Extra systemA ZooKeeper ensemble to run, secure and monitorNone
Where metadata livesZnodes in ZooKeeper, plus a copy in the controller's memory__cluster_metadata log on the controllers
ControllerOne broker, elected through the /controller znodeRaft leader of the controller quorum
Controller failoverReloads all metadata from ZooKeeperStandby is already up to date
How brokers learn changesController pushes requestsBrokers fetch the log
Broker livenessZooKeeper session, default timeout 18 sHeartbeats to the controller, default timeout 9 s
Kafka versionsUp to 3.9 (deprecated since 3.5)Production-ready since 3.3, only mode in 4.0+

6. Common Mistakes and Pitfalls

  • Trusting describeCluster().controller() in KRaft. As Example 2 shows, it returns a broker, so old dashboards quietly report the wrong node.
  • Upgrading straight from a ZooKeeper cluster to 4.x. Kafka 4.x cannot read ZooKeeper metadata. Migrate to KRaft on 3.x first. Version 3.9 is the recommended bridge release.
  • Pointing clients at controllers. bootstrap.servers must list brokers. Controllers do not serve produce or fetch requests.
  • Reusing a node.id. Every node in the cluster, broker or controller, needs a unique ID. Plan separate ranges for controllers and brokers.
  • Formatting nodes with different cluster IDs. A node whose storage was formatted with another ID refuses to start.
  • Running combined mode in production. With process.roles=broker,controller, heavy broker traffic can delay heartbeats and elections. It is fine for development.

7. Best Practices

  • Run three controllers (five for critical clusters) on separate machines or racks. Three survive one failure; five survive two.
  • Use dedicated controller nodes in production, so busy brokers cannot slow the quorum.
  • Monitor the quorum with kafka-metadata-quorum.sh describe --status and watch broker metadata lag.
  • Put the metadata log on fast, reliable disks, separate from data if needed (metadata.log.dir).
  • Rehearse migrations on a test cluster, including a rollback, before touching production.

8. Summary

  • ZooKeeper vs KRaft is a choice of where Kafka keeps its metadata: in an external ZooKeeper ensemble, or in Kafka's own Raft log.
  • ZooKeeper mode uses one broker as the controller, and it must reload every partition's state after a failover.
  • KRaft uses a controller quorum, a single-partition __cluster_metadata log and hot standbys, so failover takes seconds.
  • Use describeMetadataQuorum() or bootstrap.controllers, not describeCluster().controller(), to find the active controller.
  • Kafka 4.0 removed ZooKeeper, so migrate on 3.x (ideally 3.9) before upgrading.