Engineering Atlas › System Design Interviews

ZooKeeper for system design interviews

13 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

Znodes and the tree

Data lives in znodes addressed by slash-separated paths like /services/payments/leader. Each znode holds a small byte array (the default limit, jute.maxbuffer, is 1,048,575 bytes, just under 1 MiB, and must match on every server and client; real payloads should be far smaller) (Administrator's Guide) plus a Stat: creation and modification zxids, a data version, a child version cversion, ephemeralOwner (the session ID, or 0) and child count. Every write can be conditional on a version, which gives compare-and-set.

Node types: persistent, ephemeral (deleted when the creating session ends, and cannot have children), sequential (the server appends a 10-digit counter kept by the parent, such as lock-0000000042; it is a signed 32-bit int that wraps to a negative number after 2,147,483,647), container (deleted once its last child goes, since 3.5.3) and TTL, which is disabled by default and must be switched on with a system property (Programmer's Guide).

Sessions

A client gets a session with a timeout the server bounds by default to 2 to 20 times tickTime, so 4 to 40 s with the usual 2 s tick (Administrator's Guide). It pings when idle and can fail over to another server with the same session ID. If the leader hears nothing for the full timeout, it expires the session and deletes its ephemeral nodes. The client learns of expiry only when it reconnects, so "I hold the lock" is always a belief.

Watches

A read can leave a one-shot watch: on change the server sends one notification, delivered before the client can see the new data, and the client must re-read and re-register. Since 3.6, persistent and recursive watches (addWatch) stay registered after firing and can cover a whole subtree (Programmer's Guide).

Recipes built on top

A lock: create /lock/lock- as ephemeral and sequential, list children, and if yours is lowest you hold it. Otherwise watch only the node just before yours. When it goes, re-check. Watching the predecessor instead of the parent means a release wakes one client, not all of them (recipes). Leader election is the same recipe with the lowest node as leader.

Down the stack

Layer 1The contract

  • Linearizable writes. All updates are totally ordered by zxid and applied in that order everywhere.
  • FIFO client order. One client's requests take effect in the order it sent them, including asynchronous ones.
  • Reads are sequentially consistent, not linearizable. A server answers from its own memory, which may trail the leader. Call sync before a read to make it reflect every write committed before the sync (Programmer's Guide). Treat that as nearly, not strictly, linearizable: Jepsen later noted that sync plus read can still return stale data under some conditions (Jepsen).
  • Durability. A write acknowledged to the client is on disk on a quorum (2 of 3, or 3 of 5).
  • Availability. A majority must be up and connected to the leader. A 5-server ensemble survives 2 failures; a minority partition serves reads (with readonlymode.enabled) but no writes.

Write latency is a leader round trip plus a quorum fsync: a few ms on SSDs.

Layer 2The mechanism

ZAB. Each server is LOOKING, FOLLOWING, LEADING or OBSERVING. In FastLeaderElection, servers exchange votes of (epoch, last zxid, server ID) and converge on the server with the most recent history. The new leader then runs discovery (gathers followers' epochs and picks a higher one) and synchronisation: each follower is brought up to date with a DIFF (missing transactions), TRUNC (roll back uncommitted ones from a dead leader) or SNAP (full snapshot). Only then does broadcast begin (Zab paper).

In broadcast, the leader gives each write a zxid: a 64-bit number with the epoch in the high 32 bits and a counter in the low 32. It sends PROPOSAL to followers, which append it to their log, fsync, and reply ACK. On a quorum of ACKs the leader sends COMMIT, and observers, which do not vote, receive INFORM. Proposals travel over one TCP connection per follower, so they arrive in order with no reordering logic.

Request pipeline. On the leader, PrepRequestProcessor validates a request and turns it into an idempotent transaction: a conditional setData becomes "set data to X with version 7", and a sequential create carries its final name. SyncRequestProcessor logs and batches fsyncs, CommitProcessor waits for the quorum, and FinalRequestProcessor applies the change to the in-memory DataTree and fires watches.

Transaction log and snapshots. Transactions are appended to log.<zxid> files in dataLogDir, preallocated in 64 MB chunks (preAllocSize) so appends do not extend file metadata. After a random count between snapCount/2+1 and snapCount (100,000 by default) transactions, so servers do not snapshot together (Administrator's Guide), a background thread writes snapshot.<zxid>. The snapshot is fuzzy, taken while writes continue, which works because transactions are idempotent: on restart the server loads the snapshot and replays every log entry after its starting zxid. Old files are removed only if autopurge.purgeInterval is set: it defaults to 0, which means never, so an untouched install fills its disk.

Sessions on the leader. Expiry is tracked in buckets rounded to tickTime, so the leader checks one bucket per tick instead of every session. Expiry is itself a closeSession transaction through ZAB, so every server deletes the same ephemerals in the same order.

Layer 3The machine

The write path is bounded by fsync on the transaction log. Group commit amortises this, but a log on a disk shared with snapshots or application logs can see fsync stalls. Put dataLogDir on its own device and watch the fsync warning threshold (fsync.warningthresholdms, default 1,000 ms) (Administrator's Guide).

The whole tree lives in the JVM heap. A million small znodes with paths of about 50 bytes cost very roughly 0.5 to 1 GB of heap (hedge: depends on data and watches). Watches cost memory per watcher-path pair. A full GC pause longer than the session timeout makes clients look dead: sessions expire, ephemerals vanish, every waiter wakes and reconnects. Size heaps so that pauses stay well under tickTime, and use G1 or ZGC.

Reads scale with the number of servers; writes do not, because each goes to every voter. More voters make writes slower, which is why non-voting observers exist.

Why it's built this way

  1. Coordination needs one agreed order of events, and agreement over a network needs a quorum, so every write goes through a single leader and a majority.
  2. Consensus is slow per operation, so the data model is tiny and in memory and reads skip consensus, trading freshness for throughput.
  3. Majority writes must survive power loss, so each one is fsynced to an append-only log, and logs grow, so fuzzy snapshots plus idempotent transactions bound recovery time.
  4. Polling a coordination service from thousands of clients is expensive, so watches push changes and ephemerals turn liveness into data.
  5. Watches that wake everyone cause herds, so recipes watch a single predecessor.

Diagram

sequenceDiagram
    participant C as Client
    participant F as Follower
    participant L as Leader
    participant Q as "Other followers"
    C->>F: create /lock/lock- (ephemeral, seq)
    F->>L: forward request
    L->>L: Prep: assign zxid, final name
    L->>Q: PROPOSAL zxid
    L->>F: PROPOSAL zxid
    Q->>Q: append log, fsync
    Q-->>L: ACK
    L->>L: quorum reached
    L->>F: COMMIT zxid
    L->>Q: COMMIT zxid
    F->>F: apply to DataTree, fire watches
    F-->>C: /lock/lock-0000000042

Worked example

A batch platform runs 400 shards, each with 5 worker replicas competing to be shard leader, so 2,000 clients on a 5-server ensemble with a 10 s session timeout.

Steady state is about 650 pings per second across 5 servers and 2,000 znodes of under 100 bytes: trivial.

A naive recipe has every replica watch /shards/17 itself. When the leader dies, 4 watchers fire and each re-lists children: fine for 5 replicas. Scale to 200 replicas competing for one global lock and every release triggers 200 notifications and 200 getChildren calls. Watching the predecessor makes each release cost one notification.

Failure: a 15 s GC pause on a leader replica exceeds its 10 s timeout. ZooKeeper expires the session, deletes the node, and the next replica takes over. The paused process wakes up still believing it is leader. The fix is a fencing token: the new leader's zxid or sequence number is passed to downstream storage, which rejects writes carrying a smaller token.

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
ZooKeeperMature, ephemeral and sequential nodes, watches, wide Hadoop ecosystemJVM tuning, custom protocol, writes limited by one leaderExisting ZooKeeper-based stack (HBase, Solr, older Kafka)
etcdRaft, gRPC API, leases, MVCC history with watch from revisionDefault 2 GiB data limit (8 GiB suggested max), 1.5 MiB requests, same write ceilingKubernetes-adjacent or new Go services
ConsulService discovery, health checks and KV in oneWeaker as a general lock serviceService discovery across datacenters
Built-in Raft (KRaft, Redpanda)No extra cluster to runEach system reimplements coordinationYou own the storage system
Database row lock or leaseNo new infrastructureNo push notifications, polling loadLow-scale leader election

Pitfalls in production

Leadership lens

Check yourself

  1. Why can a client read a value older than one it just wrote through a different server?

    Show answer

    Reads are served from the local server's memory without consensus, and that server may not have applied the commit yet. FIFO order holds only per session, so use sync first or keep the same server.

  2. Why does the lock recipe watch the predecessor instead of the lock's parent?

    Show answer

    Watching the parent wakes every waiter on each release, a herd of notifications and reads. Watching the predecessor wakes exactly one waiter.

  3. How can a ZooKeeper server take a snapshot while writes continue and still recover correctly?

    Show answer

    Transactions are idempotent, so replaying the log from the snapshot's starting zxid over a fuzzy snapshot produces the correct state even if some transactions are applied twice.

  4. Why does a 4-server ensemble give no more fault tolerance than a 3-server one?

    Show answer

    A quorum of 4 is 3, so it still survives only one failure, while every write needs one more ACK.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →