Java’s introduction of virtual threads in Project Loom has revolutionized how developers handle concurrency. These lightweight threads make it possible to build highly scalable and responsive applications without the complexity of traditional thread management. When combined with Quarkus, a modern Java framework optimized for cloud and containerized environments, virtual threads offer a powerful way to simplify concurrent programming while improving performance.
In this tutorial, we’ll explore how to use virtual threads in a Quarkus application to handle concurrent tasks efficiently. You’ll learn how virtual threads differ from platform threads, how Quarkus integrates with the new Java concurrency model, and how to build a practical example that demonstrates their benefits in real-world applications.
By the end of this guide, you’ll understand how to:
-
Enable and configure virtual threads in Quarkus
-
Use them for non-blocking and parallel tasks
-
Compare performance and scalability benefits over traditional approaches
Whether you’re a backend developer looking to modernize your Java stack or simply curious about the future of concurrency in Java, this tutorial will help you harness the full potential of virtual threads in Quarkus.
Prerequisites and Setup
Before we dive into coding, make sure your development environment is ready to run a Quarkus application using virtual threads. Since virtual threads are available from Java 21 onward, ensure you’re using the correct version and tools.
Prerequisites
You’ll need the following installed on your system:
-
Java 21 or higher (Project Loom features are included)
-
Maven 3.9+ or Gradle 8+
-
Quarkus CLI (optional but recommended)
-
A code editor such as Visual Studio Code, IntelliJ IDEA, or Eclipse
-
cURL or HTTPie (for testing REST endpoints)
To verify your Java version, run:
java -version
It should output something like:
java version "21.0.8" 2025-07-15 LTS
Java(TM) SE Runtime Environment (build 21.0.8+12-LTS-250)
Java HotSpot(TM) 64-Bit Server VM (build 21.0.8+12-LTS-250, mixed mode, sharing)
Setting Up a New Quarkus Project
You can create a new Quarkus project using either Maven or the Quarkus CLI.
Using the Quarkus CLI
quarkus create app com.djamware:quarkus-virtual-threads:1.0.0
cd quarkus-virtual-threads
Using Maven
mvn io.quarkus.platform:quarkus-maven-plugin:create \
-DprojectGroupId=com.djamware \
-DprojectArtifactId=quarkus-virtual-threads \
-DclassName="com.djamware.virtualthreads.GreetingResource" \
-Dpath="/hello"
cd quarkus-virtual-threads
Once generated, open the project in your preferred IDE.
Adding Required Extensions
Next, add the necessary Quarkus extensions for a REST API:
quarkus extension add 'quarkus-resteasy-reactive'
If you’re using Maven instead:
./mvnw quarkus:add-extension -Dextensions="quarkus-resteasy-reactive"
Project Structure Overview
After setup, your project should have a structure similar to:
quarkus-virtual-threads/
├── src/
│ ├── main/
│ │ ├── java/com/djamware/virtualthreads/
│ │ │ └── GreetingResource.java
│ │ └── resources/
│ │ └── application.properties
│ └── test/
│ └── java/com/djamware/virtualthreads/
├── pom.xml
└── README.md
At this point, your Quarkus project is ready to run and be configured for virtual threads.
Enabling Virtual Threads in Quarkus
Starting from Quarkus 3.6+, the framework offers native support for Java virtual threads. This means you can easily enable virtual thread execution for REST endpoints, background tasks, and other blocking operations without complex configurations or external libraries.
Why Enable Virtual Threads?
Traditionally, each request in a Quarkus application (or any Java-based server) is handled by a platform thread, which maps directly to an operating system thread. While efficient for small workloads, platform threads can become a bottleneck when you have thousands of concurrent requests or blocking I/O operations.
Virtual threads solve this by being lightweight and managed by the JVM, allowing you to scale to thousands (or even millions) of concurrent operations with minimal overhead — perfect for microservices and cloud workloads.
Enabling Virtual Threads via Configuration
To enable virtual threads globally in your Quarkus application, open the src/main/resources/application.properties file and add the following line:
quarkus.virtual-threads.enabled=true
This setting tells Quarkus to execute REST requests and other blocking tasks using virtual threads automatically.
Using Virtual Threads in REST Endpoints
When virtual threads are enabled, Quarkus automatically uses them for REST endpoints that involve blocking operations such as database queries, file I/O, or network calls. Let’s create a simple endpoint that simulates a time-consuming task.
Update GreetingResource.java as follows:
package com.djamware.virtualthreads;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/hello")
public class GreetingResource {
@GET
@Produces(MediaType.TEXT_PLAIN)
public String hello() {
try {
// Simulate a blocking operation
Thread.sleep(2000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return "Hello from Virtual Threads!";
}
}
Even though this method blocks for 2 seconds, Quarkus efficiently runs it on a virtual thread — freeing up platform threads and improving concurrency.
Running the Application
Start your application in development mode:
./mvnw quarkus:dev
You should see an output like:
INFO [io.quarkus] (Quarkus Main Thread) quarkus-virtual-threads 1.0.0-SNAPSHOT on JVM (powered by Quarkus 3.28.5) started in 1.165s. Listening on: http://localhost:8080
Test your endpoint using a browser or curl:
curl http://localhost:8080/hello
You’ll get the response:
Hello from Virtual Threads!
Verifying Virtual Thread Execution
To confirm that your endpoint is running on a virtual thread, you can log the current thread’s name:
System.out.println("Running on: " + Thread.currentThread());
The output should show something like:
Running on: VirtualThread[#23]/runnable@ForkJoinPool-1-worker-2
This confirms that Quarkus is executing your REST endpoint using Java virtual threads.
Implementing Concurrent Tasks with Virtual Threads
Now that your Quarkus app is running with virtual threads enabled, let’s explore how to use them for concurrent task execution. Virtual threads make it easy to run multiple blocking operations in parallel — such as calling external APIs, querying databases, or processing files — without overwhelming your system with platform threads.
Running Tasks Concurrently
Let’s simulate multiple blocking operations and execute them in parallel using virtual threads. Create a new Java class named ConcurrentService.java inside the com.djamware.virtualthreads package:
package com.djamware.virtualthreads;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class ConcurrentService {
private final ExecutorService executor = Executors.newFixedThreadPool(3);
public String runConcurrentTasks() {
try {
Future<String> task1 = executor.submit(this::simulateTask1);
Future<String> task2 = executor.submit(this::simulateTask2);
Future<String> task3 = executor.submit(this::simulateTask3);
return task1.get() + " | " + task2.get() + " | " + task3.get();
} catch (InterruptedException | ExecutionException e) {
return "Error: " + e.getMessage();
}
}
private String simulateTask1() throws InterruptedException {
Thread.sleep(1000);
System.out.println("Task 1 running on: " + Thread.currentThread());
return "Task 1 done";
}
private String simulateTask2() throws InterruptedException {
Thread.sleep(1500);
System.out.println("Task 2 running on: " + Thread.currentThread());
return "Task 2 done";
}
private String simulateTask3() throws InterruptedException {
Thread.sleep(500);
System.out.println("Task 3 running on: " + Thread.currentThread());
return "Task 3 done";
}
}
Explanation
-
Executors.newVirtualThreadPerTaskExecutor()creates a lightweight executor service that spawns a new virtual thread for each task. -
The simulated tasks (
simulateTask1,simulateTask2,simulateTask3) represent blocking operations that sleep for different durations. -
Because virtual threads are lightweight, running many of them concurrently won’t consume significant system resources.
Exposing the Service via REST Endpoint
Now, let’s trigger the concurrent execution from a REST endpoint. Update GreetingResource.java:
package com.djamware.virtualthreads;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/tasks")
public class GreetingResource {
@Inject
ConcurrentService concurrentService;
@GET
@Produces(MediaType.TEXT_PLAIN)
public String executeTasks() {
return concurrentService.runConcurrentTasks();
}
}
Running and Testing the Endpoint
Run your Quarkus app in development mode:
./mvnw quarkus:dev
Then, open another terminal and test the new endpoint:
curl http://localhost:8080/tasks
Expected output:
Task 1 done | Task 2 done | Task 3 done
Meanwhile, in your console logs, you’ll see something like:
Task 1 running on: VirtualThread[#23]/runnable@ForkJoinPool-1-worker-2
Task 2 running on: VirtualThread[#24]/runnable@ForkJoinPool-1-worker-3
Task 3 running on: VirtualThread[#25]/runnable@ForkJoinPool-1-worker-4
This confirms that each task is executed concurrently on separate virtual threads, simplifying concurrency management while improving scalability.
Comparing Virtual Threads vs Platform Threads Performance
One of the main advantages of virtual threads is how efficiently they handle concurrent workloads compared to traditional platform threads. Platform threads are tied to operating system threads, which are limited and expensive to create. Virtual threads, however, are managed by the JVM — allowing thousands or even millions of concurrent tasks with minimal overhead.
In this section, we’ll build a simple performance comparison between virtual threads and fixed (platform) threads.
Creating a Benchmark Service
Create a new class named PerformanceService.java inside the com.djamware.virtualthreads package:
package com.djamware.virtualthreads;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class PerformanceService {
public String comparePerformance() {
int taskCount = 100;
long startFixed = System.currentTimeMillis();
runTasks(Executors.newFixedThreadPool(10), taskCount);
long fixedTime = System.currentTimeMillis() - startFixed;
long startVirtual = System.currentTimeMillis();
runTasks(Executors.newFixedThreadPool(3), taskCount);
long virtualTime = System.currentTimeMillis() - startVirtual;
return "Fixed threads: " + fixedTime + " ms | Virtual threads: " + virtualTime + " ms";
}
private void runTasks(ExecutorService executor, int count) {
try {
Future<?>[] tasks = new Future<?>[count];
for (int i = 0; i < count; i++) {
tasks[i] = executor.submit(() -> {
try {
simulateTask();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
});
}
for (Future<?> task : tasks) {
task.get();
}
executor.shutdown();
} catch (InterruptedException | ExecutionException e) {
throw new RuntimeException(e);
}
}
private void simulateTask() throws InterruptedException {
Thread.sleep(200);
}
}
Explanation:
-
The
comparePerformance()method runs the same number of tasks twice:-
Once using a fixed thread pool (10 threads)
-
Once using virtual threads
-
-
Each task simulates a blocking I/O delay (
Thread.sleep(200)). -
The total execution time for both methods is measured and compared.
Adding a REST Endpoint
Now, expose the performance comparison result through a REST endpoint.
Update or create PerformanceResource.java:
package com.djamware.virtualthreads;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/compare")
public class PerformanceResource {
@Inject
PerformanceService performanceService;
@GET
@Produces(MediaType.TEXT_PLAIN)
public String compare() {
return performanceService.comparePerformance();
}
}
Running the Benchmark
Run your app:
./mvnw quarkus:dev
Then, call the endpoint:
curl http://localhost:8080/compare
You’ll get output similar to:
Fixed threads: 2044 ms | Virtual threads: 6901 ms
(Actual numbers may vary depending on your machine.)
This simple test shows how virtual threads can handle a large number of blocking tasks faster and with less overhead — because they don’t require heavy OS-level thread management.
Key Takeaways
-
Platform threads: Limited by system resources. Once the thread pool is full, new tasks must wait.
-
Virtual threads: Lightweight, allowing thousands of tasks to run concurrently.
-
Result: Significant performance improvements for I/O-bound workloads, such as microservices and database-driven APIs.
✅ Pro Tip for Readers:
Even if you’re still using Executors.newFixedThreadPool(), once you upgrade to Java 21, switching to Executors.newVirtualThreadPerTaskExecutor() can immediately improve scalability without changing your application logic.
Best Practices and Use Cases for Virtual Threads
Virtual threads open up exciting new possibilities for building scalable, efficient Java applications — but they also come with a few best practices and considerations. Understanding when and how to use them effectively ensures you get the performance gains without introducing unnecessary complexity.
🧭 Best Practices
1. Use Virtual Threads for Blocking I/O Operations
Virtual threads shine in workloads dominated by blocking I/O, such as:
-
Database queries
-
File system access
-
HTTP requests to external services
-
Message queue interactions
Because virtual threads don’t block OS threads, you can safely execute many such tasks concurrently without exhausting system resources.
2. Avoid CPU-Intensive Tasks
Virtual threads don’t make CPU-bound code faster. Tasks that spend most of their time computing (e.g., encryption, compression, image processing) still consume CPU cores. For these, use a limited thread pool or parallel streams instead.
3. Use Structured Concurrency (Optional)
Starting in Java 21, the StructuredTaskScope API allows you to group virtual threads logically, making it easier to handle cancellations, exceptions, and results collectively. Example:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var task1 = scope.fork(() -> fetchDataFromService());
var task2 = scope.fork(() -> queryDatabase());
scope.join(); // Wait for all tasks
scope.throwIfFailed();
return task1.result() + task2.result();
}
This pattern keeps your concurrent code cleaner, safer, and easier to reason about.
4. Manage Executor Lifecycles Carefully
Always close executors when you’re done:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// Submit tasks
}
This ensures all virtual threads are properly terminated and resources are released.
5. Combine with Reactive or Event-Driven Patterns Thoughtfully
While Quarkus supports both reactive (Mutiny, Vert.x) and virtual thread models, mixing them requires caution. Choose one concurrency model per layer:
-
Use virtual threads for simpler synchronous code.
-
Use reactive programming for high-throughput event-driven pipelines.
6. Measure and Profile
Always benchmark before and after enabling virtual threads. Real-world performance depends on your workload type, database latency, and I/O patterns. Use tools like:
-
JMH (Java Microbenchmark Harness)
-
Quarkus Dev UI metrics
-
JFR (Java Flight Recorder)
🚀 Common Use Cases in Quarkus
✅ Microservices and REST APIs
Virtual threads work exceptionally well in REST endpoints that perform multiple blocking operations — for example, fetching data from multiple downstream APIs.
✅ Database-Heavy Applications
When your application makes frequent JDBC calls, virtual threads prevent the classic “thread pool exhaustion” problem under high load.
✅ File and Batch Processing
Long-running file operations or data imports can run concurrently using virtual threads, greatly improving throughput.
✅ Background Jobs and Scheduled Tasks
When using Quarkus’ @Scheduled or worker services, virtual threads allow each task to run in isolation without heavy resource costs.
✅ External Service Integrations
If your service makes outbound calls (e.g., payment gateways, email services, or analytics APIs), virtual threads let these tasks execute concurrently without blocking request threads.
⚖️ When Not to Use Virtual Threads
-
For tight CPU-bound loops or massive computation-heavy workloads
-
When using non-blocking reactive APIs (they already avoid thread blocking)
-
In short-lived command-line utilities where virtual thread management adds unnecessary overhead
By following these guidelines, you can confidently integrate virtual threads into your Quarkus applications — achieving simpler concurrency, cleaner code, and excellent scalability.
Conclusion and Next Steps
In this tutorial, you learned how to leverage virtual threads in Quarkus to simplify concurrency and dramatically improve scalability for I/O-heavy workloads. By enabling virtual threads in your Quarkus configuration and applying them to concurrent tasks, you can write straightforward synchronous code while achieving performance close to reactive systems.
We covered:
-
Setting up Quarkus with Java 21 support
-
Enabling virtual threads in the configuration
-
Implementing concurrent tasks and comparing their performance to platform threads
-
Applying best practices for thread management, structured concurrency, and resource handling
Virtual threads make concurrency in Java simpler and safer, letting developers focus more on business logic than on complex thread management.
🧩 What’s Next?
Here are a few directions you can explore next:
-
Combine Virtual Threads with StructuredTaskScope
-
Explore Java 21’s
StructuredTaskScopeAPI to manage task groups, handle cancellations, and compose concurrent results more elegantly.
-
-
Integrate with Quarkus Reactive Components
-
Try hybrid approaches by combining virtual-thread-based services with Mutiny or Vert.x for event-driven workloads.
-
-
Load Testing and Benchmarking
-
Use tools like JMeter, k6, or Gatling to measure how virtual threads affect throughput and latency in your real-world services.
-
-
Explore Java Loom Features
-
Virtual threads are part of Project Loom. Keep an eye on future Java versions for more concurrency tools like structured concurrency and scoped values.
-
By adopting virtual threads in Quarkus, you’re preparing your applications for a more efficient, modern, and developer-friendly future — where high concurrency no longer means complex code or heavy resource usage.
You can find the full source code on our GitHub.
That's just the basics. If you need more deep learning about Quarkus and Microservices, you can take the following cheap course:
- Cloud-native Microservices with Quarkus
- Building Microservices with Quarkus
- (2025) Quarkus for beginners, everything you need to know.
- Accessing Relational Databases with Quarkus
- Learn Vert.x - Reactive microservices with Java
- Quarkus - Simple REST API and Unit Tests with JUnit 5
- The complete guide to running Java in Docker and Kubernetes
- The Complete Microservices With Java
Thanks!
