Back to CompletableFuture & Resilience
Q13Question 13 of 19

Why can CF work be lost on shutdown?

Key Concepts
  • The default ForkJoinPool.commonPool() uses daemon threads that are killed on JVM exit without warning.
  • In-flight CompletableFuture tasks on daemon threads are silently lost: no exception, no log, no retry.
  • Fix: use a non-daemon custom executor with a shutdown hook that calls awaitTermination().
  • Spring Boot: setWaitForTasksToCompleteOnShutdown(true) and setAwaitTerminationSeconds(30).
  • For truly critical work, in-memory futures are not durable: use Kafka, transactional outbox, or DB-backed job scheduler.
  • The transactional outbox pattern ensures atomic persistence of business logic and async event.

Definition

CompletableFuture tasks on the default daemon-thread pool are silently killed on JVM exit; fix with non-daemon pools and shutdown hooks, or use durable mechanisms like Kafka and transactional outbox for critical work.
  • Default pool – ForkJoinPool.commonPool() uses daemon threads; JVM exit kills them without running finally blocks.
  • Silent loss – in-flight tasks vanish with no exception, no log, and no retry mechanism.
  • Non-daemon fix – custom ExecutorService with non-daemon threads keeps the JVM alive until tasks complete.
  • Shutdown hook – Runtime.addShutdownHook() triggers on SIGTERM/SIGINT; call pool.shutdown() + awaitTermination().
  • Spring Boot – setWaitForTasksToCompleteOnShutdown(true) with setAwaitTerminationSeconds(30).
  • Durable alternatives – Kafka, transactional outbox, and DB-backed job schedulers survive JVM crashes.

runAsync() on the default pool is like handing a trade settlement to a freelance teller (daemon thread) at National Bank of India. When the bank closes (JVM exit), the freelancer is kicked out mid-settlement. The trade is lost. Nobody knows it was not finished. Fix: hire permanent staff (non-daemon pool) or write the task to a ledger first (Kafka/outbox) so another branch can finish it.

Understanding the Concepts

Why is work lost on shutdown?

The default commonPool uses daemon threads. When all non-daemon threads finish, the JVM exits and daemon threads are killed instantly. There is no warning, no exception, no log entry, and no retry.

JAVA
public static void main(String[] args) {
    CompletableFuture.runAsync(() -> {
        settleTrade("TRD-001"); // takes 5 seconds
    });
    // main() returns. No non-daemon threads left.
    // JVM exits. settleTrade killed mid-execution. LOST!
}

What exactly happens when a daemon thread is killed?

The JVM does not call finally blocks on daemon threads during shutdown. Any resources held by the thread (database connections, file handles, partial writes) are abandoned without cleanup. If the thread was mid-way through writing a settlement to the database, the write is lost or left in an inconsistent state.

How do you fix it with a non-daemon pool?

Create a custom ThreadPoolExecutor with non-daemon threads. Non-daemon threads keep the JVM alive until they finish. Add a shutdown hook to drain in-flight tasks gracefully.

JAVA
package com.bank.executor;

import java.util.concurrent.*;

public class SafeSettlementPool {

    private static final ThreadPoolExecutor pool = new ThreadPoolExecutor(
        10, 20, 60, TimeUnit.SECONDS,
        new LinkedBlockingQueue<>(1000),
        r -> {
            Thread t = new Thread(r, "settlement-worker");
            t.setDaemon(false);  // non-daemon: keeps JVM alive
            return t;
        }
    );

    static {
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            pool.shutdown();  // stop accepting new tasks
            try {
                if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
                    pool.shutdownNow();  // interrupt stragglers
                }
            } catch (InterruptedException e) {
                pool.shutdownNow();
                Thread.currentThread().interrupt();
            }
        }, "shutdown-hook"));
    }

    public static void submitSettlement(String tradeId) {
        CompletableFuture.runAsync(() -> settleTrade(tradeId), pool);
    }
}

What about Spring Boot?

Spring Boot provides built-in support for graceful executor shutdown.

JAVA
@Bean("settlementExecutor")
public ThreadPoolTaskExecutor settlementExecutor() {
    ThreadPoolTaskExecutor e = new ThreadPoolTaskExecutor();
    e.setCorePoolSize(10);
    e.setMaxPoolSize(20);
    e.setQueueCapacity(1000);
    e.setThreadNamePrefix("settlement-");
    e.setWaitForTasksToCompleteOnShutdown(true);
    e.setAwaitTerminationSeconds(30);
    return e;
}

With setWaitForTasksToCompleteOnShutdown(true), Spring waits up to the specified seconds for in-flight tasks to complete before destroying the bean.

When do you need durable alternatives?

In-memory futures vanish on JVM crash, OutOfMemoryError, or SIGKILL (kill -9). For critical work that must never be lost, in-memory futures are insufficient.

  • Kafka – message survives producer crash; consumer retries on restart.
  • Transactional outbox – write event to DB in same transaction as business logic; poller retries.
  • DB-backed job scheduler – Quartz with JDBC store; job survives JVM restart.
JAVA
package com.bank.outbox;

import org.springframework.transaction.annotation.Transactional;

public class SettlementService {

    @Transactional
    public void settleAndPublish(Trade trade) {
        tradeRepo.save(trade.withStatus("SETTLED"));
        outboxRepo.save(new OutboxEvent("SETTLE", trade.getId(), trade.toJson()));
        // Atomic: both saved or neither
        // A background poller reads outbox, publishes to Kafka, deletes entry
        // If JVM crashes after commit, the poller picks it up on restart
    }
}

How does SIGTERM differ from SIGKILL?

| Signal | Shutdown hooks? | Finally blocks? | Use case |
|--------|----------------|-----------------|----------|
| SIGTERM | Yes | Yes (on non-daemon) | Kubernetes pod stop |
| SIGINT (Ctrl-C) | Yes | Yes (on non-daemon) | Developer stop |
| SIGKILL (kill -9) | No | No | Emergency kill |
| OOM crash | No | No | Memory exhaustion |

This is why the transactional outbox is essential for banking: SIGKILL and OOM bypass all shutdown hooks.

What are common mistakes?

JAVA
// WRONG: critical work on default daemon pool
CompletableFuture.runAsync(() -> processPayment(paymentId));
// daemon thread, may be killed mid-payment on shutdown!

// CORRECT: critical work on non-daemon pool with shutdown hook
CompletableFuture.runAsync(() -> processPayment(paymentId), settlementPool);

What are common interview follow-up questions?

  • Does SIGKILL trigger shutdown hooks? No. Only SIGTERM and SIGINT trigger hooks.
  • Can you make commonPool non-daemon? Not directly. The internal thread factory marks them daemon.
  • What is the transactional outbox pattern? Write the event to a DB table in the same transaction as the business logic. A poller reads and processes events, guaranteeing at-least-once delivery even across JVM restarts.

Visual Overview

DIAGRAM
  Default Pool (daemon threads)
  ┌───────────────────────────────┐
  │ JVM exits → daemon killed     │
  │ Task: settleTrade("TRD-001")  │
  │ Status: SILENTLY LOST         │
  └───────────────────────────────┘

  Custom Pool (non-daemon + shutdown hook)
  ┌───────────────────────────────┐
  │ SIGTERM → shutdown hook fires │
  │ pool.shutdown()               │
  │ awaitTermination(30s)         │
  │ Task completes gracefully     │
  └───────────────────────────────┘

  Durable (Kafka/Outbox)
  ┌───────────────────────────────┐
  │ JVM crashes → event in DB/Kafka│
  │ Poller/consumer retries       │
  │ Task eventually completes     │
  └───────────────────────────────┘

Key Takeaway

CompletableFuture tasks on the default ForkJoinPool use daemon threads. When the JVM exits, daemon threads are killed instantly. In-flight tasks vanish with no exception, no log, and no retry. This is acceptable for optional work like cache warming, but dangerous for critical work like settlements and payments.

The fix for graceful shutdown: use a non-daemon custom pool with a shutdown hook that calls awaitTermination(). Spring Boot provides setWaitForTasksToCompleteOnShutdown(true). For true durability, in-memory futures are not enough. JVM crash, OOM, and SIGKILL bypass all shutdown hooks. Use the transactional outbox pattern: write the event to a DB table in the same transaction as the business logic, and a poller processes it even if the original JVM is gone.

At National Bank of India, the outbox pattern guarantees zero lost settlements: the trade and the outbox event are saved atomically, and the poller retries on restart.

Remember one sentence: daemon-pool tasks die silently on shutdown; use non-daemon pools with hooks or durable outbox for critical work.

---

Share
Use ← → arrow keys to navigate