Building World Kitchen: Learning Java 21 Virtual Threads Through a Real Project
Virtual Threads are one of the most exciting things to land in modern Java - but reading about them and actually building with them are two different things. Here's what I learnt building World Kitchen App for myself.
I'd been reading about Java 21 Virtual Threads for a while before I actually used them. They kept appearing in conference talks and blog posts, always accompanied by benchmarks and enthusiasm. But reading about concurrency and reasoning about concurrency in your own code are two different things. World Kitchen was my excuse to find out what the fuss was actually about.
Before I get into the technical side โ a quick word on where the idea came from. I genuinely use a version of this in real life. I'd run out of recipes I actually wanted to cook, so I'd spin an online country wheel, search the web for a recipe, then figure out how to adapt it to my vegetarian requirements. It was tedious. Automating tedious things is basically why I became a software engineer. So yes, it's a small gimmick project โ but it came from a real need, and I think that matters. The best side projects solve something you actually care about, even if the stakes are low.
Why Virtual Threads?
The traditional Java thread model maps each platform thread to an OS thread. That's fine until you have a lot of blocking I/O - and then it becomes expensive. Each waiting thread is a thread you're paying for, sitting around doing nothing.
Virtual Threads, introduced as a preview in Java 19 and finalised in Java 21, flip this around. They're lightweight threads managed by the JVM rather than the OS. You can have thousands of them without the memory overhead of platform threads. When a virtual thread blocks on I/O, the JVM parks it and frees the underlying carrier thread to do other work. The result: much better throughput for I/O-heavy workloads, without changing how you write blocking code.
My app had a natural use case. To generate a recipe suggestion, it needs to do two things that don't depend on each other: fetch a random country from an external API, and load the user's pantry from the database. Doing them sequentially meant the total wait time was country_lookup + db_lookup. Running them concurrently meant it was roughly max(country_lookup, db_lookup). That's a straightforward win.
I'll be honest though: at my current scale, the AI recipe generation is comfortably the slowest part of the whole request, and those two parallel calls are fast enough that the difference is barely perceptible. So this is, practically speaking, overengineering for a project this size. But that's not really the point โ it's a clear, tangible example of where and how you'd apply Virtual Threads, and the pattern scales directly to real systems where those I/O operations are genuinely expensive. Sometimes you build the right solution to a small problem because it teaches you how to solve it when the problem is big.
How the App Works
World Kitchen is a single-endpoint Spring Boot API. Call GET /api/meals/suggest?userId=1 and you get back a recipe generated by Claude AI, tailored to a randomly selected country and the user's available ingredients:
{
"country": "Japan",
"countryFlag": "๐ฏ๐ต",
"recipeName": "Miso Glazed Aubergine",
"description": "A smoky, umami-rich dish from Japanese home cooking...",
"ingredients": ["1 aubergine", "2 tbsp miso paste", "1 tbsp soy sauce"],
"instructions": "1. Halve the aubergine...",
"missingIngredients": ["miso paste"],
"processingTimeMs": 1243
}
The two slow steps - the external country API call and the database lookup - are where Virtual Threads come in. Everything else (the AI call) runs after those complete.
The Implementation
Enabling Virtual Threads in Spring Boot 3.2
This is almost embarrassingly easy:
@Bean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME)
public AsyncTaskExecutor asyncTaskExecutor() {
return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
}
One bean, and Spring's @Async methods and its web layer now run on virtual threads. That's it. No thread pool sizing, no tuning.
Running the Two Operations in Parallel
The actual concurrency happens in MealSuggestionService, using CompletableFuture:
CompletableFuture<CountryResponse> countryFuture = CompletableFuture
.supplyAsync(() -> countryService.getRandomCountry(), virtualThreadExecutor);
CompletableFuture<User> userFuture = CompletableFuture
.supplyAsync(() -> userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException(userId)), virtualThreadExecutor);
CompletableFuture.allOf(countryFuture, userFuture).join();
CountryResponse country = countryFuture.get();
User user = userFuture.get();
Both operations start immediately. allOf(...).join() waits until both complete. No thread is blocked on an OS thread while waiting - virtual threads handle the parking.
Testing the Concurrency
This was the part I found most interesting to write. How do you test that two things actually ran in parallel, rather than just happening to produce the correct result?
The approach I used: inject delays into the mocked services, then measure wall-clock time.
@Test
void shouldFetchCountryAndUserConcurrently() throws Exception {
int delayMs = 200;
when(countryService.getRandomCountry()).thenAnswer(inv -> {
Thread.sleep(delayMs);
return testCountry;
});
when(userRepository.findById(1L)).thenAnswer(inv -> {
Thread.sleep(delayMs);
return Optional.of(testUser);
});
long start = System.currentTimeMillis();
mealSuggestionService.generateMealSuggestion(1L);
long elapsed = System.currentTimeMillis() - start;
assertThat(elapsed).isLessThan(delayMs * 2 - 50);
}
If the calls were sequential, elapsed would be ~400ms. Concurrent, it's ~200ms. The test checks it's comfortably below the sequential total. Simple, but it actually verifies the behaviour you care about.
Lessons Learned
The thing I'd take away from this project isn't really about Virtual Threads specifically. It's that concurrency becomes a lot less intimidating when you have a concrete, small use case to work with. "Fetch two things at once" is easy to reason about. The implementation, once you see it, is shorter than you'd expect.
One pitfall worth flagging: LazyInitializationException is still waiting for you. Virtual Threads don't change JPA session boundaries. If you try to access a lazily-loaded relationship outside a transaction โ easy to do when a parallel task closes its session before you've traversed the full object graph โ you'll hit this. The fix is straightforward: load everything you need inside the CompletableFuture task itself, not after it completes.
If you've been curious about Virtual Threads but haven't had a reason to try them: find a small I/O-heavy operation in something you're already building. The setup is minimal and the concepts carry over to anything more complex.
See you in the next post, Ola