Back to Articles
Modern Java Threading and Spring Boot Async: From Threads to CompletableFuture
SpringBootMultiThreadingThreadsJavaSchedulingTomcat-ServerFutureCompletableFuture

Modern Java Threading and Spring Boot Async: From Threads to CompletableFuture

Comprehensive guide to Java threading and Spring Boot asynchronous programming: understanding threads, @Async, Future, CompletableFuture, scheduled tasks, and Tomcat request threading for building responsive applications.

This guide connects the concepts from low-level Java threads to Spring Boot's @Async, Future, CompletableFuture, scheduled tasks, and Tomcat request threading.

Published at Medium.saiifbaba.co.


1. Why Threading Matters in Spring Boot

A typical web request looks like:

Client
  |
  v
HTTP Request
  |
  v
Tomcat Thread
  |
  v
Spring Controller
  |
  v
Service
  |
  v
Database / API / Other Work
  |
  v
HTTP Response

If the request thread spends most of its time waiting:

Tomcat Thread
    |
    +--> HTTP API call
    |       WAIT
    |
    +--> Database query
    |       WAIT
    |
    +--> File operation
            WAIT

that thread cannot normally process another request during the blocking operation.

As traffic increases, insufficient thread capacity can lead to:

  • Increased latency
  • Thread-pool exhaustion
  • Request timeouts
  • Connection-pool exhaustion
  • Poor throughput
  • Cascading failures

Asynchronous programming can help, but it is not a magic performance switch. The correct executor, workload, resource limits, and application architecture still matter.


Part 1 — Java Threads

2. What Is a Thread?

A thread is an independent path of execution inside a process.

A Java application can have multiple threads:

Java Process
├── Main Thread
├── HTTP Worker Threads
├── Database-related Threads
├── Scheduler Threads
└── Background Worker Threads

Threads share process memory but have their own execution stack.


3. Creating a Java Thread

The simplest example is:

Thread thread = new Thread(() -> {
    System.out.println("Running in: " + Thread.currentThread().getName());
});

thread.start();

The important distinction is:

thread.start();

versus:

thread.run();

Calling start() asks the JVM to create/schedule execution on a new thread.

Calling run() directly is simply a normal method invocation on the current thread.


4. Thread Lifecycle

A simplified Java thread lifecycle can be visualized as:

NEW
 |
 | start()
 v
RUNNABLE
 |
 +--> RUNNING
 |
 +--> BLOCKED
 |
 +--> WAITING
 |
 +--> TIMED_WAITING
 |
 v
TERMINATED

The exact JVM scheduling behavior is more nuanced, but this model is useful when debugging thread behavior.


5. Thread Names Matter

Always give important application executors meaningful thread names.

Instead of:

pool-1-thread-7

prefer:

email-worker-7
payment-worker-3
report-scheduler-1

Meaningful names make logs and production debugging much easier.


6. Runnable vs Callable

Runnable represents work that does not return a result:

Runnable task = () -> {
    System.out.println("Doing work");
};

Callable<T> represents work that can return a value or throw an exception:

Callable<String> task = () -> {
    return "Completed";
};

This distinction becomes important when using Future.


Part 2 — ExecutorService

7. Why ExecutorService?

Creating threads manually for every task is usually a poor production strategy:

new Thread(task).start();

If thousands of requests create thousands of threads, the application can become resource-constrained.

ExecutorService provides a managed pool of worker threads:

Tasks
 | | | | |
 v v v v v
+-------------+
| Thread Pool  |
| T1 T2 T3 T4 |
+-------------+

Example:

ExecutorService executor =
        Executors.newFixedThreadPool(10);

executor.submit(() -> {
    System.out.println("Executing task");
});

executor.shutdown();

The pool reuses threads instead of creating a new thread for every task.


8. Fixed Thread Pool

ExecutorService executor =
        Executors.newFixedThreadPool(10);

At most roughly 10 worker threads execute submitted tasks concurrently.

This is useful for workloads where you know the desired concurrency level.

But the correct pool size depends on the workload and resource constraints.


9. Cached Thread Pools

ExecutorService executor =
        Executors.newCachedThreadPool();

This can create threads dynamically and reuse idle threads.

It can be dangerous for uncontrolled workloads because concurrency can grow significantly.

For server-side applications, explicit bounded executors are generally easier to reason about.


10. Scheduled Executors

Java also provides scheduled execution:

ScheduledExecutorService scheduler =
        Executors.newScheduledThreadPool(2);

scheduler.schedule(
        () -> System.out.println("Executed later"),
        5,
        TimeUnit.SECONDS
);

This is the foundation behind many scheduling patterns.


Part 3 — Future

11. What Is Future?

A Future<T> represents the result of asynchronous computation.

Example:

ExecutorService executor =
        Executors.newFixedThreadPool(5);

Future<String> future = executor.submit(() -> {
    Thread.sleep(1000);
    return "Hello";
});

Later:

String result = future.get();

The problem is that:

future.get();

can block until the result is available.

So although the work was submitted asynchronously, the caller can still become blocked when it asks for the result.


12. Future Cancellation

A future can be cancelled:

future.cancel(true);

And you can inspect its state:

future.isDone();
future.isCancelled();

This is useful when a task is no longer needed.


13. Limitations of Future

Future works, but composing multiple asynchronous operations is cumbersome.

Suppose you need:

Call Service A
      |
      v
Call Service B
      |
      v
Combine Results
      |
      v
Return Response

With plain Future, you can quickly end up with blocking calls:

Future<A> a = executor.submit(...);
Future<B> b = executor.submit(...);

A resultA = a.get();
B resultB = b.get();

This is one reason CompletableFuture became so useful.


Part 4 — CompletableFuture

14. What Is CompletableFuture?

CompletableFuture<T> supports asynchronous computation and composition.

Basic example:

CompletableFuture<String> future =
        CompletableFuture.supplyAsync(() -> {
            return "Hello";
        });

Retrieve the result:

future.thenAccept(System.out::println);

The major advantage is that operations can be chained.


15. thenApply

Use thenApply when you want to transform a result:

CompletableFuture<String> future =
        CompletableFuture.supplyAsync(() -> "hello");

CompletableFuture<String> upper =
        future.thenApply(String::toUpperCase);

Flow:

"hello"
   |
   v
toUpperCase()
   |
   v
"HELLO"

16. thenAccept

Use thenAccept when you consume the result but do not produce another result:

future.thenAccept(result -> {
    System.out.println(result);
});

17. thenRun

Use thenRun when you only want to run another action after completion:

future.thenRun(() -> {
    System.out.println("Task completed");
});

It does not receive the previous result.


18. thenCompose

thenCompose is useful when one asynchronous operation depends on another.

CompletableFuture<User> userFuture =
        getUser();

CompletableFuture<Order> orderFuture =
        userFuture.thenCompose(user ->
                getLatestOrder(user.getId())
        );

Conceptually:

getUser()
   |
   v
User
   |
   v
getLatestOrder(userId)
   |
   v
Order

This avoids creating nested futures such as:

CompletableFuture<CompletableFuture<Order>>

19. thenCombine

Use thenCombine when two independent asynchronous operations can execute concurrently and their results need to be combined.

CompletableFuture<User> user =
        getUser();

CompletableFuture<Account> account =
        getAccount();

CompletableFuture<UserAccount> result =
        user.thenCombine(
                account,
                UserAccount::new
        );

Conceptually:

        +--> getUser() ------+
        |                    |
Start --+                    +--> Combine --> Result
        |                    |
        +--> getAccount() ---+

20. Running Independent Tasks in Parallel

Suppose a dashboard needs:

User Profile
Recommendations
Notifications

These may be independent.

Instead of:

Profile
  |
  v
Recommendations
  |
  v
Notifications

you can start them concurrently:

          +--> Profile --------+
          |                    |
Request --+--> Recommendations +--> Combine
          |                    |
          +--> Notifications --+

Example:

CompletableFuture<Profile> profile =
        getProfile();

CompletableFuture<List<Item>> recommendations =
        getRecommendations();

CompletableFuture<List<Notification>> notifications =
        getNotifications();

Then combine the results.


Part 5 — Exception Handling

21. exceptionally

Handle a failure with:

future.exceptionally(ex -> {
    log.error("Async operation failed", ex);
    return "fallback";
});

This allows you to produce a fallback value.


22. handle

handle receives either the successful result or an exception:

future.handle((result, ex) -> {
    if (ex != null) {
        return "fallback";
    }

    return result;
});

23. whenComplete

Use whenComplete when you want to observe completion without necessarily transforming the result:

future.whenComplete((result, ex) -> {
    if (ex != null) {
        log.error("Failed", ex);
    } else {
        log.info("Completed: {}", result);
    }
});

24. Exception Propagation

Exceptions in a CompletableFuture chain can propagate downstream.

For example:

CompletableFuture<String> result =
        CompletableFuture
                .supplyAsync(this::loadData)
                .thenApply(this::process)
                .thenApply(this::format);

If loadData() fails, later stages may not execute normally.

This makes centralized error handling important in complex async workflows.


Part 6 — Spring Boot @Async

25. Enabling Async Processing

Spring provides a high-level abstraction over asynchronous execution.

Enable it:

@Configuration
@EnableAsync
public class AsyncConfig {
}

Then:

@Service
public class EmailService {

    @Async
    public void sendEmail() {
        // background work
    }
}

The caller can return without waiting for the method to finish.


26. What @Async Actually Does

Like @Transactional, @Async commonly works through Spring proxying.

Conceptually:

Caller
  |
  v
Spring Proxy
  |
  +---- Submit Task to Executor
  |
  +---- Return
          |
          v
     Worker Thread
          |
          v
    Async Method

The method does not necessarily execute on the original request thread.


27. The Default Executor

If no custom executor is configured, Spring may use a default asynchronous executor strategy.

For production applications, explicitly configuring a bounded ThreadPoolTaskExecutor is often preferable because it makes concurrency, queueing, and resource usage visible and controllable.


28. Custom ThreadPoolTaskExecutor

Example:

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean(name = "applicationTaskExecutor")
    public Executor applicationTaskExecutor() {

        ThreadPoolTaskExecutor executor =
                new ThreadPoolTaskExecutor();

        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(30);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("app-async-");

        executor.initialize();

        return executor;
    }
}

Then:

@Async("applicationTaskExecutor")
public void process() {
    // async work
}

This is much more explicit than relying on an unspecified executor.


29. Understanding Core Pool, Max Pool and Queue

Suppose:

corePoolSize = 10
maxPoolSize = 30
queueCapacity = 100

A simplified model is:

Tasks arrive
    |
    v
Use available worker threads
    |
    v
Queue additional tasks
    |
    v
If queue is saturated
    |
    v
Create additional threads up to max
    |
    v
If everything is saturated
    |
    v
RejectedExecutionHandler

Exact behavior depends on the executor implementation and configuration.

The queue is not an infinite buffer. Unbounded queues can hide overload until latency and memory usage become unacceptable.


Part 7 — Future with Spring @Async

30. Returning Future

An @Async method can return a future:

@Async("applicationTaskExecutor")
public Future<String> process() {

    String result = doWork();

    return new AsyncResult<>(result);
}

Caller:

Future<String> future =
        service.process();

String result = future.get();

Again, get() can block.


31. Returning CompletableFuture from @Async

A more modern approach is:

@Async("applicationTaskExecutor")
public CompletableFuture<String> process() {

    String result = doWork();

    return CompletableFuture.completedFuture(result);
}

Then:

service.process()
       .thenAccept(System.out::println);

This allows asynchronous composition.


Part 8 — Important @Async Pitfall

32. Self-Invocation

This does not reliably activate asynchronous proxy behavior:

@Service
public class ReportService {

    public void generate() {
        generateAsync();
    }

    @Async
    public void generateAsync() {
        // ...
    }
}

The internal call can bypass the Spring proxy.

Better:

@Service
public class ReportService {

    private final AsyncReportService asyncReportService;

    public void generate() {
        asyncReportService.generateAsync();
    }
}
@Service
public class AsyncReportService {

    @Async
    public void generateAsync() {
        // ...
    }
}

The same proxy principle explains many @Transactional and @Async surprises.


Part 9 — Async and Transactions

33. Does a Transaction Automatically Follow @Async?

Generally, do not assume it does.

Consider:

@Transactional
public void processOrder() {
    saveOrder();

    asyncService.sendNotification();
}

The async method executes on another thread.

The original transaction is not automatically propagated to that worker thread in the same way as a normal synchronous method call.

If the async operation needs database access, define its own transaction boundary where appropriate:

@Async
@Transactional
public void processAsync() {
    // ...
}

But be careful: this starts/manages a transaction for the async operation itself; it does not make it the same transaction as the caller's.


Part 10 — Tomcat Threading

34. How a Spring Boot HTTP Request Is Processed

When using embedded Tomcat:

Client
  |
  v
TCP Connection
  |
  v
Tomcat
  |
  v
Request Worker Thread
  |
  v
Spring MVC
  |
  v
Controller
  |
  v
Service

The request is normally handled by a Tomcat worker thread.

For example:

http-nio-8080-exec-1
http-nio-8080-exec-2
http-nio-8080-exec-3

These names are commonly visible in Spring Boot logs.


35. Tomcat Thread Pool

Tomcat maintains a pool of request-processing threads.

If too many requests arrive and all available workers are busy:

Request 1 --> Thread 1
Request 2 --> Thread 2
Request 3 --> Thread 3
...
Request N --> Waiting / constrained

This can increase request latency.

The exact connection and request queue behavior depends on the connector and configuration.


36. Tomcat vs @Async

This distinction is critical.

Tomcat threads:

HTTP Request
    |
    v
Tomcat Worker Thread

Spring async executor:

@Async Method
    |
    v
Application Worker Thread

They are different thread pools.

Example:

HTTP Request
    |
    v
Tomcat Thread
    |
    +--> @Async
             |
             v
       Async Executor
             |
             v
       Worker Thread

Adding @Async does not increase the Tomcat request-thread pool.


Part 11 — Why @Async Can Improve Responsiveness

37. Fire-and-Continue Pattern

Suppose an API needs to save an order and send a notification.

Without async:

Request Thread
    |
    +--> Save Order
    |
    +--> Send Email
    |       WAIT
    |
    +--> Return Response

With async:

Request Thread
    |
    +--> Save Order
    |
    +--> Submit Email Task
    |
    +--> Return Response
              |
              v
        Async Worker
              |
              +--> Send Email

The request can complete sooner.

However, the system has now introduced eventual completion and failure-handling concerns.


38. Don't Use Async Everywhere

Bad design:

@Async
public void everyMethod() {
}

Asynchronous execution introduces:

  • Another thread pool
  • Queueing
  • Context propagation concerns
  • Failure handling
  • Monitoring requirements
  • Ordering questions
  • Shutdown behavior
  • Resource contention

Use it where concurrency actually improves the business workflow.


Part 12 — Task Scheduling

39. @Scheduled

Spring provides scheduled execution.

Enable scheduling:

@Configuration
@EnableScheduling
public class SchedulingConfig {
}

Then:

@Scheduled(fixedRate = 5000)
public void refreshCache() {
    // runs periodically
}

40. Fixed Rate

@Scheduled(fixedRate = 5000)

Conceptually, the task is scheduled at a fixed interval.

For example:

T=0s   Run
T=5s   Run
T=10s  Run
T=15s  Run

Actual execution can be affected by scheduler availability and task duration.


41. Fixed Delay

@Scheduled(fixedDelay = 5000)
public void process() {
}

The next execution is scheduled after the previous execution completes and the configured delay passes.

Conceptually:

Start
  |
  v
Work
  |
  v
Complete
  |
  +--> 5 second delay
  |
  v
Start again

This differs from fixed-rate scheduling.


42. Cron Scheduling

Example:

@Scheduled(cron = "0 0 2 * * *")
public void nightlyJob() {
    // run at 2 AM
}

Cron expressions are useful for calendar-based schedules.

Examples:

Every hour
Every day at a specific time
Specific weekdays
Monthly jobs

Always verify the cron format and timezone expectations for the Spring Boot version and deployment environment being used.


Part 13 — Scheduled Task Threading

43. Scheduler Pool

Scheduled tasks use Spring's scheduling infrastructure.

If multiple scheduled jobs run at the same time, you need to consider the scheduler's thread capacity.

A common configuration is:

@Bean
public TaskScheduler taskScheduler() {
    ThreadPoolTaskScheduler scheduler =
            new ThreadPoolTaskScheduler();

    scheduler.setPoolSize(5);
    scheduler.setThreadNamePrefix("scheduler-");
    scheduler.initialize();

    return scheduler;
}

Now several scheduled jobs can execute concurrently when appropriate.


44. Scheduled + Async

You can combine scheduling and asynchronous execution carefully:

@Scheduled(fixedRate = 60000)
@Async("applicationTaskExecutor")
public void processBatch() {
    // ...
}

The scheduler triggers the method, while the async executor handles the actual work.

This can be useful, but you must prevent overlapping executions when the job must be single-instance or ordered.


Part 14 — Thread Pool Sizing

45. CPU-Bound vs I/O-Bound Work

Thread-pool sizing depends heavily on workload type.

CPU-bound

Examples:

  • Compression
  • Image processing
  • Encryption
  • Heavy calculations

Too many threads can increase context switching and reduce performance.

A pool near the available CPU capacity is often a starting point, followed by measurement.

I/O-bound

Examples:

  • HTTP calls
  • Database waits
  • File I/O
  • Remote service calls

Threads may spend significant time waiting, so concurrency can be higher than CPU count.

But the downstream resource becomes the limiting factor.


46. The Database Connection Pool Limit

Suppose:

Async Threads = 100
DB Connections = 20

If all async tasks immediately need a database connection:

100 Worker Threads
        |
        v
20 DB Connections
        |
        v
80 tasks waiting

Increasing the thread pool to 200 may make things worse.

The bottleneck has not changed.

This is one of the most important production threading lessons:

Thread-pool size must be designed together with downstream resource capacity.


Part 15 — Backpressure and Queueing

47. What Happens When Tasks Arrive Faster Than They Complete?

Suppose:

Incoming Tasks = 1000/sec
Processing Capacity = 100/sec

A queue may grow:

1000 tasks
   |
   v
Executor
   |
   +--> Workers process 100/sec
   |
   +--> Queue grows

Eventually:

Queue Full
   |
   v
Reject / Drop / Slow Producer

Without a capacity strategy, overload can become a memory and latency problem.


48. Rejection Policies

Java executors support rejection strategies.

Examples include:

Abort
Caller Runs
Discard
Discard Oldest

The correct choice depends on the business semantics.

For example, CallerRunsPolicy can provide a form of backpressure by making the submitting thread execute work when the pool is saturated.

But that can also make an HTTP request thread perform expensive work, so it must be chosen intentionally.


Part 16 — Async Context Propagation

49. ThreadLocal Does Not Automatically Mean "Every Thread"

Many frameworks use thread-local state for contextual information.

Examples:

  • Security context
  • Request context
  • MDC logging context
  • Transaction context

When work moves to another thread, you should not assume all thread-local context follows automatically.

For example:

Request Thread
   |
   +--> User Context
   |
   +--> @Async
            |
            v
       Worker Thread

The worker thread may not have the same context.

For security-sensitive or logging-sensitive workflows, use the appropriate Spring-supported context propagation mechanisms rather than manually copying sensitive state everywhere.


Part 17 — Logging Async Applications

50. Thread Names in Logs

A useful log:

2026-08-29 10:30:10 INFO
[http-nio-8080-exec-4]
OrderController - Request received

Then:

2026-08-29 10:30:10 INFO
[app-async-2]
EmailService - Email processing started

This makes thread transitions visible.

For distributed systems, combine thread names with correlation/request IDs.


Part 18 — Virtual Threads

51. Why Virtual Threads Matter

Modern Java provides virtual threads as a lightweight concurrency model.

Traditional platform threads map more directly to operating-system threads.

Virtual threads are JVM-managed lightweight threads designed to make large numbers of concurrent blocking tasks more practical.

Conceptually:

Platform Threads
    |
    +--> OS resources
    |
    +--> More expensive per thread

Virtual Threads
    |
    +--> JVM-managed
    |
    +--> Lightweight

Virtual threads are especially interesting for I/O-heavy applications.


52. Virtual Threads in Spring Boot

Depending on the Java and Spring Boot version, virtual threads can be enabled/configured for supported application components.

A common property in compatible Spring Boot versions is:

spring.threads.virtual.enabled=true

Do not assume virtual threads eliminate all bottlenecks.

They do not make:

  • Databases infinitely scalable
  • HTTP APIs faster
  • CPU work free
  • Connection pools unlimited

The downstream resource still determines capacity.


Part 19 — CompletableFuture with Custom Executor

53. Avoid Uncontrolled Async Execution

This:

CompletableFuture.supplyAsync(() -> {
    return loadData();
});

uses the default asynchronous execution facility unless an executor is supplied.

For application-controlled workloads, consider:

CompletableFuture.supplyAsync(
        () -> loadData(),
        applicationExecutor
);

Now the executor is explicit:

CompletableFuture
       |
       v
Application Executor
       |
       v
Controlled Worker Pool

This makes capacity planning and observability easier.


Part 20 — Async HTTP Requests

54. Parallel External Calls

Suppose a service needs three independent APIs:

CompletableFuture<User> user =
        userClient.getUserAsync();

CompletableFuture<List<Order>> orders =
        orderClient.getOrdersAsync();

CompletableFuture<Profile> profile =
        profileClient.getProfileAsync();

Then:

             +--> User API ------+
             |                  |
Request -----+--> Orders API ---+--> Combine
             |                  |
             +--> Profile API --+

This can reduce total latency from approximately:

Tuser + Torders + Tprofile

toward:

max(Tuser, Torders, Tprofile)

assuming the calls are truly independent, can execute concurrently, and there are no additional bottlenecks.


Part 21 — Timeout and Resilience

55. Never Let Async Work Wait Forever

External operations should have reasonable timeouts.

With CompletableFuture, timeout support can be expressed using methods such as:

future.orTimeout(5, TimeUnit.SECONDS);

or:

future.completeOnTimeout(
        fallback,
        5,
        TimeUnit.SECONDS
);

Use fallbacks only when the business semantics allow them.

Timeouts should also be configured at the underlying HTTP client/database layer where applicable.


Part 22 — Common Threading Problems

56. Thread Starvation

Thread starvation occurs when all worker threads are occupied and new work cannot progress.

Example:

Pool Size = 10

Thread 1 -> waiting for service
Thread 2 -> waiting for service
...
Thread 10 -> waiting for service

New task -> WAITING

If tasks depend on other tasks scheduled onto the same exhausted pool, the situation can become especially dangerous.


57. Deadlock

A deadlock can happen when threads wait for each other's locks indefinitely.

Conceptually:

Thread A
  |
  +--> Lock 1
  |
  +--> Waiting for Lock 2

Thread B
  |
  +--> Lock 2
  |
  +--> Waiting for Lock 1

Neither can continue.

Avoid unnecessary locking and establish consistent lock ordering when locks are required.


58. Race Conditions

A race condition occurs when the result depends on unpredictable execution ordering.

Example:

counter++;

is not automatically safe for concurrent access.

Possible solutions include:

AtomicInteger
synchronized
Lock
ConcurrentHashMap

depending on the problem.


Part 23 — Thread Safety in Spring Beans

59. Singleton Beans

Spring services are commonly singleton-scoped.

Example:

@Service
public class OrderService {
}

There is normally one shared instance per application context.

Therefore, avoid mutable request-specific state:

@Service
public class BadService {

    private String currentUser;

}

Multiple requests can access the same object concurrently.

Prefer local variables:

@Service
public class GoodService {

    public void process(String user) {
        String currentUser = user;
    }
}

Local variables belong to the executing thread's stack frame and are not shared in the same way as instance fields.


Part 24 — Tomcat and Async Controller Processing

60. Spring MVC Async Request Processing

Spring MVC can also support asynchronous request processing using types such as:

Callable<T>
DeferredResult<T>
WebAsyncTask<T>

Example:

@GetMapping("/report")
public Callable<String> report() {
    return () -> generateReport();
}

This is different from simply putting @Async on a service method.

The goal is to allow the servlet request lifecycle to release its original request-processing thread while the async result is being computed.

The exact executor and container behavior depends on the Spring MVC configuration.


Part 25 — @Async vs CompletableFuture vs Scheduling

61. Choosing the Right Tool

Use a normal method when:

Work is fast
Work must be synchronous
Caller needs immediate result

Use @Async when:

Work should execute on a Spring-managed executor
Caller does not need to block

Use CompletableFuture when:

You need asynchronous composition
You need parallel operations
You need transformations
You need explicit success/failure stages

Use @Scheduled when:

Work should run according to a schedule

Use a message broker when:

Work should be decoupled
Work should survive process boundaries
Work needs durable delivery
Consumers should scale independently

This last distinction is important. An in-memory async task is not equivalent to a durable Kafka/RabbitMQ/SQS message.


Part 26 — Async vs Messaging

62. In-Memory Async

Application
    |
    v
Thread Pool
    |
    v
Task

If the application crashes before the task completes, the task can be lost.


63. Message-Based Processing

Producer
   |
   v
Message Broker
   |
   v
Consumer
   |
   v
Worker

The broker can provide durability, retry, replay, consumer scaling, and decoupling depending on the technology and configuration.

For critical business operations, durable messaging is often more appropriate than fire-and-forget in-memory execution.


Part 27 — Production Configuration Example

64. Async Executor Configuration

A practical example:

@Configuration
@EnableAsync
public class AsyncConfiguration {

    @Bean(name = "applicationExecutor")
    public Executor applicationExecutor() {

        ThreadPoolTaskExecutor executor =
                new ThreadPoolTaskExecutor();

        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(30);
        executor.setQueueCapacity(200);
        executor.setKeepAliveSeconds(60);
        executor.setThreadNamePrefix("application-async-");

        executor.initialize();

        return executor;
    }
}

Use:

@Async("applicationExecutor")
public CompletableFuture<Result> execute() {
    // ...
}

The numbers above are examples, not universal production defaults. Measure the workload before choosing them.


Part 28 — Async Error Handling

65. Void @Async Methods

For:

@Async
public void process() {
}

the caller does not receive a result.

Unhandled exceptions therefore need appropriate asynchronous exception handling.

Spring supports an AsyncUncaughtExceptionHandler for qualifying void async methods.

Example:

@Configuration
@EnableAsync
public class AsyncConfiguration
        implements AsyncConfigurer {

    @Override
    public AsyncUncaughtExceptionHandler
    getAsyncUncaughtExceptionHandler() {

        return (ex, method, params) -> {
            // log/report the failure
        };
    }
}

For business-critical operations, returning a CompletableFuture or using durable messaging can provide a more explicit failure model.


Part 29 — Graceful Shutdown

66. What Happens During Application Shutdown?

Suppose:

100 async tasks

are running when the application shuts down.

You should configure executors so shutdown behavior is intentional.

Important considerations include:

  • Should queued tasks finish?
  • How long should the application wait?
  • Can tasks be safely interrupted?
  • Are tasks idempotent?
  • Is work recoverable from a message broker?

For critical background work, durable messaging is usually safer than relying entirely on in-memory executor queues.


Part 30 — Monitoring Thread Pools

67. What Should Be Monitored?

At minimum, observe:

Active threads
Pool size
Queue size
Task completion rate
Rejected tasks
Task latency
Task failures
Tomcat busy threads
HTTP request latency
Database pool usage

A thread pool is a production resource, not just a configuration value.

If queue depth keeps increasing:

Queue:
10
50
100
150
200

the system is falling behind.


Part 31 — A Realistic Request Flow

68. Spring Boot Application with Async Work

Imagine:

Client
  |
  v
Load Balancer
  |
  v
Tomcat
  |
  v
Controller
  |
  v
Order Service
  |
  +--> Database
  |
  +--> @Async Notification
  |         |
  |         v
  |     Async Executor
  |         |
  |         v
  |      Email API
  |
  +--> Response

The HTTP response can be returned without waiting for non-critical notification work.

However, if the notification is business-critical, simply using @Async may not be sufficient. A durable event/message can be a better architecture.


Part 32 — Example: Parallel Dashboard

69. Dashboard Service

@Service
@RequiredArgsConstructor
public class DashboardService {

    private final UserClient userClient;
    private final OrderClient orderClient;
    private final RecommendationClient recommendationClient;
    private final Executor applicationExecutor;

    public CompletableFuture<Dashboard> getDashboard(Long userId) {

        CompletableFuture<User> user =
                CompletableFuture.supplyAsync(
                        () -> userClient.getUser(userId),
                        applicationExecutor
                );

        CompletableFuture<List<Order>> orders =
                CompletableFuture.supplyAsync(
                        () -> orderClient.getOrders(userId),
                        applicationExecutor
                );

        CompletableFuture<List<Item>> recommendations =
                CompletableFuture.supplyAsync(
                        () -> recommendationClient.getRecommendations(userId),
                        applicationExecutor
                );

        return user
                .thenCombine(orders, Dashboard::withOrders)
                .thenCombine(
                        recommendations,
                        Dashboard::withRecommendations
                );
    }
}

The three independent calls can execute concurrently.


Part 33 — Important Caveat About Parallel Calls

70. Concurrency Is Not Free

If one request launches:

3 external calls

and the application receives:

1000 requests/sec

you may create pressure equivalent to thousands of downstream calls per second.

Therefore, parallelism should be controlled.

Consider:

Concurrency Limit
Timeouts
Retries
Circuit Breakers
Bulkheads
Connection Pools
Rate Limits

A faster individual request can still damage the system if it generates uncontrolled downstream load.


Part 34 — Thread Pools as Bulkheads

71. Separate Pools for Different Workloads

Instead of:

One Executor
   |
   +--> Email
   +--> Reports
   +--> External APIs
   +--> Image Processing

consider dedicated executors when workloads have very different characteristics:

Email Executor
Report Executor
API Executor
CPU Executor

Then a report backlog does not necessarily consume every thread needed for latency-sensitive work.

This is a bulkhead-style isolation strategy.


Part 35 — Common Mistakes

72. Mistake: Creating a Thread Per Request

Avoid:

new Thread(() -> process()).start();

inside every controller invocation.

Use managed executors or messaging.


73. Mistake: Unbounded Queues

An unbounded queue can hide overload:

Incoming Work
    |
    v
Huge Queue
    |
    v
Workers

The application may appear healthy while latency and memory consumption grow.


74. Mistake: Blocking Inside Async Chains

This:

future1.get();
future2.get();

inside an asynchronous workflow can reintroduce blocking.

Prefer composition:

future1.thenCombine(future2, ...)

where appropriate.


75. Mistake: Calling join() Everywhere

This:

future.join();

can turn asynchronous code back into blocking code.

Use blocking only when the application boundary genuinely requires the final result and the wait is understood.


76. Mistake: Ignoring Downstream Limits

Increasing:

Thread Pool: 10 -> 100

does not automatically increase:

Database Connections
External API Quota
CPU
Memory

More concurrency can make an overloaded system fail faster.


Part 36 — Interview Questions

77. What Is the Difference Between Thread and ExecutorService?

A Thread represents an execution thread. ExecutorService manages a pool of threads and provides task submission and lifecycle management.


78. Future vs CompletableFuture?

Future provides a handle to an asynchronous result but has limited composition capabilities.

CompletableFuture supports chaining, combination, transformation, and asynchronous error handling.


79. What Does @Async Do?

It instructs Spring to execute the annotated method asynchronously using a configured executor, typically through proxy-based interception.


80. Why Does @Async Fail During Self-Invocation?

Because the internal method call can bypass the Spring proxy responsible for applying the asynchronous interceptor.


81. Does @Async Create a New Thread Every Time?

Not necessarily. It normally submits work to an executor, which manages a pool or another configured execution strategy.


82. Are Tomcat Threads and Async Threads the Same?

No.

Tomcat manages HTTP request processing threads, while @Async typically uses an application task executor.


83. Why Can Too Many Threads Hurt Performance?

Threads consume memory and scheduling resources. Excessive concurrency can cause context switching, contention, downstream overload, and increased latency.


84. When Should You Use Kafka Instead of @Async?

Use durable messaging when work needs persistence, decoupling, retries, replay, independent scaling, or cross-process communication.

@Async is in-process asynchronous execution and does not provide those guarantees by itself.


Part 37 — Production Checklist

Before introducing asynchronous processing, ask:

[ ] Is the work actually suitable for async execution?
[ ] Is the work CPU-bound or I/O-bound?
[ ] Which executor will execute it?
[ ] Is the executor bounded?
[ ] What is the queue capacity?
[ ] What happens when the queue is full?
[ ] What happens when the task fails?
[ ] Does the caller need the result?
[ ] Is a timeout configured?
[ ] Is retry behavior safe?
[ ] Is the operation idempotent?
[ ] Are downstream connection pools large enough?
[ ] Could async work overload another service?
[ ] Does the task require security/request context?
[ ] Does it require a database transaction?
[ ] What happens during application shutdown?
[ ] Should this be a durable message instead?
[ ] Are thread-pool metrics monitored?

Part 38 — Final Mental Model

85. The Complete Picture

Think of a Spring Boot application as multiple execution systems working together:

                         Spring Boot
                             |
          +------------------+------------------+
          |                  |                  |
          v                  v                  v
      Tomcat Pool       Async Executor      Scheduler
          |                  |                  |
          v                  v                  v
     HTTP Requests       @Async Work       @Scheduled Jobs
          |                  |                  |
          +------------------+------------------+
                             |
                             v
                     Application Services
                             |
          +------------------+------------------+
          |                  |                  |
          v                  v                  v
       Database          HTTP APIs          Message Broker

The key ideas are:

Java Thread
    |
    v
ExecutorService
    |
    v
Future
    |
    v
CompletableFuture
    |
    v
Spring @Async
    |
    +--> Custom Executor
    |
    v
Task Scheduling
    |
    v
Tomcat Request Threads

The most important production principle is:

Concurrency should be controlled, not maximized.

A well-designed Spring Boot application uses asynchronous execution when it improves responsiveness or throughput, but it also respects the limits of CPU, memory, database connections, downstream services, queues, and external APIs.

Once you understand how Tomcat request threads, Spring async executors, Future, CompletableFuture, scheduled tasks, transactions, and durable messaging fit together, you can make much better decisions about where work should execute and how much concurrency your system can safely handle.

Design & Developed by Saif Baba MS
© 2026. All rights reserved.