Async/await for Groovy™
Published: 2026-03-27 04:30PM
Introduction
A proposed enhancement, targeted for Groovy 6,
adds native async/await as a language-level feature
(GROOVY-9381,
PR #2387).
Inspired by similar constructs in JavaScript, C#, Kotlin, and Swift,
the proposal would let you write asynchronous code in a sequential, readable
style — with first-class support for async streams, deferred cleanup,
structured concurrency, Go-style channels, and framework adapters
for Reactor and RxJava.
On JDK 21+, async methods automatically leverage virtual threads for optimal scalability.
This is a comprehensive feature. Rather than cover every detail, this post walks through a handful of bite-sized examples that show what the day-to-day experience would feel like — and how it compares to what you’d write in plain Java today.
Choosing the right tool
The proposal provides several complementary features. Before diving in, here’s a quick guide to when you’d reach for each:
| Feature | Use when… | Complements |
|---|---|---|
|
You have sequential steps that involve I/O or other blocking work and want code that reads top-to-bottom. |
Foundation for everything else — the other features build on top of async methods and closures. |
|
You need to launch several independent tasks and collect (all) or race (first) their results. |
Pairs with |
|
You’re producing or consuming a stream of values over time — paginated APIs, sensor data, log tailing. |
Producer uses |
|
You acquire resources at different points and want guaranteed cleanup without nested |
Works inside async methods and async closures. LIFO order mirrors Go’s |
Channels ( |
Two or more tasks need to communicate — producer/consumer, fan-out/fan-in, or rendezvous hand-off. |
Created with |
|
You want structured concurrency — child task lifetimes tied to a scope with automatic cancellation. |
Groovy’s take on the same goal as JDK |
|
You need contextual data (e.g. player session, logging trace ID) to follow your async calls across thread hops. |
Automatically propagated through |
Framework adapters |
You’re already using Reactor or RxJava and want |
Auto-discovered via |
In practice, you’ll mix and match. A typical service handler might
use async/await for its main flow, Awaitable.all to fan out
parallel calls, defer for cleanup, and AsyncScope to ensure
nothing leaks.
To make the features concrete, the examples below follow a running theme: building the backend for Groovy Quest, a fictitious online game where heroes battle villains across dungeons. Each example tackles a different part of the game — loading heroes, spawning enemies, streaming dungeon waves, managing resources, and coordinating raid parties.
The problem: callback complexity
Imagine a player logs in and we need to load their quest: look up
their hero ID, fetch the hero’s class, then load their active quest.
With CompletableFuture the logic gets buried under plumbing:
// Java with CompletableFuture
CompletableFuture<Quest> quest =
lookupHeroId(loginToken)
.thenCompose(id -> fetchHeroClass(id))
.thenCompose(heroClass -> loadActiveQuest(heroClass))
.exceptionally(e -> Quest.DEFAULT);
Each .thenCompose() adds a nesting level, exception recovery is
separated from the code that causes the exception, and the control
flow reads inside-out. For this example, the simple chaining
is manageable, but the complexity grows non-linearly with
branching and error handling.
Example 1: loading a hero — reads like synchronous code
With the proposed async/await, the same logic becomes:
async Quest loadHeroQuest(String loginToken) {
var heroId = await lookupHeroId(loginToken)
var heroClass = await fetchHeroClass(heroId)
var quest = await loadActiveQuest(heroClass)
return quest
}
Variables are declared at the point of use. The return value is obvious. No callbacks, no lambdas, no chained combinators.
What about the .exceptionally(e → Quest.DEFAULT) fallback from
the Java version? With async/await, it’s just a try/catch:
async Quest loadHeroQuest(String loginToken) {
try {
var heroId = await lookupHeroId(loginToken)
var heroClass = await fetchHeroClass(heroId)
return await loadActiveQuest(heroClass)
} catch (NoActiveQuestException e) {
return Quest.DEFAULT
}
}
await automatically unwraps CompletionException, so you catch
the original exception type — NoActiveQuestException here, not
a CompletionException wrapper. Error handling reads exactly like
synchronous code — no separate .exceptionally() callback bolted
on at the end of a chain.
Example 2: preparing for battle — fetch once, await together
Before a battle, the game needs to load several things in parallel:
the hero’s stats, their inventory, and the villain they’re about
to face. Launching concurrent work and collecting the results is a
common pattern. Here’s how it looks with Awaitable.all:
async prepareBattle(heroId, visibleVillainId) {
var stats = async { fetchHeroStats(heroId) }
var inventory = async { fetchInventory(heroId) }
var villain = async { fetchVillain(visibleVillainId) }
var result = await Awaitable.all(stats(), inventory(), villain())
var (s, inv, v) = result*.get() // spread the results into variables
return new BattleScreen(s, inv, v)
}
Here, async { … } creates an async closure — a reusable
block that doesn’t run until you call it.
Invoking stats(), inventory(), and villain() each launches its respective block concurrently and returns an Awaitable. The all combinator produces another Awaitable that completes when every task has finished. If any task fails, the remaining tasks still run to completion, and the first exception is thrown unwrapped. (For fail-fast semantics — cancelling siblings as soon as one fails — see AsyncScope in Example 6.)
How this compares to Java’s StructuredTaskScope
Java’s structured concurrency preview
(JEP 525, previewing since JDK 21)
provides a similar capability through StructuredTaskScope:
// Java with StructuredTaskScope (JDK 25 preview API)
try (var scope = StructuredTaskScope.open()) {
var statsTask = scope.fork(() -> fetchHeroStats(heroId));
var inventoryTask = scope.fork(() -> fetchInventory(heroId));
var villainTask = scope.fork(() -> fetchVillain(villainId));
scope.join();
return new BattleScreen(
statsTask.get(), inventoryTask.get(), villainTask.get());
}
The goals are aligned — both approaches bind task lifetimes to a
scope and cancel siblings on failure. The Groovy version adds
syntactic sugar (await, all) and integrates with the same
async/await model used everywhere else, whereas Java’s API
is deliberately lower-level and imperative. We’ll see more on
how Groovy’s AsyncScope complements JDK structured concurrency
in Example 6.
Example 3: dungeon waves — async streams with yield return and for await
A dungeon sends waves of enemies at the hero. Each wave is fetched
from the server (maybe procedurally generated), and the hero fights
them as they arrive. This is a natural fit for async streams:
yield return produces values lazily, and for await consumes them.
async generateWaves(String dungeonId) {
var depth = 1
while (depth <= await dungeonDepth(dungeonId)) {
var wave = await spawnEnemies(dungeonId, depth)
yield return wave
depth++
}
}
async runDungeon(hero, dungeonId) {
for await (wave in generateWaves(dungeonId)) {
wave.each { villain -> hero.fight(villain) }
}
}
The producer yields each wave on demand. The consumer pulls them
with for await. The runtime provides natural back-pressure —
the producer blocks on each yield return until the hero is ready
for the next wave, preventing unbounded enemy spawning. No explicit
queues, signals, or synchronization required.
There’s no language-level equivalent in plain Java today.
You’d typically reach for Reactor’s Flux or RxJava’s Flowable, each of which
brings its own operator vocabulary and mental model. With for await,
async iteration feels as natural as a regular for loop.
Example 4: entering a dungeon — defer for guaranteed cleanup
Before entering a dungeon, our hero summons a familiar (spirit pet) and opens a
magic portal. Both must be cleaned up when the quest ends, whether
the hero triumphs or falls. The defer keyword schedules cleanup
to run when the enclosing async method completes — multiple deferred
blocks execute in LIFO order, exactly like
Go’s defer:
async enterDungeon(hero, dungeonId) {
var familiar = hero.summonFamiliar()
defer familiar.dismiss()
var portal = openPortal(dungeonId)
defer portal.close()
await hero.explore(portal, familiar)
}
defer also works inside async closures — handy for one-off
tasks like a hero briefly powering up. Notice how the deferred
cleanup runs after the body completes:
def log = []
def powerUp = async {
defer { log << 'shield down' }
log << 'shield up'
'charged'
}
def result = await powerUp()
assert result == 'charged'
assert log == ['shield up', 'shield down']
This is cleaner than nested try/finally blocks, especially when
multiple resources are acquired at different points in the method
or closure.
Example 5: the villain spawner — Go-style channels
In a boss fight, a villain factory spawns enemies in the background while the hero fights them as they appear. The two sides need to communicate without tight coupling — a perfect fit for CSP-style channels inspired by Go:
async bossFight(hero, bossArena) {
var enemies = AsyncChannel.create(3) // buffered channel
// Villain spawner — runs concurrently
Awaitable.go {
for (type in bossArena.spawnOrder) {
await enemies.send(new Villain(type))
}
enemies.close()
}
// Hero fights each enemy as it arrives
var xp = 0
for await (villain in enemies) {
xp += hero.fight(villain)
}
return xp
}
Channels support both unbuffered (rendezvous) and buffered modes.
for await iterates received values until the channel is closed —
the Groovy equivalent of Go’s for range ch. You can also race
channel operations with Awaitable.any(…), serving a similar
role to Go’s select statement.
Example 6: the raid party — structured concurrency with AsyncScope
A raid sends multiple heroes to scout different dungeon rooms
simultaneously. If any hero falls, the whole raid retreats.
AsyncScope binds child task lifetimes to a scope — inspired by
Kotlin’s coroutineScope, Swift’s TaskGroup, and Java’s
StructuredTaskScope. When the scope exits, all child tasks have
completed or been canceled:
async raidDungeon(List<Hero> party, List<Room> rooms) {
try(var scope = AsyncScope.create()) {
var missions = unique(party, rooms).collect { hero, room ->
scope.async { await hero.scout(room) }
}
missions.collect { await it } // all loot gathered
}
}
By default, AsyncScope uses fail-fast semantics: if any hero’s
scouting task throws (the hero falls), sibling tasks are cancelled
immediately — the raid retreats.
Cancellation and timeouts
Cancellation is one of the trickiest parts of async programming — and one of `CompletableFuture’s biggest pain points. The proposal makes it straightforward. For instance, a raid might have a time limit — if the party takes too long, all scouting missions are cancelled:
async raidWithTimeLimit(List<Hero> party, List<Room> rooms) {
try {
await Awaitable.orTimeout(raidDungeon(party, rooms), 30, SECONDS)
} catch (TimeoutException e) {
party.each { it.retreat() }
return [] // no loot this time
}
}
When the timeout fires, the scope’s child tasks are cancelled and
a TimeoutException is thrown — which you handle with an ordinary
catch, just like any other error.
In simple cases, you can also use completeOnTimeout:
var boobyPrize = ['an old boot']
var loot = await Awaitable.completeOnTimeout(raidDungeon(heroes, rooms), boobyPrize, 30, SECONDS)
Complementing JDK structured concurrency
Java’s StructuredTaskScope
(JEP 525, previewing since JDK 21)
brings structured concurrency to the platform. AsyncScope shares
the same design goals — child lifetimes bounded by a parent scope,
automatic cancellation on failure — but layers additional value
on top:
-
async/awaitintegration. JDK scopes usefork()andjoin()as separate steps;AsyncScopeusesscope.async { … }andawait, keeping scoped work consistent with the rest of your async code. -
Works on JDK 17+.
StructuredTaskScoperequires JDK 21+ and is still a preview API.AsyncScoperuns on JDK 17+ (usingThreadLocalfallback) and usesScopedValuewhen available on JDK 25+. -
Composes with other async features. Inside a scope you can use
deferfor cleanup,for awaitto consume streams, channels for inter-task communication, andAwaitable.all/anyfor coordination — all within the same structured lifetime guarantee. -
Groovy-idiomatic API.
AsyncScope.withScope { scope → … }uses a closure, avoiding thetry-with-resources boilerplate of Java’sscope.open()/scope.close().
Think of AsyncScope as Groovy’s opinionated take on the same
principle: structured concurrency is the safety net, and
async/await is the ergonomic surface you interact with daily.
Example 7: game event streams — framework integration
Many game backends already use reactive frameworks. The await
keyword natively understands CompletableFuture,
CompletionStage, Future, and Flow.Publisher. For third-party
frameworks, drop-in adapter modules are auto-discovered via
ServiceLoader.
Here, heroes might asynchronously gain boosts in power (buff), and we might be able to stream villain alerts from a dungeon’s alert feed. With the appropriate adapters on the classpath, we can await Reactor’s Mono and Flux or RxJava’s Single and Observable directly:
// With groovy-reactor on the classpath:
async heroBoosts(heroId) {
var hero = await Mono.just(fetchHero(heroId))
for await (boost in Flux.from(hero.activeBoostStream())) {
hero.applyBoost(boost)
}
}
// With groovy-rxjava on the classpath:
async villainAlerts(dungeonId) {
var dungeon = await Single.just(loadDungeon(dungeonId))
for await (alert in Observable.from(dungeon.alertFeed())) {
broadcastToParty(alert)
}
}
No manual adapter registration is needed — add the dependency and
await works transparently with Reactor and RxJava types.
How it relates to GPars and virtual threads
Readers of the GPars meets virtual threads blog post will recall that GPars provides parallel collections, actors, agents, and dataflow concurrency — and that it works well with virtual threads via custom executor services.
The async/await proposal complements GPars rather than replacing
it. GPars excels at data-parallel operations (collectParallel,
findAllParallel) and actor-based designs. Async/await targets a
different sweet spot: sequential-looking code that is actually
asynchronous, with language-level support for streams, cleanup,
structured concurrency, and framework bridging. If you’re calling
microservices, paginating through APIs, or coordinating I/O-bound
tasks, async/await gives you a concise way to express that without
dropping into callback chains.
Both approaches benefit from virtual threads on JDK 21+, and both can coexist in the same codebase.
The full picture
The examples above are only a taste. The complete proposal also includes
async closures and lambdas, the @Async annotation (for Java-style
declarations), other Awaitable combinators (any, allSettled, delay),
more details about AsyncContext for propagating trace and tenant metadata across
thread hops, cancellation support, and a pluggable adapter registry for
custom async types. The full spec is available in the
draft documentation.
We’d love your feedback
The async/await feature is currently a proposal in PR #2387 (tracking issue GROOVY-9381). This is a substantial addition to the language and we want to get it right.
-
Comment on the PR or the JIRA issue with your thoughts, use cases, or design suggestions.
-
Vote on the JIRA issue if you’d like to see this feature land.
Your feedback helps us gauge interest and shape the final design.
Conclusion
Through our Groovy Quest examples we’ve seen how the proposed async/await feature lets you write async Groovy code that reads almost like synchronous code — from loading a hero’s quest, to preparing a battle in parallel, streaming dungeon waves, cleaning up summoned familiars, coordinating a boss fight over channels, and rallying a raid party with structured concurrency. The syntax is concise, the mental model is straightforward, and virtual threads make it scale.
