
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 ResponseIf the request thread spends most of its time waiting:
Tomcat Thread
|
+--> HTTP API call
| WAIT
|
+--> Database query
| WAIT
|
+--> File operation
WAITthat 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 ThreadsThreads 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
TERMINATEDThe 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-7prefer:
email-worker-7
payment-worker-3
report-scheduler-1Meaningful 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 ResponseWith 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
OrderThis 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
NotificationsThese may be independent.
Instead of:
Profile
|
v
Recommendations
|
v
Notificationsyou 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 MethodThe 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 = 100A 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
RejectedExecutionHandlerExact 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
ServiceThe 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-3These 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 / constrainedThis 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 ThreadSpring async executor:
@Async Method
|
v
Application Worker ThreadThey are different thread pools.
Example:
HTTP Request
|
v
Tomcat Thread
|
+--> @Async
|
v
Async Executor
|
v
Worker ThreadAdding @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 ResponseWith async:
Request Thread
|
+--> Save Order
|
+--> Submit Email Task
|
+--> Return Response
|
v
Async Worker
|
+--> Send EmailThe 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 RunActual 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 againThis 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 jobsAlways 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 = 20If all async tasks immediately need a database connection:
100 Worker Threads
|
v
20 DB Connections
|
v
80 tasks waitingIncreasing 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/secA queue may grow:
1000 tasks
|
v
Executor
|
+--> Workers process 100/sec
|
+--> Queue growsEventually:
Queue Full
|
v
Reject / Drop / Slow ProducerWithout 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 OldestThe 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 ThreadThe 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 receivedThen:
2026-08-29 10:30:10 INFO
[app-async-2]
EmailService - Email processing startedThis 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
|
+--> LightweightVirtual 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=trueDo 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 PoolThis 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 + Tprofiletoward:
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 -> WAITINGIf 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 1Neither 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
ConcurrentHashMapdepending 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 resultUse @Async when:
Work should execute on a Spring-managed executor
Caller does not need to blockUse CompletableFuture when:
You need asynchronous composition
You need parallel operations
You need transformations
You need explicit success/failure stagesUse @Scheduled when:
Work should run according to a scheduleUse a message broker when:
Work should be decoupled
Work should survive process boundaries
Work needs durable delivery
Consumers should scale independentlyThis 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
TaskIf the application crashes before the task completes, the task can be lost.
63. Message-Based Processing
Producer
|
v
Message Broker
|
v
Consumer
|
v
WorkerThe 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 tasksare 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 usageA thread pool is a production resource, not just a configuration value.
If queue depth keeps increasing:
Queue:
10
50
100
150
200the 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
|
+--> ResponseThe 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 callsand the application receives:
1000 requests/secyou 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 LimitsA 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 Processingconsider dedicated executors when workloads have very different characteristics:
Email Executor
Report Executor
API Executor
CPU ExecutorThen 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
WorkersThe 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 -> 100does not automatically increase:
Database Connections
External API Quota
CPU
MemoryMore 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 BrokerThe key ideas are:
Java Thread
|
v
ExecutorService
|
v
Future
|
v
CompletableFuture
|
v
Spring @Async
|
+--> Custom Executor
|
v
Task Scheduling
|
v
Tomcat Request ThreadsThe 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.