Async/await for Groovy™
Published: 2026-03-27 04:30PM (Last updated: 2026-04-06 11:35PM)
Introduction
Groovy 6 adds native async/await as a language-level feature
(GROOVY-9381).
Write asynchronous code in a sequential, readable style —
with support for generators, deferred cleanup, Go-style channels,
structured concurrency, and framework adapters for Reactor and RxJava.
On JDK 21+, async tasks automatically leverage virtual threads for optimal scalability. On JDK 17–20, a cached thread pool provides correct behavior as a fallback.
To make the features concrete, the examples follow a running theme: building the backend for Groovy Quest, a fictitious online game where heroes battle villains across dungeons.
Getting started
The problem: callback complexity
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 it, and the control flow reads
inside-out.
Loading a hero — reads like synchronous code
With async/await, the same logic becomes:
Quest loadHeroQuest(String loginToken) {
var heroId = await lookupHeroId(loginToken)
var heroClass = await fetchHeroClass(heroId)
return await loadActiveQuest(heroClass)
}
Variables are declared at the point of use. The return value is obvious. No callbacks, no lambdas, no chained combinators. The method is a regular method and called in the regular way:
// Call directly (blocking — fine on virtual threads):
def quest = loadHeroQuest(token)
The caller can choose to run it asynchronously if they want:
// Or run asynchronously:
def quest = await async { loadHeroQuest(token) }
Exception handling — just try/catch
What about the .exceptionally(e → Quest.DEFAULT) fallback from
the Java version?
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 unwraps CompletionException automatically, so you catch
the original exception type. Error handling reads exactly like
synchronous code.
Running tasks in parallel
Preparing for battle — Awaitable.all
Before a battle, the game loads the hero’s stats, inventory, and the villain — all in parallel:
def prepareBattle(heroId, visibleVillainId) {
var stats = async { fetchHeroStats(heroId) }
var inventory = async { fetchInventory(heroId) }
var villain = async { fetchVillain(visibleVillainId) }
var (s, inv, v) = await stats, inventory, villain
return new BattleScreen(s, inv, v)
}
Each async { … } starts immediately on a background thread.
The await stats, inventory, villain expression waits for all three
to complete — it’s shorthand for await Awaitable.all(stats, inventory, villain).
Parentheses also work: await(stats, inventory, villain).
How this compares to Java’s StructuredTaskScope
Java’s structured concurrency preview (JEP 525) provides a similar capability:
// 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());
}
Both approaches bind task lifetimes to a scope. Groovy adds syntactic
sugar (await, all) and integrates with the same model used
everywhere else, whereas Java’s API is deliberately lower-level.
Groovy’s AsyncScope (covered later) brings the full structured
concurrency model.
Capture the flag — Awaitable.any
Where all waits for every task, any returns when any task completes — a race:
def captureTheFlag(hero, villain, flag) {
var heroGrab = async { hero.grab(flag) }
var villainGrab = async { villain.grab(flag) }
var winner = await Awaitable.any(heroGrab, villainGrab)
println "$winner.name captured the flag!"
}
If the winner threw an exception, it propagates immediately.
The loser’s task still runs to completion in the background
(use AsyncScope for fail-fast cancellation).
If you want to ignore failures and take the first success, use Awaitable.first instead.
Other combinators
-
Awaitable.first(a, b, c)— returns the first successful result, ignoring individual failures. Like JavaScript’sPromise.any(). Useful for hedged requests and graceful degradation. -
Awaitable.allSettled(a, b)— waits for all tasks to settle (succeed or fail) without throwing. Returns anAwaitResultlist withsuccess,value, anderrorfields.
Combinator summary
| Combinator | Completes when | On failure | Use case |
|---|---|---|---|
|
All succeed |
Fails immediately on first failure (fail-fast) |
Gather results from independent tasks |
|
All complete (success or fail) |
Never throws; failures captured in |
Inspect every outcome, e.g. partial-success reporting |
|
First task completes (success or failure) |
Propagates the first completion’s result or error |
Latency-sensitive races, fastest-response wins |
|
First task succeeds, or all fail |
Throws only when every source fails (aggregate error) |
Hedged requests, graceful degradation with fallbacks |
Generators and streaming
Dungeon waves — yield return and for await
A dungeon sends waves of enemies. Each wave is generated on demand and the hero fights them as they arrive:
def generateWaves(String dungeonId) {
async {
var depth = 1
while (depth <= dungeonDepth(dungeonId)) {
yield return spawnEnemies(dungeonId, depth)
depth++
}
}
}
def runDungeon(hero, dungeonId) {
for (wave in generateWaves(dungeonId)) {
wave.each { villain -> hero.fight(villain) }
}
}
The producer yields each wave on demand. The consumer pulls with a normal for loop.
Natural back-pressure — the producer blocks on each
yield return until the consumer is ready. No queues, signals, or
synchronization.
Since generators return a standard Iterable, regular for loops as shown above
and other Groovy collection methods (collect, findAll, take) also work.
Other reactive libraries have different mechanisms for returning streaming results.
You can always use their native methods but Groovy’s for await provides some
syntactic sugar to make it more seamless:
def runDungeon(hero, dungeonId) {
for await (wave in generateWaves(dungeonId)) {
wave.each { villain -> hero.fight(villain) }
}
}
The consumer pulls with for await instead of for but no other changes are required.
You can optionally use for await with the builtin generators, but it’s required for
other reactive types (Flux, Observable) if you want the Groovy async friendly experience.
Deferred cleanup — defer
Before entering a dungeon, the hero summons a familiar and opens a
portal. Both must be cleaned up when the quest ends. defer schedules
cleanup in LIFO order, like
Go’s defer:
def enterDungeon(hero, dungeonId) {
def task = async {
var familiar = hero.summonFamiliar()
defer familiar.dismiss()
var portal = openPortal(dungeonId)
defer portal.close()
hero.explore(portal, familiar)
}
await task
}
Deferred actions always run — even when an exception occurs.
This is cleaner than nested try/finally blocks when multiple
resources are acquired at different points.
Diving deeper
Channels — the villain spawner
In a boss fight, a villain factory spawns enemies while the hero fights them. Channels provide Go-style decoupled communication:
def bossFight(hero, bossArena) {
var enemies = AsyncChannel.create(3) // buffered channel
// Villain spawner — runs concurrently
async {
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 unbuffered (rendezvous) and buffered modes.
for await iterates until the channel is closed and drained.
Channels implement Iterable, so regular for loops work too.
Structured concurrency — the raid party
A raid sends heroes to scout different rooms. If anyone falls, the
raid retreats. AsyncScope binds child task lifetimes to a scope:
def raidDungeon(List<Hero> party, List<Room> rooms) {
AsyncScope.withScope { scope ->
var missions = unique(party, rooms).collect { hero, room ->
scope.async { hero.scout(room) }
}
missions.collect { await it } // all loot gathered
}
}
By default, AsyncScope uses fail-fast semantics: if any task
fails, siblings are cancelled immediately. The scope guarantees all
children have completed when withScope returns.
Timeouts
A raid with a time limit:
def raidWithTimeLimit(List<Hero> party, List<Room> rooms) {
try {
await Awaitable.orTimeoutMillis(
async { raidDungeon(party, rooms) }, 30_000)
} catch (TimeoutException e) {
party.each { it.retreat() }
return []
}
}
Or with a fallback value:
var loot = await Awaitable.completeOnTimeoutMillis(
async { raidDungeon(heroes, rooms) }, ['an old boot'], 30_000)
Complementing JDK structured concurrency
AsyncScope shares the same design goals as Java’s
StructuredTaskScope but adds:
-
async/awaitintegration —scope.async { … }andawaitinstead offork()+join(). -
Works on JDK 17+ — uses
ThreadLocal(virtual threads on 21+). -
Composes with other features —
defer,for await, channels, and combinators all work inside a scope. -
Groovy-idiomatic API —
AsyncScope.withScope { scope → … }with a closure, notry-with-resources boilerplate.
Framework adapters
await natively understands CompletableFuture, CompletionStage,
Future, and any type with a registered AwaitableAdapter.
Drop-in adapter modules are provided:
-
groovy-reactor—awaitonMono,for awaitoverFlux -
groovy-rxjava—awaitonSingle/Maybe/Completable,for awaitoverObservable/Flowable
Without the adapter:
def result = Single.just('hello').toCompletionStage().toCompletableFuture().join()
With groovy-rxjava on the classpath:
def result = await Awaitable.from(Single.just('hello'))
Best practices
Prefer returning values over shared mutation
Async closures run on separate threads. Mutating shared variables is a race condition:
// UNSAFE — shared mutation is a race condition
var total = 0
def tasks = heroes.collect { h -> async { total += fetchScore(h) } }
tasks.each { await it }
// total may be wrong!
Return values and collect results instead:
// SAFE — each task returns its result
def tasks = heroes.collect { h -> async { fetchScore(h) } }
def results = await Awaitable.all(*tasks)
def total = results.sum()
When shared mutable state is unavoidable, use the appropriate
concurrency-aware type — AtomicInteger for a shared counter,
or thread-safe types from java.util.concurrent.
Choosing the right tool
| Feature | Use when… |
|---|---|
|
Sequential steps with I/O or blocking work. |
|
Launch independent tasks, collect all, race them, or take first success. |
|
Producing or consuming a stream of values. |
|
Guaranteed cleanup without nested |
|
Producer/consumer communication between tasks. |
|
Child task lifetimes tied to a scope with fail-fast cancellation. |
Framework adapters |
Transparent |
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.
Async/await complements GPars rather than replacing it. GPars excels at data-parallel operations and actor-based designs. Async/await targets sequential-looking code that is actually asynchronous, with language-level support for streams, cleanup, structured concurrency, and framework bridging.
GPars' callAsync() and asyncFun() return futures that work
naturally with await and the Awaitable combinators, so you
can mix and match both styles in the same codebase.
Both approaches benefit from virtual threads on JDK 21+.
Conclusion
Through our Groovy Quest examples we’ve seen how async/await lets you write concurrent code that reads 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 design philosophy is simple: closures run on real threads (virtual when available), stack traces are preserved, exceptions propagate naturally, and there’s no function coloring. The caller decides what’s concurrent — not the method signature.
