Back to Scenarios & Design Questions
Q1Question 1 of 67

How does a system recover when a worker node fails?

Key Concepts
  • The first recovery layer is retry with exponential backoff and jitter, because many failures are transient.
  • Only idempotent operations should be retried, because a retry may duplicate an effect.
  • A coordinator like ZooKeeper detects dead nodes via missed heartbeats and declares them dead after the session timeout.
  • The Competing Consumers pattern lets surviving workers pick up released work from a shared queue without explicit reassignment.
  • For partitioned work like Kafka consumer groups, the coordinator revokes the dead node's partitions and reassigns them.
  • Checkpointing bounds how much expensive work a failure can cost by letting the replacement resume from the last saved progress.
  • The coordinator itself must be highly available via a replicated quorum to avoid being a single point of failure.

Definition

Recover from a worker failure in layers: retry first because most failures are transient, then a coordinator detects a dead node by missed heartbeats and redistributes its work to surviving workers, and long-running jobs resume from their last checkpoint instead of restarting.
  • Retry with Backoff – the first and cheapest layer, absorbing transient faults like brief network blips and GC pauses by retrying with exponential backoff and jitter.
  • Heartbeat-Based Detection – each worker periodically sends an "I am alive" signal to a coordinator; when heartbeats stop past the session timeout, the worker is declared dead.
  • Competing Consumers – work items live on a shared queue and any available worker can pick up the next item, so a dead worker's unacknowledged items naturally time out and reappear.
  • Partition Rebalance – for partitioned work like Kafka consumer groups, the coordinator revokes the dead node's partitions and reassigns them to surviving consumers.
  • Checkpointing – long-running jobs periodically save their progress to durable storage so a replacement resumes from the last checkpoint, not from scratch.

At National Bank of India, imagine a processing clerk suddenly stops working. The first response is simple: maybe he just dropped his pen, so you wait a moment and ask again. But if the clerk has truly collapsed, a supervisor who expects each clerk to wave every few seconds declares him out after the timeout and hands his unfinished tasks to whichever clerks are free. And for a huge reconciliation task, the clerk was told to jot down his page number every so often, so the replacement starts from the last noted page rather than redoing the entire pile.

Understanding the Concepts

What is the first recovery layer: retry?

A large fraction of failures in distributed systems are transient: a brief network delay, a load spike, a short GC pause. Retrying after a short pause usually succeeds, which is far cheaper than detecting node death and reassigning work.

JAVA
package com.bank.recovery;

import java.math.BigDecimal;
import java.util.concurrent.ThreadLocalRandom;

public class RetryWithBackoff {

    public static BigDecimal fetchBalanceWithRetry(String accountId, int maxRetries) {
        for (int attempt = 1; attempt <= maxRetries; attempt++) {
            try {
                return fetchFromLedger(accountId);
            } catch (Exception e) {
                long backoff = (long) Math.pow(2, attempt) * 100
                        + ThreadLocalRandom.current().nextLong(50); // jitter
                System.out.println("Attempt " + attempt + " failed, retrying in " + backoff + "ms");
                try { Thread.sleep(backoff); } catch (InterruptedException ie) { break; }
            }
        }
        throw new RuntimeException("All retries exhausted for account " + accountId);
    }

    private static BigDecimal fetchFromLedger(String accountId) {
        // Simulates a transient failure on first call
        if (ThreadLocalRandom.current().nextBoolean())
            throw new RuntimeException("Transient timeout");
        return new BigDecimal("50000.00");
    }

    public static void main(String[] args) {
        BigDecimal balance = fetchBalanceWithRetry("SAV2001", 3);
        System.out.println("Balance for SAV2001: " + balance);
    }
}
TEXT
Attempt 1 failed, retrying in 237ms
Balance for SAV2001: 50000.00

Why must retries be idempotent?

A retry may duplicate an effect. If Rajesh's transfer of 50,000 rupees times out and you retry, you might transfer 1,00,000 instead of 50,000. Only retry operations that are safe to repeat: idempotent operations that produce the same result regardless of how many times they run.

JAVA
package com.bank.recovery;

import java.math.BigDecimal;

public class IdempotentRetry {

    // WRONG: non-idempotent, retry can double-debit
    static void wrongTransfer(String from, String to, BigDecimal amount) {
        // Each call deducts again: 50000 + 50000 = 100000 lost
        System.out.println("WRONG: debit " + amount + " from " + from + " (again!)");
    }

    // CORRECT: idempotent with idempotency key
    static void correctTransfer(String idempotencyKey, String from, String to, BigDecimal amount) {
        // Ledger checks: has this key been processed? If yes, return original result.
        System.out.println("CORRECT: debit " + amount + " with key " + idempotencyKey
                + " (deduplicated if already processed)");
    }

    public static void main(String[] args) {
        correctTransfer("TRF-20240115-001", "SAV2001", "SAV3002",
                new BigDecimal("50000.00"));
    }
}
TEXT
CORRECT: debit 50000.00 with key TRF-20240115-001 (deduplicated if already processed)

How does heartbeat-based failure detection work?

Each worker periodically sends a small "I am alive" signal. In ZooKeeper this is modeled as an ephemeral node tied to the worker's session. When heartbeats stop past the session timeout, the coordinator declares the worker dead.

JAVA
package com.bank.recovery;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class HeartbeatMonitor {

    private final Map<String, Long> lastHeartbeat = new ConcurrentHashMap<>();
    private final long timeoutMs;

    public HeartbeatMonitor(long timeoutMs) {
        this.timeoutMs = timeoutMs;
    }

    public void receiveHeartbeat(String workerId) {
        lastHeartbeat.put(workerId, System.currentTimeMillis());
    }

    public void checkHealth() {
        long now = System.currentTimeMillis();
        lastHeartbeat.forEach((worker, lastBeat) -> {
            if (now - lastBeat > timeoutMs) {
                System.out.println("DEAD: " + worker + " (missed heartbeat for " + timeoutMs + "ms)");
                lastHeartbeat.remove(worker);
            } else {
                System.out.println("ALIVE: " + worker);
            }
        });
    }

    public static void main(String[] args) throws InterruptedException {
        HeartbeatMonitor monitor = new HeartbeatMonitor(3000);
        monitor.receiveHeartbeat("worker-1");
        monitor.receiveHeartbeat("worker-2");
        monitor.receiveHeartbeat("worker-3");

        Thread.sleep(4000); // worker-3 stops sending heartbeats
        monitor.receiveHeartbeat("worker-1");
        monitor.receiveHeartbeat("worker-2");
        // worker-3 did NOT send a heartbeat

        monitor.checkHealth();
    }
}
TEXT
ALIVE: worker-1
ALIVE: worker-2
DEAD: worker-3 (missed heartbeat for 3000ms)

How does the Competing Consumers pattern redistribute work?

Work items live on a shared queue and any worker can pull the next available item. When a worker dies, its claimed-but-unacknowledged items become visible again after the lease expires, and surviving workers pick them up as part of normal polling.

JAVA
package com.bank.recovery;

import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

public class CompetingConsumers {

    static BlockingQueue<String> queue = new LinkedBlockingQueue<>();

    static void worker(String name) {
        new Thread(() -> {
            while (true) {
                try {
                    String task = queue.take();
                    System.out.println(name + " picked up: " + task);
                    Thread.sleep(100); // process
                } catch (InterruptedException e) { break; }
            }
        }, name).start();
    }

    public static void main(String[] args) throws InterruptedException {
        // Two workers competing for tasks
        worker("worker-1");
        worker("worker-2");

        // Tasks that a dead worker released back to the queue
        queue.put("reconcile-batch-42");
        queue.put("reconcile-batch-43");
        queue.put("reconcile-batch-44");

        Thread.sleep(500);
        System.out.println("All released tasks picked up by surviving workers");
    }
}
TEXT
worker-1 picked up: reconcile-batch-42
worker-2 picked up: reconcile-batch-43
worker-1 picked up: reconcile-batch-44
All released tasks picked up by surviving workers

How does checkpointing bound recovery cost?

If a long job dies near the end, restarting from scratch wastes all the progress. Instead the job periodically records its progress to durable storage, and the replacement resumes from there.

JAVA
package com.bank.recovery;

public class CheckpointedJob {

    private long checkpoint = 0;

    public void loadCheckpoint() {
        // In production: read from DB or distributed storage
        checkpoint = 500_000; // resume from row 500,000
        System.out.println("Loaded checkpoint: row " + checkpoint);
    }

    public void process(long totalRows) {
        for (long row = checkpoint; row < totalRows; row++) {
            // Process row
            if (row % 100_000 == 0 && row > checkpoint) {
                checkpoint = row;
                System.out.println("Checkpoint saved at row " + row);
            }
        }
        System.out.println("Job complete. Processed rows " + 500_000 + " to " + totalRows);
    }

    public static void main(String[] args) {
        CheckpointedJob job = new CheckpointedJob();
        job.loadCheckpoint();
        job.process(1_000_000);
    }
}
TEXT
Loaded checkpoint: row 500000
Checkpoint saved at row 600000
Checkpoint saved at row 700000
Checkpoint saved at row 800000
Checkpoint saved at row 900000
Job complete. Processed rows 500000 to 1000000

What about Kafka partition rebalancing?

For partitioned work, the coordinator revokes the dead consumer's partitions and reassigns them to surviving consumers, who resume from the last committed offset.

JAVA
package com.bank.recovery;

import java.util.List;
import java.util.Map;
import java.util.HashMap;

public class PartitionRebalance {

    public static void main(String[] args) {
        Map<String, List<Integer>> assignment = new HashMap<>();
        assignment.put("consumer-1", List.of(0, 1));
        assignment.put("consumer-2", List.of(2, 3));
        assignment.put("consumer-3", List.of(4, 5));
        System.out.println("Before failure: " + assignment);

        // consumer-3 dies, its partitions are redistributed
        List<Integer> orphaned = assignment.remove("consumer-3");
        assignment.put("consumer-1", List.of(0, 1, 4)); // inherits partition 4
        assignment.put("consumer-2", List.of(2, 3, 5)); // inherits partition 5
        System.out.println("After rebalance: " + assignment);
        System.out.println("Consumers resume from last committed offset for inherited partitions");
    }
}
TEXT
Before failure: {consumer-1=[0, 1], consumer-2=[2, 3], consumer-3=[4, 5]}
After rebalance: {consumer-1=[0, 1, 4], consumer-2=[2, 3, 5]}
Consumers resume from last committed offset for inherited partitions

What should a senior engineer know?

The heartbeat timeout is a trade-off: too short gives false positives where a merely slow node is wrongly declared dead, too long makes recovery slow. The coordinator itself must be highly available via a replicated quorum (ZooKeeper, etcd). Because failure detection is imperfect and delivery is at-least-once, idempotency is the non-negotiable foundation.

What are common interview follow-up questions?

  • How do you tune the heartbeat interval versus the session timeout?
  • What happens when a node is wrongly declared dead but is actually still processing?
  • How does exactly-once semantics work in Kafka consumer groups?
  • Why must the coordinator itself be a replicated quorum?

Visual Overview

DIAGRAM
  Worker Node Failure Recovery Layers
  ────────────────────────────────────

  Layer 1: Retry (sub-second, cheapest)
  ┌─────────────────────────────────────┐
  │ Request fails → wait → retry        │
  │ Exponential backoff + jitter        │
  │ Only for idempotent operations      │
  └──────────────┬──────────────────────┘
                 │ retries exhausted
                 ▼
  Layer 2: Heartbeat Detection + Redistribution
  ┌─────────────────────────────────────┐
  │ Coordinator watches heartbeats      │
  │ Worker misses timeout → declared    │
  │   dead → work released to queue     │
  │ Competing Consumers pick it up      │
  └──────────────┬──────────────────────┘
                 │ for long-running jobs
                 ▼
  Layer 3: Checkpointing
  ┌─────────────────────────────────────┐
  │ Job saves progress periodically     │
  │ New worker loads last checkpoint    │
  │ Resumes from 500K, not from 0      │
  └─────────────────────────────────────┘

Key Takeaway

Recovery from a worker node failure happens in three layers matched to different time scales and costs. The first layer is retry with exponential backoff and jitter, because many failures are transient and a quick retry is far cheaper than detecting node death and reassigning work. Only idempotent operations should be retried, because a retry can duplicate an effect.

The second layer handles genuine node death through heartbeats watched by a coordinator like ZooKeeper. When a worker misses its heartbeats past the session timeout, it is declared dead and its unfinished work is redistributed. The cleanest mechanism is the Competing Consumers pattern, where work items live on a shared queue and surviving workers pick up released items as part of normal polling. For partitioned work like Kafka consumer groups, the coordinator revokes the dead node's partitions and reassigns them.

The third layer is checkpointing, which bounds how much expensive work a failure can cost. The job periodically records its progress, and the replacement resumes from the last checkpoint rather than starting over. The coordinator itself must be highly available via a replicated quorum, and idempotency is the non-negotiable foundation that makes the entire scheme safe.

Remember one sentence: retry for transient faults, heartbeat detection plus competing consumers for real failures, and checkpointing so long jobs never restart from zero.

---

Share
Use ← → arrow keys to navigate