Async/await for Groovy™

Author:  Paul King
PMC Member

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

async/await

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.

Awaitable.all / any

You need to launch several independent tasks and collect (all) or race (first) their results.

Pairs with async closures to create the tasks that all/any coordinate.

yield return / for await

You’re producing or consuming a stream of values over time — paginated APIs, sensor data, log tailing.

Producer uses async + yield return; consumer uses for await. Back-pressure is automatic.

defer

You acquire resources at different points and want guaranteed cleanup without nested try/finally.

Works inside async methods and async closures. LIFO order mirrors Go’s defer.

Channels (AsyncChannel)

Two or more tasks need to communicate — producer/consumer, fan-out/fan-in, or rendezvous hand-off.

Created with AsyncChannel.create(); consumed with for await; launched with Awaitable.go.

AsyncScope

You want structured concurrency — child task lifetimes tied to a scope with automatic cancellation.

Groovy’s take on the same goal as JDK StructuredTaskScope, with async/await integration.

AsyncContext

You need contextual data (e.g. player session, logging trace ID) to follow your async calls across thread hops.

Automatically propagated through async/await, AsyncScope, and Awaitable.go.

Framework adapters

You’re already using Reactor or RxJava and want await to work transparently with their types.

Auto-discovered via ServiceLoader — just add the dependency.

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/await integration. JDK scopes use fork() and join() as separate steps; AsyncScope uses scope.async { …​ } and await, keeping scoped work consistent with the rest of your async code.

  • Works on JDK 17+. StructuredTaskScope requires JDK 21+ and is still a preview API. AsyncScope runs on JDK 17+ (using ThreadLocal fallback) and uses ScopedValue when available on JDK 25+.

  • Composes with other async features. Inside a scope you can use defer for cleanup, for await to consume streams, channels for inter-task communication, and Awaitable.all/any for coordination — all within the same structured lifetime guarantee.

  • Groovy-idiomatic API. AsyncScope.withScope { scope → … } uses a closure, avoiding the try-with-resources boilerplate of Java’s scope.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.