Creating Grails Async Controllers for Non-Blocking Requests

A Grails controller normally receives an HTTP request, performs its work, and returns a response while the server thread remains occupied. That model is straightforward, but it becomes inefficient when an action waits for a slow API, a file operation, a message broker, or another service. Async controller design lets the request hand work to a managed executor and complete later, keeping the web layer more responsive.

In Grails, asynchronous request handling commonly uses Promise and the Promises utility from the Grails async library. A controller action can return a promise instead of an immediately available model. Grails then resolves the value and turns it into the normal view, JSON, or other response format.

The goal is not to make every method asynchronous. The useful pattern is to isolate work that can safely run away from the request thread, define clear time limits, and return a predictable response when the task finishes. Developers working through Grails Example can apply these ideas alongside ordinary controllers, services, testing, and deployment lessons.

What Async Means in Grails

A non-blocking request does not mean that the underlying operation uses no waiting at all. If a background task calls a remote service synchronously, that worker still waits for the remote service. The important distinction is that the original servlet request thread is released or managed differently while the promise is being processed.

A simple controller action can create a promise with Promises.task and return it:

import grails.async.Promise
import static grails.async.Promises.task

class ReportController {

    def reportService

    def show(Long id) {
        Promise reportPromise = task {
            reportService.buildReport(id)
        }

        reportPromise.then { report ->
            [report: report]
        }
    }
}

The exact behaviour depends on the Grails version, servlet container, and response rendering configuration. In many Grails applications, returning a promise from an action allows the framework to resolve the value before rendering. For more specialised streaming or long-lived connections, servlet asynchronous APIs or a reactive framework may be a better fit.

The async task should return a value that the controller can render directly. Keeping the promise chain small makes errors, logging, and testing easier. A controller that starts several unrelated operations can use waitAll, combine their results, and return one final model.

Shape the Controller Around Promises

The controller should coordinate the request rather than contain business rules. Put database queries, external API calls, and domain calculations in a service, then have the async closure call that service. This keeps transaction boundaries and unit tests away from the web layer.

import static grails.async.Promises.task

class DashboardController {

    DashboardService dashboardService

    def index() {
        def summary = task {
            dashboardService.loadSummary()
        }

        summary.then { data ->
            render(
                contentType: 'application/json',
                status: 200,
                text: data as grails.converters.JSON
            )
        }.onError { Throwable error ->
            log.error('Dashboard generation failed', error)
            render(status: 503, text: 'Dashboard temporarily unavailable')
        }
    }
}

The error callback is important. An exception inside a promise does not always behave like an exception thrown directly from the controller method. Attach an error handler, or ensure that the framework’s promise integration converts failures into a suitable HTTP response. Avoid exposing exception messages because they can reveal database details, internal URLs, or credentials.

A promise should also have a clear ownership model. If the action starts work and the client disconnects, the operation may continue unless it can be cancelled. For expensive reports or exports, a job queue with a stored job identifier is often safer than holding an HTTP request open.

Protect Threads and External Resources

Async code can improve throughput, but it cannot create unlimited capacity. Every executor has a finite number of worker threads and a queue. If a task waits on a slow partner API, thousands of incoming requests can still fill the queue and exhaust memory. Configure pool sizes according to the operation, rather than copying a value from a different application.

Separate CPU-heavy work from I/O-heavy work where possible. Image processing, document conversion, and large calculations need CPU capacity. HTTP calls and database operations spend much of their time waiting. A dedicated executor for a slow integration prevents that integration from consuming threads needed by short internal tasks.

Transactions require particular care. A Hibernate session or Grails transaction associated with the request thread should not be assumed to remain available inside an asynchronous closure. Load the required data inside an appropriate service boundary, or start a new transaction for the background operation. Do not pass lazy domain associations into a task and expect them to work after the original session closes.

Useful safeguards include:

Return Clear HTTP Responses

A browser or mobile client needs to know whether it is receiving completed data, a pending job, or an error. For a short operation, the promise can resolve to a view model or JSON document. For a long export, return 202 Accepted with a job identifier, then provide a status endpoint.

import static grails.async.Promises.task

class ExportController {

    ExportService exportService
    JobService jobService

    def create() {
        def job = jobService.createPendingJob(request.userPrincipal.name)

        task {
            exportService.generate(job.id)
        }.onComplete {
            jobService.markFinished(job.id)
        }.onError { Throwable error ->
            jobService.markFailed(job.id, error.message)
        }

        render(
            status: 202,
            contentType: 'application/json',
            text: [jobId: job.id, status: 'pending']
        )
    }

    def status(Long id) {
        render jobService.status(id) as grails.converters.JSON
    }
}

This pattern prevents a load balancer, browser, or proxy from timing out while a large file is produced. It also gives users a durable status record if they close their browser. The generated file should be stored in a controlled location such as object storage, with access checked before download.

For ordinary promise-backed responses, choose status codes deliberately. A successful result generally uses 200 OK; a newly created asynchronous job uses 202 Accepted; an invalid identifier uses 404 Not Found; and temporary dependency failure may justify 503 Service Unavailable. Consistent response formats make frontend retry logic much less fragile.

Test and Observe Asynchronous Work

Async tests need to wait for a result without introducing arbitrary sleeps. Capture the returned promise, wait with a bounded timeout, and assert both the value and the failure path. A short timeout exposes tasks that accidentally depend on a request-scoped resource or an unavailable service.

The service doing the work should have ordinary unit tests with mocked dependencies. Controller tests can then focus on status codes, response formats, promise handling, and error translation. Integration tests should cover database sessions, transaction boundaries, and the executor configuration used in the deployed application.

Useful checks for a Grails async endpoint include:

Production monitoring should record task duration, queue depth, active worker count, timeout totals, and rejected tasks. Add a correlation ID to the incoming request and include it in logs created by the asynchronous task. Without that identifier, a controller log and a later service failure can be difficult to connect.

Load testing should resemble actual traffic. An Australian retail service may receive a sharp evening surge in Sydney and Melbourne, while regional users may experience longer network paths. Test both a fast local dependency and a delayed remote one. Watch database connection usage as well as application threads, because asynchronous code can increase concurrent database demand.

Deploy for Australian Traffic

Geography affects the value of asynchronous controllers. A service hosted in Sydney may provide good latency for users in New South Wales and Victoria, but customers in Perth or remote Queensland can see different round-trip times. Async handling helps the application remain available while waiting, yet it does not remove the need for sensible timeouts, caching, and a regionally appropriate hosting design.

Australian applications also operate across AEST, AEDT, ACST, and AWST. Store timestamps in UTC and convert them only when presenting a status or scheduling a task for a user. A daylight-saving transition in Melbourne or Sydney should never cause a report job to run twice because a local wall-clock value was used as its identifier.

Plan for local connectivity and operational patterns. NBN performance varies between locations, mobile users may move between networks, and users often retry when a page appears slow. Idempotency keys prevent those retries from creating duplicate payments, exports, or account changes. A task that writes to a database should record its state before and after the external side effect.

The Australian market also places practical emphasis on privacy, data residency, and dependable support during business hours. Avoid placing personal information in task logs, confirm where uploaded files and generated reports are stored, and check whether a cloud provider’s selected region matches the application’s contractual requirements. Queue metrics and alerting are particularly valuable when a service supports customers across several time zones.

For a production deployment, keep the async executor configuration externalised and review it with the database pool size. A worker pool larger than the available database connections can create contention rather than speed. Use graceful shutdown so new tasks stop being accepted while in-flight work receives enough time to finish or move to a durable queue.

Async controllers are most effective when used selectively. They suit slow, independent operations and short-lived request workflows, while durable background jobs, scheduled work, and high-volume event processing belong in a queue or job system. With explicit promise handling, bounded resources, observable failures, and responses designed around the client’s needs, Grails applications can remain responsive under real-world load.

Start with one endpoint that waits on a measurable external operation, wrap that operation in a controlled promise, add timeout and error handling, and test it under concurrent requests. Then inspect the executor, database pool, logs, and Australian deployment region before extending the pattern to other controllers.