Async/Await

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Apache Groovy 6.0 (6.0.0, 2026-09-22) and every example was run with Groovy 6.0.0 on JDK 26 (checked on 2026-10-14). Native async/await is new in Groovy 6.0 and is still incubating in that release, which means the API under groovy.concurrent may change in a minor release. Two keywords do the work: async { ... } starts a closure on a background thread and returns an Awaitable immediately, and await suspends the caller until that result is available, so concurrent code can be written top to bottom instead of as callback or CompletableFuture chains. On JDK 21 and later every task runs on its own virtual thread; on JDK 17 to 20 a cached daemon thread pool is the fallback. The running scenario is the same greenhouse used elsewhere in this section: sensors that must be polled concurrently because each reading takes time. Generators, channels and structured concurrency build on the same keywords and live on Generators, Channels & Structured Concurrency; parallel collections and dataflow are on Parallel Collections & Dataflow. For the same idea in other languages see Kotlin Coroutines Basics and, for the JDK building block Groovy uses on JDK 21 and later, Java Virtual Threads.

A practical note on imports: the official guide omits them, but with Groovy 6.0.0 the async and await keywords work without any import while the types Awaitable, AwaitResult, AsyncChannel, ChannelSelect, BroadcastChannel and AsyncScope need import groovy.concurrent.*. The examples below include it whenever they name one of those types.

Your first async and await

async { ... } evaluates its closure on a background thread and returns at once with an Awaitable, a promise-like handle for the eventual result. await blocks the calling thread (cheaply, when it is a virtual thread) until the value arrives, and returns it.

import groovy.concurrent.Awaitable

// Greenhouse sensors: poll one sensor in the background
def poll = { String sensor ->
    async {
        Thread.sleep(100)                      // pretend to talk to the device
        [sensor: sensor, celsius: 21.5, thread: Thread.currentThread().virtual]
    }
}

def task = poll('gh-1-temp')                  // starts immediately, returns an Awaitable
println task.getClass().simpleName
def reading = await task                      // suspend the caller until the value is ready
println reading
assert reading.celsius == 21.5
println "JDK ${Runtime.version().feature()}, virtual threads available: ${Awaitable.virtualThreadsAvailable}"
GroovyPromise
[sensor:gh-1-temp, celsius:21.5, thread:true]
JDK 26, virtual threads available: true

Three details are worth remembering. The task starts the moment async is evaluated, not when it is awaited, so calling poll three times starts three polls. The concrete class behind an Awaitable is an internal implementation (GroovyPromise here), so program against the Awaitable interface. And thread:true shows that the closure really ran on a virtual thread. async, await and defer are contextual keywords: code that already uses them as variable or method names keeps compiling.

Async methods are methods that return an Awaitable

Groovy 6.0.0 has no async method modifier: async def read() { ... } and async read() { ... } are both syntax errors. An "async method" is simply a method or closure whose body is (or returns) an async { ... } expression, which makes it a reusable factory. Inside an async closure, further await calls read like ordinary sequential code.

// "Async methods": an ordinary method that returns an Awaitable
class SensorHub {
    def readTemperature(String sensor) {
        async { Thread.sleep(50); sensor == 'gh-2' ? 24.0 : 19.5 }
    }
    def readHumidity(String sensor) {
        async { Thread.sleep(50); 0.62 }
    }
    // sequential style: each await suspends this task, not the caller
    def summary(String sensor) {
        async {
            def t = await readTemperature(sensor)
            def h = await readHumidity(sensor)
            "$sensor: ${t}C, ${(h * 100) as int}% RH"
        }
    }
}
def hub = new SensorHub()
println await(hub.summary('gh-2'))
gh-2: 24.0C, 62% RH

await is not restricted to async code: the script above awaits at the top level, and a normal method may await too. It then simply blocks its own thread, so prefer to do the awaiting inside an async closure when the caller itself must stay responsive.

Exception handling

A failure inside an async closure is stored in the Awaitable. When someone awaits it, await rethrows the original exception, unwrapping the CompletionException or ExecutionException that the java.util.concurrent classes would add. Ordinary try/catch therefore works exactly as in synchronous code, and catch (SensorOffline e) matches the type you threw. Chain methods such as exceptionally recover without try.

import groovy.concurrent.*

// await rethrows the original exception, not CompletionException
class SensorOffline extends RuntimeException {
    SensorOffline(String m) { super(m) }
}
def broken = async { throw new SensorOffline('gh-3 is offline') }
try {
    await broken
} catch (SensorOffline e) {
    println "caught ${e.class.simpleName}: ${e.message}"
}
// a failed Awaitable can also be recovered without try/catch
def safe = (async { throw new SensorOffline('gh-3 is offline') }).exceptionally { 0.0 }
println await(safe)
// failures only surface when somebody awaits
def forgotten = async { throw new SensorOffline('nobody listens') }
Thread.sleep(100)
println "forgotten failed: ${forgotten.isCompletedExceptionally()}"
caught SensorOffline: gh-3 is offline
0.0
forgotten failed: true

The last case is the trap: an Awaitable nobody awaits fails silently. Always either await every task you start or start it inside a scope that waits for it, as described in Generators, Channels & Structured Concurrency. Groovy’s general exception model is covered on Exceptions & Error Handling.

When one Awaitable has to be transformed rather than awaited, the interface offers the familiar chain methods then, thenCompose, thenAccept, exceptionally, handle and whenComplete, plus isDone, isCancelled, isCompletedExceptionally and cancel(). await is almost always clearer than a chain, but the methods are useful for a one-line recovery as above.

CompletableFuture interop

await accepts more than Awaitable: out of the box it understands CompletableFuture, CompletionStage and Future, so any Java library that returns one of them can be awaited directly. In the other direction Awaitable.toCompletableFuture() hands an Awaitable to Java code, and Awaitable.from(x) wraps any supported object.

import groovy.concurrent.*
import java.util.concurrent.*

// await works with CompletableFuture, CompletionStage and Future
def cf = CompletableFuture.supplyAsync { 'cf-reading' }
println await(cf)
def pool = Executors.newSingleThreadExecutor()
Future fut = pool.submit({ 'future-reading' } as Callable)
println await(fut)
pool.shutdown()
// the other way: Awaitable -> CompletableFuture, plus chaining
def a = async { 20 }
CompletableFuture<Integer> asCf = a.toCompletableFuture()
println asCf.thenApply { it + 1 }.get()
def chained = a.then { it * 2 }.thenCompose { v -> async { v + 2 } }
println await(chained)
// wrap any supported object explicitly
println await(Awaitable.from(CompletableFuture.completedFuture('wrapped')))
println await(Awaitable.of(7))
cf-reading
future-reading
21
42
wrapped
7

await on a type that nothing adapts, such as an Integer, fails with IllegalArgumentException: No Awaitable adapter found for type: java.lang.Integer. The adapter registry behind this is the extension point used by the Reactor and RxJava modules described below.

Running tasks in parallel

The point of async/await is to overlap waiting. Awaiting each task right after starting it serialises the work; starting all tasks first and awaiting afterwards runs them side by side. The following example polls three sensors that each take 300 ms:

import groovy.concurrent.*

long t0 = System.nanoTime()
def poll = { String s -> async { Thread.sleep(300); "$s=ok" } }
def sequential = [await(poll('a')), await(poll('b')), await(poll('c'))]
long seqMs = (System.nanoTime() - t0) / 1_000_000
t0 = System.nanoTime()
def a = poll('a'); def b = poll('b'); def c = poll('c')     // all started
def parallel = await Awaitable.all(a, b, c)
long parMs = (System.nanoTime() - t0) / 1_000_000
def sugar = await(poll('x'), poll('y'))                        // multi-argument await
println sequential
println parallel
println sugar
println "sequential >= 900 ms: ${seqMs >= 900}, parallel < 600 ms: ${parMs < 600}"
assert seqMs >= 900 && parMs < 600
def (t, h, l) = await(poll('temp'), poll('humidity'), poll('light'))
println "$t $h $l"
[a=ok, b=ok, c=ok]
[a=ok, b=ok, c=ok]
[x=ok, y=ok]
sequential >= 900 ms: true, parallel < 600 ms: true
temp=ok humidity=ok light=ok

await(a, b, c) with several arguments is syntactic sugar for await Awaitable.all(a, b, c) and returns a list in argument order, which destructures neatly into def (t, h, l) = .... The diagram shows why the second version is faster: the three polls overlap, and the caller resumes when the slowest one finishes.

sequenceDiagram participant M as Main task participant T as async temperature poll participant H as async humidity poll participant L as async light poll M->>T: async poll (starts at once) M->>H: async poll (starts at once) M->>L: async poll (starts at once) M->>M: await(t, h, l) suspends Note over T,L: the three polls run concurrently on virtual threads H-->>M: humidity ready T-->>M: temperature ready L-->>M: light ready (slowest) Note over M: await returns [t, h, l] in argument order

The order in which the polls finish does not matter: the result list always follows the argument order.

Combinators: all, any, first and allSettled

Four static methods on Awaitable coordinate a group of tasks. They differ in when they complete and what they do with failures; the names and semantics follow the JavaScript Promise combinators.

Combinator Completes when On failure Typical use

Awaitable.all

every task has succeeded

fails fast with the first failure

gather results of independent tasks

Awaitable.allSettled

every task has finished, successfully or not

never throws; each outcome is an AwaitResult

report partial success

Awaitable.any

the first task settles, success or failure

propagates that first outcome (JavaScript Promise.race)

the fastest answer wins

Awaitable.first

the first task succeeds

throws only if every task fails (JavaScript Promise.any)

hedged requests, fallbacks

import groovy.concurrent.*

def probe = { String name, long ms, boolean fail = false ->
    async {
        Thread.sleep(ms)
        if (fail) throw new IOException("$name unreachable")
        "$name answered"
    }
}

// all: fail-fast
try {
    await Awaitable.all(probe('gw-a', 50), probe('gw-b', 10, true), probe('gw-c', 300))
} catch (IOException e) {
    println "all failed fast: ${e.message}"
}

// any: first to settle wins, success or failure (like Promise.race)
println "any:   " + await(Awaitable.any(probe('slow', 400), probe('quick', 20)))
try {
    await Awaitable.any(probe('slow', 400), probe('broken', 20, true))
} catch (IOException e) {
    println "any propagated the first settlement: ${e.message}"
}

// first: first *success* wins (like Promise.any)
println "first: " + await(Awaitable.first(probe('broken', 10, true), probe('backup', 80)))
try {
    await Awaitable.first(probe('x', 10, true), probe('y', 20, true))
} catch (Throwable e) {
    println "first with no success: ${e.class.name}"
}

// allSettled: never throws, one AwaitResult per task, in argument order
def results = await Awaitable.allSettled(probe('a', 10), probe('b', 10, true))
results.each { println it }
println results*.success
println results[1].error.message
println results[1].getOrElse { 'default' }
println results[0].map { it.toUpperCase() }
all failed fast: gw-b unreachable
any:   quick answered
any propagated the first settlement: broken unreachable
first: backup answered
first with no success: java.io.IOException
AwaitResult.Success[a answered]
AwaitResult.Failure[java.io.IOException: b unreachable]
[true, false]
b unreachable
default
AwaitResult.Success[A ANSWERED]

Points to note:

  • Awaitable.all and allSettled accept an empty argument list and complete with an empty list. any and first need at least one source and throw IllegalArgumentException ("sources must not be empty") for none, because a race without runners has no result.

  • Awaitable.first with only failures rethrows a failure rather than a special wrapper: in the run above it was the first task’s IOException. Do not rely on which failure is reported.

  • An AwaitResult has success/failure flags, value, error, getOrElse (a function from the error to a default) and map (transform a success, keep a failure), so partial results can be processed without try.

  • The losers of any and first are not cancelled automatically; call cancel() on them, or start them inside an AsyncScope, if they must stop.

Cancelling a task is cooperative and explicit: task.cancel() returns true, isCancelled() then reports it, and awaiting a cancelled task throws java.util.concurrent.CancellationException.

Timeouts and delays

A deadline can be put on any awaitable, and delay pauses without holding a platform thread. The static forms accept any supported object (an Awaitable, a CompletableFuture…​) and the instance forms are available on Awaitable itself. The names carry the unit: orTimeoutMillis and completeOnTimeoutMillis take milliseconds, while orTimeout and completeOnTimeout take a TimeUnit.

import groovy.concurrent.*
import java.util.concurrent.TimeoutException

def slowPoll = { async { Thread.sleep(2000); 21.5 } }

try {
    await Awaitable.orTimeoutMillis(slowPoll(), 150)
} catch (TimeoutException e) {
    println "timed out: ${e.class.simpleName}"
}

def value = await Awaitable.completeOnTimeoutMillis(slowPoll(), -1.0, 150)
println "fallback value: $value"

long t0 = System.nanoTime()
await Awaitable.delay(200)
long ms = (System.nanoTime() - t0) / 1_000_000
println "delay waited >= 190 ms: ${ms >= 190}"

// instance forms, and TimeUnit overloads
def p = slowPoll()
println await(p.completeOnTimeout(0.0, 100, java.util.concurrent.TimeUnit.MILLISECONDS))
try {
    await slowPoll().orTimeoutMillis(100)
} catch (TimeoutException e) {
    println 'instance form timed out'
}
timed out: TimeoutException
fallback value: -1.0
delay waited >= 190 ms: true
0.0
instance form timed out

A timeout makes the awaiting side give up; it does not stop the work already running, which is why the slow polls above keep sleeping in the background until they finish. For a deadline that also cancels the children, use AsyncScope.withScope(Duration) { ... } (see Generators, Channels & Structured Concurrency). The 6.0 release notes also list Awaitable.timeout(duration) and AsyncScope.run; neither method exists in the 6.0.0 binary used here, whose API is the one the user guide documents (orTimeoutMillis, completeOnTimeoutMillis and AsyncScope.withScope). Check the API of the exact Groovy version you run.

Executors and virtual threads

By default each async task runs on a virtual-thread-per-task executor on JDK 21 and later, which is why a thousand blocked tasks cost almost nothing. On JDK 17 to 20 a cached pool of daemon threads is used instead, capped at 256 threads by default and configurable with the groovy.async.parallelism system property. Awaitable.virtualThreadsAvailable tells you which mode the running JVM is in, and Awaitable.executor returns the executor in use. To use your own executor, install it with AsyncSupport.setExecutor; setExecutor(null) or AsyncSupport.resetExecutor() restores the platform default.

import groovy.concurrent.*
import java.util.concurrent.Executors
import org.apache.groovy.runtime.async.AsyncSupport

def whoAmI = { async { Thread.currentThread().with { [virtual: it.virtual, name: it.name] } } }
println "default executor: " + await(whoAmI())

def pool = Executors.newFixedThreadPool(2) { r -> new Thread(r, 'sensor-pool') }
AsyncSupport.setExecutor(pool)
println "custom executor:  " + await(whoAmI())
AsyncSupport.resetExecutor()           // setExecutor(null) does the same
pool.shutdown()
println "restored:         " + await(whoAmI())
println "Awaitable.virtualThreadsAvailable = ${Awaitable.virtualThreadsAvailable}"
default executor: [virtual:true, name:]
custom executor:  [virtual:false, name:sensor-pool]
restored:         [virtual:true, name:]
Awaitable.virtualThreadsAvailable = true

The executor is global to the JVM (a static setting), not per call, so change it once at start-up rather than around individual tasks. A fixed pool also limits concurrency: with two threads, a third blocking task waits for a free thread, which is the right trade-off for CPU-bound work and the wrong one for large numbers of blocked I/O waits. The AsyncSupport class is in the implementation package org.apache.groovy.runtime.async, so treat its API as less stable than groovy.concurrent.

Integration with JDK classes

Because await understands CompletableFuture, many JDK APIs plug in without a wrapper: Process.onExit(), HttpClient.sendAsync(...), the Path methods getTextAsync(), getBytesAsync() and writeAsync(...) from groovy-nio (see Files I/O & Paths), AsynchronousFileChannel and CompletableFuture.supplyAsync/allOf/anyOf. The next script runs a process, reads a file asynchronously and calls a local HTTP endpoint with Groovy’s HttpBuilder (module groovy-http-builder, incubating in 6.0; see HTTP Client with HttpBuilder) concurrently. The "gateway" is a throw-away JDK HttpServer on 127.0.0.1.

import groovy.concurrent.*
import groovy.http.HttpBuilder
import com.sun.net.httpserver.HttpServer
import java.nio.file.Files

// a throw-away local "gateway" on an ephemeral port
def server = HttpServer.create(new InetSocketAddress('127.0.0.1', 0), 0)
server.createContext('/status') { ex ->
    def body = 'all sensors nominal'.bytes
    ex.sendResponseHeaders(200, body.length)
    ex.responseBody.withCloseable { it.write(body) }
}
server.start()
def baseUrl = "http://127.0.0.1:${server.address.port}"
def log = Files.createTempFile('sensors', '.log')
log.text = 'gh-1 21.5\ngh-2 24.0\n'

try {
    def proc = async {
        def p = ['echo', 'firmware 1.4'].execute()
        await p.onExit()                       // Process.onExit() is a CompletableFuture
        p.text.trim()
    }
    def file = async { await log.textAsync }   // groovy-nio: Path.getTextAsync()
    def http = async {
        def client = HttpBuilder.http { baseUri baseUrl }
        client.get('/status').body
    }
    def (fw, readings, status) = await(proc, file, http)
    println fw
    println readings.readLines()
    println status
} finally {
    server.stop(0)
    Files.deleteIfExists(log)
}
firmware 1.4
[gh-1 21.5, gh-2 24.0]
all sensors nominal

Process handling is described on External Processes. To call a real HTTP service with the JDK client directly, await client.sendAsync(request, HttpResponse.BodyHandlers.ofString()) is all that is needed.

Framework adapters: Reactor and RxJava

await natively understands Awaitable, CompletableFuture, CompletionStage and Future, and for await (next page) also understands java.util.concurrent.Flow.Publisher. Other asynchronous types are supported through the AwaitableAdapter service-provider interface, registered in META-INF/services/groovy.concurrent.AwaitableAdapter. Two ready-made adapter modules ship in the Groovy distribution and, as the lib directory of the binary shows, groovy-reactor and groovy-rxjava are on the classpath of the groovy command; the reactive libraries themselves are separate dependencies.

Module Adds

groovy-reactor

await on Mono, for await over Flux

groovy-rxjava

await on Single, Maybe, Completable; for await over Observable and Flowable

@Grab('io.projectreactor:reactor-core:3.7.11')
@Grab('io.reactivex.rxjava3:rxjava:3.1.10')
import groovy.concurrent.*
import reactor.core.publisher.Mono
import io.reactivex.rxjava3.core.Single

// groovy-reactor: await understands Mono
println await(Mono.just('reactor-reading').map { it.toUpperCase() })

// groovy-rxjava: await understands Single, Maybe and Completable
println await(Single.just('rx-reading'))
println await(Awaitable.from(Single.just('wrapped-rx')))
println await(Mono.just('a'), Single.just('b'), async { 'c' })
REACTOR-READING
rx-reading
wrapped-rx
[a, b, c]

The run also printed three SLF4J(W): No SLF4J providers were found lines from Reactor on standard error, which are harmless and omitted above. Without the adapter you would convert by hand, for example with Single.just('x').toCompletionStage().toCompletableFuture().join(). Since the adapters sit behind a registry, the three different types in the last line combine in one await.

Best practices

Return values instead of mutating shared state

Async closures run on different threads, so updating a shared variable from many of them is a data race. The next script has 200 tasks each add 1000 to a counter in three ways:

import groovy.concurrent.*
import java.util.concurrent.atomic.AtomicInteger

// Unsafe: many tasks mutate one plain variable
int unsafe = 0
await Awaitable.all(*(1..200).collect { async { 1000.times { unsafe++ } } })
println "plain int:   ${unsafe} (expected 200000)"

// Better: return values and combine them afterwards
def partials = await Awaitable.all(*(1..200).collect { async { 1000 } })
println "returned:    ${partials.sum()}"

// When state really must be shared, use a thread-safe type
def counter = new AtomicInteger()
await Awaitable.all(*(1..200).collect { async { 1000.times { counter.incrementAndGet() } } })
println "AtomicInteger: ${counter.get()}"
plain int:   21484 (expected 200000)
returned:    200000
AtomicInteger: 200000

The first line differs from run to run, because lost updates are random; only the other two are reliable. Groovy also has declarative alternatives for guarding shared state, such as @Synchronized and @WithReadLock (see AST Transformations: Logging, Safety, Concurrency & Directives), and message-passing models in Actors, Agents & Active Objects.

Other guidelines

  • Start tasks first and await them together (Awaitable.all or multi-argument await); awaiting inside the loop that starts them serialises the work.

  • Await everything you start, or start it in a scope: an un-awaited failure is invisible.

  • Use blocking calls freely inside async closures on JDK 21 and later (virtual threads make it cheap), but remember that a fixed custom executor or the JDK 17 to 20 pool is finite.

  • Treat the API as incubating: pin the Groovy version and re-run your concurrency tests when you upgrade.

Choosing the right tool

Feature Use when

async/await

you have sequential steps involving I/O or blocking work and want code that reads top to bottom

Awaitable.all/any/first/allSettled

you launch independent tasks and need all results, the fastest, the first success, or every outcome

yield return/for await

you produce or consume a stream of values, such as paginated APIs or log tailing

defer

resources are acquired at different points and cleanup must be guaranteed without nested try/finally

AsyncChannel

tasks must communicate: producer/consumer, fan-out/fan-in, hand-off

AsyncScope

child task lifetimes should be bound to a scope with cancellation on failure

framework adapters

you already use Reactor or RxJava and want await and for await on their types

parallel collections

the work is data-parallel over a collection; see Parallel Collections & Dataflow

Monadic comprehensions

The same section of the official guide documents DO, an incubating macro from the groovy-macro-library module that rewrites a sequence of name in source generators followed by a body into a chain of bind operations on the carrier type (Optional, Stream, CompletableFuture, Awaitable, DataflowVariable and user types that opt in). It is a compile-time facility, and under @CompileStatic it needs the MonadicChecker extension for static types. It belongs with the functional constructs and is covered on Functional Programming; the type checkers are listed on Built-in Type Checkers.

Quick reference

Construct Description

async { ... }

start a closure on a background thread; returns an Awaitable (an Iterable when it contains yield return)

await expr

block until the result is available; rethrows the original exception

await(a, b, c)

wait for all; sugar for await Awaitable.all(a, b, c)

Awaitable.all/any/first/allSettled

combinators described above

Awaitable.orTimeoutMillis/completeOnTimeoutMillis

deadline with an exception, or with a fallback value

Awaitable.delay(ms)

pause without blocking a platform thread

Awaitable.of/failed/from/go

completed, failed, adapted or supplier-started awaitables

AsyncSupport.setExecutor(e)

replace the executor; resetExecutor() restores the default

yield return, for await, defer, AsyncChannel, ChannelSelect, BroadcastChannel, AsyncScope

covered on Generators, Channels & Structured Concurrency

Where the book is outdated

The book consulted for this section (see the section bibliography) was written when concurrency in Groovy meant plain Thread.start, the java.util.concurrent classes and, as an add-on library, GPars. None of that is wrong, but Groovy 6.0 now has a native alternative: async/await keywords, Awaitable combinators, channels and structured scopes in groovy.concurrent, with virtual threads doing the work on JDK 21 and later. The GPars-style toolkit (parallel collections, dataflow, actors and agents) was folded into the same package; migration notes are on Parallel Collections & Dataflow and Actors, Agents & Active Objects. Because the new API is incubating in 6.0, check the release notes of every minor release when you rely on it.