hbrown.dev

Welcome to my developer page. Contact me on: henry.g.brown@hey.com

View on GitHub
1 May 2026

Configure a background coroutine for your Spring Boot application

by Henry Brown

Sometimes you need to run work in the background of a Spring Boot application. This may be work that should not block the HTTP request that triggered it, or work that is started by a scheduled process, event listener, message consumer, or application service.

In Kotlin, coroutines make it straightforward to start asynchronous work. However, I prefer not to scatter calls to CoroutineScope(...) or Dispatchers.IO, (and definitely NOT GlobalScope) throughout an application. Instead, I like to define an explicit background coroutine configuration and inject it where it is needed.

This gives the application a named place for background execution, error handling, and shutdown behaviour.

@Configuration
@OptIn(ExperimentalCoroutinesApi::class)
class CoroutineConfig : DisposableBean {

    private val backgroundExecutor: ExecutorService = Executors.newVirtualThreadPerTaskExecutor()
    private val backgroundDispatcher = backgroundExecutor.asCoroutineDispatcher()

    private val handler = CoroutineExceptionHandler { ctx, e ->
        val correlationId = ctx[CorrelationIdContextElement]?.correlationId ?: NO_CORRELATION_ID
        log.error(">>> Unhandled background coroutine error, correlationId=[$correlationId]", ctx, e)
    }

    @Bean
    @Qualifier(BACKGROUND_DISPATCHER_NAME)
    fun backgroundDispatcher(): CoroutineDispatcher = backgroundDispatcher

    @Bean
    @Qualifier(BACKGROUND_SCOPE_NAME)
    fun backgroundScope(): CoroutineScope {
        return CoroutineScope(SupervisorJob() + handler + backgroundDispatcher + CoroutineName(BACKGROUND_SCOPE_NAME))
    }

    override fun destroy() {
        backgroundDispatcher.close()
        backgroundExecutor.shutdown()
    }

    companion object {
        private val log = CoroutineLogger(LoggerFactory.getLogger(CoroutineConfig::class.java))
    }
}

const val BACKGROUND_DISPATCHER_NAME = "backgroundDispatcher"
const val BACKGROUND_SCOPE_NAME = "backgroundScope"
private const val NO_CORRELATION_ID = "<no correlation id>"

Why define a background coroutine configuration?

The main reason is ownership.

When a coroutine is launched, something should own its lifecycle. In a web application, the request normally owns request-scoped work. If the request is cancelled, times out, or completes, that work should usually stop with it.

Background work is different. It often needs to continue independently of the request that caused it. For example, an endpoint may accept a command, persist the initial state, and then trigger some follow-up processing in the background. That follow-up processing should not be tied to the HTTP connection staying open.

By exposing a dedicated CoroutineScope as a Spring bean, you make that decision explicit:

@Bean
@Qualifier(BACKGROUND_SCOPE_NAME)
fun backgroundScope(): CoroutineScope {
    return CoroutineScope(SupervisorJob() + handler + backgroundDispatcher + CoroutineName(BACKGROUND_SCOPE_NAME))
}

Any class that needs to launch background work can inject this scope using the qualifier. That keeps the application honest about which work is request-bound and which work is intentionally detached.

Why use a dedicated dispatcher?

The configuration creates an ExecutorService and adapts it into a coroutine dispatcher:

private val backgroundExecutor: ExecutorService = Executors.newVirtualThreadPerTaskExecutor()
private val backgroundDispatcher = backgroundExecutor.asCoroutineDispatcher()

In this case, the executor uses Java virtual threads. That makes the dispatcher a good fit for many typical application-level background jobs. Especially where the job may spend time waiting on blocking I/O such as database calls, HTTP calls, file operations, or SDK clients that are not coroutine-native.

The important point is not only that this uses virtual threads. The important point is that background work is not silently being pushed onto an unrelated dispatcher. It has a specific execution policy that can be named, tested, monitored, and changed later without hunting through the codebase.

For example, if you later decide that a particular application needs a bounded thread pool, a different executor, or more detailed instrumentation, the change can happen in this configuration class rather than at every call site.

Why use a SupervisorJob?

The scope uses a SupervisorJob:

CoroutineScope(SupervisorJob() + handler + backgroundDispatcher + CoroutineName(BACKGROUND_SCOPE_NAME))

This is useful for background processing because one failing child coroutine should not necessarily cancel every other background task running in the same scope.

For example, if three independent notification jobs are launched and one fails because a downstream provider is unavailable, the other two should usually be allowed to continue. SupervisorJob gives you that isolation.

This does not mean failures are ignored. It means failures are contained. The exception handler is still important.

Centralised exception handling

Background work can fail after the original caller has already received a response. That means you cannot rely on normal request handling to surface errors.

This configuration adds a CoroutineExceptionHandler:

private val handler = CoroutineExceptionHandler { ctx, e ->
    val correlationId = ctx[CorrelationIdContextElement]?.correlationId ?: NO_CORRELATION_ID
    log.error(">>> Unhandled background coroutine error, correlationId=[$correlationId]", ctx, e)
}

The useful part here is that the error handling is not an afterthought at each launch site. Unhandled background coroutine failures are logged in one place, and the log entry attempts to include a correlation id from the coroutine context.

That is especially valuable when the background job was triggered by a request, message, or scheduled process, and you want to connect the failure back to the original flow.

Clean shutdown

The configuration implements DisposableBean:

override fun destroy() {
    backgroundDispatcher.close()
    backgroundExecutor.shutdown()
}

The dispatcher is backed by an executor, and the executor should be shut down when the Spring application context is closed. Without this, you risk leaving executor resources alive longer than intended during shutdown, tests, redeployments, or local development restarts.

Typical use cases

I would typically use a background dispatcher like this for work that is application-owned, asynchronous, and not part of the direct response path.

Some examples are:

  1. Sending email, SMS, push notifications, or webhooks after a command has been accepted.
  2. Calling third-party APIs where the user does not need to wait for the result immediately.
  3. Post-processing uploaded files, generated reports, images, or exported data.
  4. Updating derived state, caches, search indexes, or read models after a write.
  5. Running fan-out tasks where one action needs to trigger several independent downstream operations.
  6. Starting work from a Spring event listener or scheduled job while keeping the coroutine execution policy consistent.
  7. Running blocking I/O integrations from coroutine-based services without mixing that concern into every service method.

The common theme is that the work should be visible as background work in the code. Injecting a specifically named scope or dispatcher makes that intent clear.

How it might be used

A service can inject the scope and launch work on it:

@Service
class NotificationService(
    @Qualifier(BACKGROUND_SCOPE_NAME)
    private val backgroundScope: CoroutineScope,
) {

    fun sendWelcomeNotification(command: WelcomeNotificationCommand) {
        backgroundScope.launch {
            sendEmail(command)
            sendSms(command)
        }
    }
}

If the launched coroutine fails unexpectedly, the exception handler configured on the scope will log the failure. If multiple jobs are running in the same background scope, SupervisorJob prevents one failing job from cancelling the entire scope.

When not to use it

This configuration is not a replacement for every asynchronous processing pattern.

If the work must be durable, retryable, or guaranteed to run after the application restarts, then a queue, database-backed job table, workflow engine, or message broker is usually a better fit. A coroutine launched in memory is still in-memory work. If the process dies, that work is gone.

I would also avoid using this for request-bound concurrency. If the work is part of producing the HTTP response, prefer structured concurrency inside the suspend function handling the request. That way cancellation and error propagation remain tied to the request lifecycle.

For me, this configuration is the middle ground: useful for application-owned background work that should be explicit, observable, and cleaned up correctly, but does not need the durability guarantees of a full job processing system.

tags: kotlin - springboot - coroutine