Implementing asynchronous Grails jobs with the Executor plugin
Long-running work can make a Grails application feel slow even when the request itself is simple. Sending email, generating reports, resizing uploaded images, calling a remote API, or importing a large catalogue can keep a controller thread busy for several seconds or longer. An executor-backed job moves that work into a managed thread pool so the request can return promptly.
The Grails Executor plugin provides a practical bridge between Grails application code and Java’s concurrency facilities. It is particularly useful in older Grails applications that do not yet use a more modern job-processing platform. The same design principles also apply when migrating to Grails 3 or 4, where a dedicated queue, scheduled job plugin, or external worker may be a better long-term fit.
How executor-based work operates
An executor maintains a pool of worker threads. Instead of running a task directly inside a controller action, the application submits a closure or runnable task to the pool. A worker then picks up the task when a thread becomes available. The HTTP request can respond with a status such as 202 Accepted, while the work continues in the background.
This is different from creating a new thread for every request. A managed executor controls the number of concurrent tasks, reuses threads, and can shut down cleanly with the application. It also gives the application a central place to configure pool size, queue capacity, and rejection behaviour.
The plugin does not make an operation magically reliable. If a process is killed, the queued task may disappear. If the task fails, the original browser request will not automatically receive that exception. For essential business operations, combine the executor with a database record, retry strategy, and a way to inspect job status.
Installing and configuring the plugin
Check the plugin version against the Grails version before adding a dependency. The Executor plugin has been used widely in Grails 2 applications, while newer Grails projects may need a compatible community release or a different implementation based on Spring’s TaskExecutor. A typical legacy dependency in BuildConfig.groovy looks like this:
plugins {
compile ':executor:0.3'
}
The exact version and dependency scope should come from the plugin’s documentation and the application’s Grails release. Run the application’s dependency report after installation so that conflicting Spring or Hibernate libraries are detected early. For a Grails 3 application, declare the dependency in build.gradle only if the selected plugin explicitly supports that generation of Grails.
A basic configuration can be placed in the application configuration file used by the project:
grails {
plugins {
executor {
poolSize = 8
}
}
}
Configuration keys vary between plugin releases, so treat this as a pattern rather than a universal copy-and-paste setting. Some releases expose an executorService bean with defaults, while others support named executors or properties under a different namespace. Confirm the actual bean name in the plugin documentation or application context before wiring production code.
Submitting work from a Grails service
Services are usually the safest place to submit asynchronous tasks because they separate web concerns from business logic. A controller can validate input, create a job record, and call the service. The service then submits the closure to the executor:
class ReportService {
def executorService
def startReport(Long reportId) {
executorService.submit {
generateReport(reportId)
}
}
private void generateReport(Long reportId) {
Report.withNewSession {
Report report = Report.get(reportId)
if (!report) {
log.warn "Report ${reportId} no longer exists"
return
}
report.status = 'RUNNING'
report.save(failOnError: true)
// Build the report and store the result here
report.status = 'COMPLETE'
report.save(failOnError: true)
}
}
}
The injected property and submit method depend on the plugin release, but the structure is consistent: obtain the managed executor, submit a small task, and pass identifiers rather than large domain objects. A domain instance attached to the request’s Hibernate session may be stale or inaccessible when the worker starts. Reloading it inside a fresh session avoids many lazy-loading and session-bound errors.
A controller might use the service like this:
def create() {
Report report = new Report(params).save(failOnError: true)
reportService.startReport(report.id)
render status: 202, contentType: 'application/json',
text: [id: report.id, status: 'QUEUED'] as grails.converters.JSON
}
Returning a job identifier allows a frontend to poll a status endpoint. It also suits common Australian business workflows, such as a Melbourne wholesaler generating a large invoice bundle while staff continue processing orders through the same browser application.
Designing a reliable job lifecycle
A database-backed job record is more dependable than a log message alone. Store fields such as status, createdAt, startedAt, finishedAt, attempts, errorMessage, and resultLocation. Useful statuses include QUEUED, RUNNING, COMPLETE, and FAILED. The record gives support staff a way to distinguish a task that is still processing from one that failed overnight.
Set the status before or immediately after submission, then update it inside the worker. Make each transition deliberate and persist it in a short transaction. A task should be idempotent where possible: running it twice should not send two invoices, duplicate a payment, or create two copies of the same export. An idempotency key or unique database constraint can protect against duplicate submissions.
def queueExport(Long exportId) {
Export export = Export.get(exportId)
export.status = 'QUEUED'
export.save(failOnError: true)
executorService.submit {
try {
Export.withNewSession {
def current = Export.get(exportId)
current.status = 'RUNNING'
current.attempts++
current.startedAt = new Date()
current.save(failOnError: true)
exportFiles(current)
current.status = 'COMPLETE'
current.finishedAt = new Date()
current.save(failOnError: true)
}
} catch (Throwable error) {
log.error("Export ${exportId} failed", error)
markExportFailed(exportId, error)
}
}
}
Do not catch an exception and silently ignore it. Log a useful identifier, persist a safe error summary, and avoid writing passwords, access tokens, or personal information into logs. If a task can be retried, record the attempt count and use increasing delays rather than immediately filling the executor with repeated failures.
Managing transactions, sessions, and application state
A worker thread is not the same as the thread that handled the web request. Request attributes, authentication details, Hibernate sessions, locale settings, and transaction boundaries should not be assumed to exist. Pass the minimum required data to the task and load current state when processing begins.
For database changes, use an explicit service method with a transaction boundary or create a fresh session as required by the Grails version. Avoid holding a transaction open while waiting for a remote service, writing a large file, or processing thousands of records. Short transactions reduce database locks and help other requests continue normally.
Closures submitted to an executor should also avoid mutable shared state. Do not append to a controller field or reuse a non-thread-safe formatter across tasks. Java and Groovy collections, date formatters, caches, and custom singletons need careful review when multiple workers can access them at once. Developers moving into Groovy should first review its syntax and object model through this Groovy learning resource, then apply ordinary Java concurrency rules to the resulting code.
Security context requires special attention. A background task may run after the user’s session has expired, so it should authorise the operation when the job is created and store a safe actor identifier for auditing. It should not depend on a thread-local web security context being available later.
Choosing pool size and protecting the application
Pool size should reflect the type of work. CPU-heavy operations such as PDF rendering or image conversion compete for processor time, so a large pool can reduce performance. I/O-heavy operations such as calling an email gateway or reading object storage can tolerate more workers, although external services often impose their own rate limits.
Start with a conservative value and measure queue depth, task duration, database connections, CPU, memory, and error rates. An eight-thread pool is not automatically suitable for an eight-core server, especially if the application also serves web requests and runs scheduled maintenance. The executor, database connection pool, and remote API limits must be considered together.
A busy public holiday promotion in Sydney can create a sudden burst of report or notification tasks. Without a bounded queue, memory usage may grow as requests continue submitting work. Prefer limits and visible rejection behaviour over unlimited accumulation. When the pool is full, the application can return a controlled response, record the request for later processing, or ask the user to retry rather than accepting work that cannot be completed safely.
For larger workloads, an executor inside the web process may no longer be enough. A queue backed by Redis, RabbitMQ, or a cloud service gives tasks durability and permits separate worker instances. That approach is often preferable for a national Australian retailer serving customers across AEST and AWST, where traffic peaks can arrive at different local times.
Testing, deployment, and operational safeguards
Unit tests should verify that the service submits the expected task and that the business operation behaves correctly when the executor runs it. Integration tests should cover status transitions, missing records, failed remote calls, duplicate submissions, and retry limits. A test executor that runs tasks synchronously can make assertions deterministic, while a small number of dedicated asynchronous tests can verify real thread behaviour.
Add monitoring around task count, average duration, oldest queued job, failure rate, and rejected submissions. A status endpoint should expose only information the current user is authorised to see. Administrative tools should support safe requeueing without allowing an operator to trigger an unlimited number of copies.
Deployment behaviour matters. During a rolling restart, a task running in an old instance may be interrupted, while a newly submitted task may land on another instance. Mark jobs as recoverable, use idempotent processing, and consider a persistent queue when losing work is unacceptable. The web development resource can also help developers compare application architecture options before placing all background work inside the Grails web process.
Australian compliance should be part of the design rather than a later patch. The Privacy Act 1988 and the Australian Privacy Principles affect personal information in job payloads, generated files, logs, and monitoring systems. Encrypt sensitive exports, restrict download URLs, define retention periods, and consider where cloud-hosted worker data is stored. These controls are relevant whether the application serves a Brisbane clinic, a Perth logistics company, or a Melbourne professional-services firm.
When to use a different job mechanism
The Executor plugin is a good fit for short-lived, best-effort tasks that can run in the same application process. Examples include sending a notification after a successful save, refreshing a small cache, or generating a modest document. It keeps the implementation close to familiar Grails services and is quick to introduce into an established application.
Use a more durable queue when tasks must survive deployments, run for many minutes, be retried independently, or process large volumes. A scheduled job is more appropriate for predictable recurring work, such as a nightly reconciliation. A separate worker service is preferable when the job requires different libraries, substantial memory, or an independent scaling policy.
Keep the boundary clear whichever approach you choose. The web request should validate and record intent, the job should process a stable identifier, and the system should expose progress and failure states. That pattern makes an Executor-based implementation easier to replace later without rewriting the application’s user-facing workflow.
Add the plugin to a small Grails feature, configure a conservative pool, and implement one status-tracked task before moving critical workloads into the background. Test failure and restart scenarios, review personal-data handling under the Privacy Act 1988, and monitor the queue in each deployment environment. With those safeguards in place, asynchronous execution can keep Grails applications responsive while giving teams a controlled path from simple executor tasks to durable distributed workers.