Java's ExecutorService.submit() Exception-Swallowing Trap: The Uncaught Handler That Never Fires

2026-08-30

This service processes user events on a thread pool. If a handler crashes, ops wants a stack trace in the logs — that's what the UncaughtExceptionHandler is for. It has been running in production for months. Recently, database rows have started going missing: no errors, no alerts, no log lines. Just gaps in the data.

public class EventProcessor {
    private final ExecutorService executor = Executors.newFixedThreadPool(4);
    private static final Logger log = LoggerFactory.getLogger(EventProcessor.class);

    @PostConstruct
    void init() {
        Thread.setDefaultUncaughtExceptionHandler(
            (t, e) -> log.error("Uncaught exception in {}", t.getName(), e)
        );
    }

    public void process(List<Event> events) {
        for (Event event : events) {
            executor.submit(() -> {
                validate(event);
                database.write(event);      // occasionally throws SQLException
                metrics.increment("events.processed");
            });
        }
    }
}

The Bug

submit() and execute() look interchangeable, but they handle exceptions in fundamentally different ways.

execute(Runnable) runs the task directly on a worker thread. If it throws, the exception bubbles up to the thread's UncaughtExceptionHandler, gets logged, and the worker is replaced.

submit(Runnable) — despite accepting the same lambda — wraps the task in a FutureTask. FutureTask.run() has a try/catch (Throwable) around the body that stores any exception inside the Future so a later get() call can re-throw it. The worker thread sees no exception. The UncaughtExceptionHandler never fires.

Since the code discards the returned Future<?>, nobody ever calls .get(). Every SQLException from database.write() vanishes into the void: no log, no metric, no alert. Validation ran, the write silently failed, and the metrics.increment line never executed — so even the counter looks fine, because failed writes simply don't count.

Worst of all, the code looks defensive. There's an uncaught handler right there in init(). During code review, everyone nods and moves on.

The Fix

Three options, in order of preference for fire-and-forget work:

1. Use execute() when you don't need the Future. This preserves the uncaught-handler contract you thought you had:

executor.execute(() -> {
    validate(event);
    database.write(event);
    metrics.increment("events.processed");
});

2. Handle exceptions inside the task — explicit and robust regardless of which method you use:

executor.submit(() -> {
    try {
        validate(event);
        database.write(event);
        metrics.increment("events.processed");
    } catch (Exception e) {
        log.error("Failed to process event {}", event.getId(), e);
        metrics.increment("events.failed");
    }
});

3. If you truly need the Future, drain the results — never let a submitted Future go unobserved. A CompletableFuture with an .exceptionally() handler is often cleaner than a raw Future.

The general rule: a discarded Future is a discarded exception. Any codebase that calls submit() without capturing the return value is one NullPointerException away from a silent outage. Static analyzers like SpotBugs and Error Prone flag this pattern (FutureReturnValueIgnored) — turn the check on.

Key Takeaway: ExecutorService.submit() catches every Throwable and stores it in the returned Future; if you ignore the Future, you ignore the exception — use execute() for fire-and-forget work, or catch inside the task.

All newsletters