Async/await for Groovy™

Author:  Paul King
PMC Member

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’s Promise.any(). Useful for hedged requests and graceful degradation.

  • Awaitable.allSettled(a, b) — waits for all tasks to settle (succeed or fail) without throwing. Returns an AwaitResult list with success, value, and error fields.

Combinator summary

Combinator Completes when On failure Use case

Awaitable.all

All succeed

Fails immediately on first failure (fail-fast)

Gather results from independent tasks

Awaitable.allSettled

All complete (success or fail)

Never throws; failures captured in AwaitResult list

Inspect every outcome, e.g. partial-success reporting

Awaitable.any

First task completes (success or failure)

Propagates the first completion’s result or error

Latency-sensitive races, fastest-response wins

Awaitable.first

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/await integrationscope.async { …​ } and await instead of fork() + join().

  • Works on JDK 17+ — uses ThreadLocal (virtual threads on 21+).

  • Composes with other featuresdefer, for await, channels, and combinators all work inside a scope.

  • Groovy-idiomatic APIAsyncScope.withScope { scope → … } with a closure, no try-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-reactorawait on Mono, for await over Flux

  • groovy-rxjavaawait on Single/Maybe/Completable, for await over Observable/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…​

async/await

Sequential steps with I/O or blocking work.

Awaitable.all / any / first

Launch independent tasks, collect all, race them, or take first success.

yield return / for await

Producing or consuming a stream of values.

defer

Guaranteed cleanup without nested try/finally.

AsyncChannel

Producer/consumer communication between tasks.

AsyncScope

Child task lifetimes tied to a scope with fail-fast cancellation.

Framework adapters

Transparent await / for await with Reactor or 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.

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.

References

Update history

27/Mar/2026: Initial version.
06/Apr/2026: Revised version after feedback including numerous simplifications.