Using Virtual Threads in Quarkus: Concurrency Simplified

by Didin J. on Oct 29, 2025 Using Virtual Threads in Quarkus: Concurrency Simplified

Learn how to use virtual threads in Quarkus to simplify concurrency, boost performance, and handle thousands of tasks efficiently with Java 21.

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:

  1. Combine Virtual Threads with StructuredTaskScope

    • Explore Java 21’s StructuredTaskScope API to manage task groups, handle cancellations, and compose concurrent results more elegantly.

  2. Integrate with Quarkus Reactive Components

    • Try hybrid approaches by combining virtual-thread-based services with Mutiny or Vert.x for event-driven workloads.

  3. 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.

  4. 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:

Thanks!