Why Do Spring Boot @Async Threads Silently Fail on AWS Lambda?

spring boot aws lambda async serverless concurrency

Spring Boot @Async threads on AWS Lambda aren't failing — they're being killed mid-execution. AWS Lambda freezes the JVM process the instant your handler method returns a response. Any background threads that @Async spawned get frozen (and often never resumed), so work silently disappears. The fix is to stop using fire-and-forget @Async on Lambda entirely. Instead, either await the Future before returning or move genuinely asynchronous work to SQS/SNS.

Why Lambda Kills Your @Async Threads

Lambda's execution model enforces a critical contract: after your handler returns, the runtime might immediately freeze the entire container. The JVM doesn't get a shutdown hook, the ThreadPoolTaskExecutor doesn't flush, and your @Async Runnable might be mid-execution when everything halts. On a warm invoke, the frozen thread might resume, but you have no guarantee. On cold starts or scale-down, that work is permanently lost. This is fundamentally different from running on EC2 or ECS, where the JVM stays alive.

The Broken @Async Pattern on Lambda

Here's the typical code that silently loses work on Lambda.

@Service
public class NotificationService {

    // This thread will be frozen or killed when the handler returns.
    @Async
    public void sendNotification(String userId, String message) {
        externalApi.postNotification(userId, message);
        auditRepository.save(new AuditEntry(userId, message));
    }
}
@RestController
public class OrderController {

    @Autowired
    private NotificationService notificationService;

    @PostMapping("/orders")
    public ResponseEntity createOrder(@RequestBody OrderRequest request) {
        Order order = orderService.process(request);
        // Fire-and-forget: Lambda freezes before this completes.
        notificationService.sendNotification(order.getUserId(), "Order placed");
        return ResponseEntity.ok(order);
    }
}

Fix 1: Await the CompletableFuture Before Returning

If the async work must happen inside the same Lambda invocation, return a CompletableFuture from your @Async method and block on it before the handler responds. Yes, this defeats the purpose of @Async, and that's the point. On Lambda, fire-and-forget inside the same process isn't safe.

@Service
public class NotificationService {

    // Return a Future so the caller can await completion.
    @Async
    public CompletableFuture sendNotification(String userId, String message) {
        externalApi.postNotification(userId, message);
        auditRepository.save(new AuditEntry(userId, message));
        return CompletableFuture.completedFuture(null);
    }
}
@PostMapping("/orders")
public ResponseEntity createOrder(@RequestBody OrderRequest request)
        throws Exception {
    Order order = orderService.process(request);
    CompletableFuture notification =
            notificationService.sendNotification(order.getUserId(), "Order placed");
    // Block until the async work finishes so Lambda doesn't freeze it.
    notification.get(5, TimeUnit.SECONDS);
    return ResponseEntity.ok(order);
}

Fix 2: Offload to SQS for Production Async Work

If you genuinely need asynchronous processing, meaning the caller shouldn't wait, the work must leave the Lambda process entirely. Push a message to SQS or SNS, then have a separate Lambda (or any consumer) process it. This is the idiomatic serverless pattern, and it also gives you retry semantics, dead-letter queues, and observability for free.

@Service
public class NotificationService {

    @Autowired
    private SqsTemplate sqsTemplate;

    // No @Async. Synchronously enqueue, then return.
    public void enqueueNotification(String userId, String message) {
        NotificationEvent event = new NotificationEvent(userId, message);
        // Send completes before the handler returns — work is safe.
        sqsTemplate.send("notification-queue", event);
    }
}

Gotchas and Edge Cases

Warm containers create false confidence. During local testing or on a warm Lambda, the frozen thread might resume on the next invocation and complete its work. This makes the bug appear intermittent: the notification sometimes arrives and sometimes doesn't. You'll see success rates around 60–80% under load, which is maddening to debug.

@EnableAsync with a custom ThreadPoolTaskExecutor doesn't help. The pool stays alive only as long as the JVM process does. Lambda's freeze/thaw cycle doesn't respect thread pool lifecycle at all. Setting setWaitForTasksToCompleteOnShutdown(true) on the executor is useless because Lambda doesn't trigger a graceful shutdown. It freezes the process.

Spring Cloud Function changes nothing. Whether you use SpringBootRequestHandler, the AWS Serverless Java Container, or Spring Cloud Function, the underlying issue is identical: the Lambda runtime freezes your process after the handler returns.

If you must run parallel work within a single invocation, use CompletableFuture.allOf() and join before returning. This gives you real concurrency during the invocation while guaranteeing that all work completes before Lambda freezes.

@PostMapping("/orders")
public ResponseEntity createOrder(@RequestBody OrderRequest request)
        throws Exception {
    Order order = orderService.process(request);
    // Run both in parallel on the @Async executor.
    CompletableFuture notification =
            notificationService.sendNotification(order.getUserId(), "Order placed");
    CompletableFuture analytics =
            analyticsService.trackEvent(order.getId(), "ORDER_CREATED");
    // Wait for all work to finish before Lambda freezes the container.
    CompletableFuture.allOf(notification, analytics).get(10, TimeUnit.SECONDS);
    return ResponseEntity.ok(order);
}

Which Approach to Pick

If the async work takes under a few hundred milliseconds and belongs to the request's logical unit of work, await the CompletableFuture inline. It's the smallest change and preserves your existing code structure. If the work is slow, unreliable, or truly decoupled from the request (emails, webhooks, analytics), offload it to SQS. Don't attempt to replicate long-lived background threads on Lambda. That's what ECS, EKS, or a plain EC2 instance is for.

← Back to all articles