
El caso de uso
Un endpoint REST recibe la subida de un archivo de 50 MB. Hay que validarlo, almacenarlo en S3, generar miniaturas, indexarlo en Elasticsearch y notificar al usuario. Si todo se hace síncronamente, el endpoint tarda 8-10 segundos. Si se procesa en background, el endpoint responde en 100 ms con un identificador y el procesamiento ocurre en otro thread.
@Async resuelve este caso. Mueve la ejecución del método a un thread del executor configurado y devuelve inmediatamente al llamador.
Activación
@SpringBootApplication
@EnableAsync
public class EmpleadosApplication { ... }
Uso básico
Método void: fire-and-forget
@Service
public class FileProcessingService {
@Async
public void procesarArchivo(byte[] contenido, String nombre) {
log.info("Procesando archivo {} en thread {}", nombre, Thread.currentThread().getName());
// 8-10 segundos de procesamiento
validar(contenido);
almacenarEnS3(contenido, nombre);
generarMiniaturas(contenido);
indexarEnElasticsearch(nombre);
notificarUsuario(nombre);
}
}
@RestController
@RequiredArgsConstructor
public class FileController {
private final FileProcessingService service;
@PostMapping("/files")
public ResponseEntity<FileUploadDto> subir(@RequestBody FileUploadRequest req) {
var id = UUID.randomUUID().toString();
service.procesarArchivo(req.contenido(), req.nombre()); // no bloquea
return ResponseEntity.accepted().body(new FileUploadDto(id, "PROCESSING"));
}
}
El cliente recibe 202 Accepted en milisegundos. El procesamiento real sigue su curso en otro thread.
Devolver CompletableFuture
Cuando el llamador necesita el resultado:
@Service
public class DataAggregationService {
@Async
public CompletableFuture<List<EmpleadoDto>> buscarEmpleadosDeptoA() {
var empleados = findByDepartment("A");
return CompletableFuture.completedFuture(empleados);
}
@Async
public CompletableFuture<List<EmpleadoDto>> buscarEmpleadosDeptoB() {
var empleados = findByDepartment("B");
return CompletableFuture.completedFuture(empleados);
}
}
@RestController
@RequiredArgsConstructor
public class DashboardController {
private final DataAggregationService service;
@GetMapping("/dashboard")
public DashboardDto dashboard() throws Exception {
var futureA = service.buscarEmpleadosDeptoA();
var futureB = service.buscarEmpleadosDeptoB();
// Las dos consultas se ejecutan en paralelo
var empleadosA = futureA.get(5, TimeUnit.SECONDS);
var empleadosB = futureB.get(5, TimeUnit.SECONDS);
return new DashboardDto(empleadosA, empleadosB);
}
}
Si cada consulta tarda 1 segundo y se ejecutan en paralelo, el endpoint tarda 1 segundo (no 2). En aplicaciones con muchas llamadas a servicios externos, este patrón es la diferencia entre 200 ms y 2 segundos.
Configuración del TaskExecutor
Spring Boot detecta beans TaskExecutor y los usa para @Async. Sin configuración explícita, Spring Boot 3+ usa por defecto un ApplicationTaskExecutor con corePoolSize = 8.
Pool tradicional para CPU-bound
Si las tareas hacen trabajo CPU-intensivo (parseo, cálculo, encryption), un pool con tamaño igual a núcleos es lo correcto:
@Configuration
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
var executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(Runtime.getRuntime().availableProcessors());
executor.setMaxPoolSize(Runtime.getRuntime().availableProcessors() * 2);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("async-cpu-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}
CallerRunsPolicy hace que, si el pool y la cola están llenos, la tarea se ejecute en el thread del llamador en lugar de descartarla. Es backpressure natural.
Virtual Threads para IO-bound (Spring Boot 4 + Java 25)
Para tareas que hacen IO (HTTP, JDBC, archivos), Virtual Threads son ideales: cero coste de creación, sin contención, miles concurrentes:
@Configuration
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
var executor = new SimpleAsyncTaskExecutor("async-vt-");
executor.setVirtualThreads(true);
executor.setTaskTerminationTimeout(Duration.ofSeconds(30));
return executor;
}
}
O activar globalmente con propiedad:
spring:
threads:
virtual:
enabled: true
Con esto, todos los executors de Spring (incluido el de @Async y el del servidor web) usan Virtual Threads.
Regla práctica: si las tareas hacen IO (DB, HTTP, archivos, Kafka) → Virtual Threads. Si hacen cálculo puro → pool tradicional con tamaño = núcleos.
Múltiples executors
Una aplicación puede tener varios executors para diferentes tipos de tareas:
@Configuration
public class AsyncConfig {
@Bean(name = "ioExecutor")
public TaskExecutor ioExecutor() {
var executor = new SimpleAsyncTaskExecutor("io-");
executor.setVirtualThreads(true);
return executor;
}
@Bean(name = "cpuExecutor")
public TaskExecutor cpuExecutor() {
var executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(Runtime.getRuntime().availableProcessors());
executor.setMaxPoolSize(Runtime.getRuntime().availableProcessors());
executor.setThreadNamePrefix("cpu-");
executor.initialize();
return executor;
}
}
@Service
public class MyService {
@Async("ioExecutor")
public CompletableFuture<String> llamadaHttp() { ... }
@Async("cpuExecutor")
public CompletableFuture<byte[]> generarMiniatura() { ... }
}
@Async("nombreBean") selecciona el executor explícitamente.
Propagación de contexto
@Async cambia de thread, lo que rompe los contextos basados en ThreadLocal por defecto: SecurityContextHolder, RequestContextHolder y MDC. Esto es un problema:
@Async
public void procesar() {
// SecurityContextHolder.getContext().getAuthentication() == null !!
// MDC.get("correlationId") == null !!
}
SecurityContext: DelegatingSecurityContextAsyncTaskExecutor
@Bean
public TaskExecutor asyncExecutor() {
var delegate = new ThreadPoolTaskExecutor();
delegate.setCorePoolSize(8);
delegate.initialize();
return new DelegatingSecurityContextAsyncTaskExecutor(delegate);
}
Con esto, el SecurityContext se copia al thread asíncrono.
MDC: TaskDecorator
@Bean
public TaskExecutor asyncExecutor() {
var executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setTaskDecorator(runnable -> {
var contextMap = MDC.getCopyOfContextMap();
return () -> {
try {
if (contextMap != null) MDC.setContextMap(contextMap);
runnable.run();
} finally {
MDC.clear();
}
};
});
executor.initialize();
return executor;
}
El TaskDecorator se aplica a cada Runnable antes de enviarlo al pool. Captura el MDC del thread llamador y lo restaura en el thread ejecutor.
RequestContext: RequestContextFilter + propagación
Para que RequestContextHolder.currentRequestAttributes() funcione en el thread asíncrono:
@Bean
public TaskDecorator requestContextDecorator() {
return runnable -> {
var requestAttributes = RequestContextHolder.currentRequestAttributes();
return () -> {
try {
RequestContextHolder.setRequestAttributes(requestAttributes);
runnable.run();
} finally {
RequestContextHolder.resetRequestAttributes();
}
};
};
}
Patrón completo
@Configuration
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
var delegate = new ThreadPoolTaskExecutor();
delegate.setCorePoolSize(8);
delegate.setMaxPoolSize(32);
delegate.setThreadNamePrefix("async-");
delegate.setTaskDecorator(this::decoradorMdcYRequest);
delegate.initialize();
// Wrap para SecurityContext
return new DelegatingSecurityContextAsyncTaskExecutor(delegate);
}
private Runnable decoradorMdcYRequest(Runnable runnable) {
var contextMap = MDC.getCopyOfContextMap();
var requestAttributes = RequestContextHolder.getRequestAttributes();
return () -> {
try {
if (contextMap != null) MDC.setContextMap(contextMap);
if (requestAttributes != null) RequestContextHolder.setRequestAttributes(requestAttributes);
runnable.run();
} finally {
MDC.clear();
RequestContextHolder.resetRequestAttributes();
}
};
}
}
Con esto, los logs en threads asíncronos muestran el correlation ID, el SecurityContext está disponible (por ejemplo para @PreAuthorize en métodos @Async) y RequestContextHolder también.
Error handling
Métodos void: AsyncUncaughtExceptionHandler
Para @Async con retorno void, las excepciones se pierden. Hay que registrarlas con un handler:
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (throwable, method, params) -> {
log.error("Excepción en método async {}.{}: {}",
method.getDeclaringClass().getSimpleName(),
method.getName(),
throwable.getMessage(),
throwable);
// Métricas: incrementar contador
// Alerta: si es crítico
};
}
Métodos con CompletableFuture
La excepción se propaga por el future:
service.buscarAlgo()
.exceptionally(ex -> {
log.error("Fallo en búsqueda", ex);
return Collections.emptyList();
})
.thenAccept(resultado -> { ... });
O con try/catch sobre .get():
try {
var resultado = service.buscarAlgo().get(5, TimeUnit.SECONDS);
} catch (ExecutionException e) {
var cause = e.getCause(); // la excepción real lanzada en el método
log.error("Fallo: {}", cause.getMessage());
}
Combinando @Async y @Transactional
@Async invalida la transacción del llamador (otro thread = otra transacción). Si el método asíncrono necesita transacción propia, hay que anotarlo:
@Async
@Transactional
public CompletableFuture<Empleado> crearEmpleadoAsync(EmpleadoDto dto) {
// Esta transacción es independiente de la del llamador
var empleado = repository.save(toEntity(dto));
return CompletableFuture.completedFuture(empleado);
}
Atención: el llamador no espera ni participa en la transacción del método async. Si el llamador hace rollback, el método async ya habrá hecho commit (o seguirá pendiente). Para coordinarlos hay que diseñar el flujo con eventos transaccionales (lección anterior) o publicar después del commit del llamador.
Limitaciones
Como cualquier mecanismo basado en proxies de Spring:
- Llamadas internas no se interceptan: si
metodoAllama ametodoBAsyncdentro de la misma clase, no es asíncrono (la llamada va porthis, no por el proxy). - Solo métodos públicos: por defecto solo métodos
publicse interceptan. - No retorna en métodos
void: si el método devuelvevoid, no hay forma de saber si terminó ni si falló (salvo por elAsyncUncaughtExceptionHandlerglobal).
Combinación con CompletableFuture
CompletableFuture ofrece composición de flujos asíncronos:
service.buscarEmpleado(id)
.thenCompose(empleado -> service.buscarDepartamento(empleado.getDepartamentoId()))
.thenCombine(service.buscarHistorialSalarios(id), (depto, salarios) ->
new EmpleadoCompletoDto(depto, salarios))
.exceptionally(ex -> EmpleadoCompletoDto.vacio())
.thenAccept(dto -> log.info("Resultado: {}", dto));
thenCompose: encadena un futuro que depende del resultado del anterior.thenCombine: combina dos futuros independientes.exceptionally: captura excepciones.
Con Virtual Threads, el código secuencial (var x = service.buscar(); var y = service.otro(x);) es igual de eficiente que la composición con CompletableFuture. Esa es la promesa de Loom: código simple sin perder rendimiento.
Buenas prácticas
- Decidir IO vs CPU bound: Virtual Threads para IO, pool tradicional para CPU.
- Configurar TaskDecorator para propagar MDC y RequestContext desde el primer momento.
- Usar
DelegatingSecurityContextAsyncTaskExecutorsi la aplicación tiene Spring Security. - Devolver
CompletableFuture<T>cuando el llamador necesita el resultado o quiere manejar errores. - Métricas con Micrometer sobre el TaskExecutor: tamaño activo, cola, rechazados. Sin estas métricas, una saturación de pool pasa desapercibida.
- No abusar:
@Asyncañade complejidad. Si el resultado se necesita inmediatamente, mejor síncrono. Reservar para casos de fire-and-forget genuinos o paralelización efectiva. - No combinar
@Asynccon@Scheduledsin pensarlo:@Asynccambia de pool y puede saltarse el@SchedulerLocko los timers de Micrometer.
El procesamiento asíncrono en Spring Boot 4 sobre Java 25 LTS es uno de los superpoderes del stack moderno. Virtual Threads han eliminado la mayor parte de las decisiones difíciles de configuración de pools. La regla nueva es simple: para IO, Virtual Threads; para CPU, núcleos físicos. Lo demás es propagación de contexto y manejo de errores.
Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Spring Boot es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de Spring Boot
Explora más contenido relacionado con Spring Boot y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
Mover métodos a otro thread con @Async sin bloquear al llamador. Configurar TaskExecutor con Virtual Threads o thread pool tradicional. Combinar @Async con CompletableFuture para devolver resultados. Propagar SecurityContext, MDC y RequestContextHolder a los threads asíncronos. Diferenciar tareas IO-bound (Virtual Threads) de CPU-bound (ForkJoinPool).