Why Your @Async Method Ignores @Transactional (And Leaks Internal Errors)

Production bug report: "Why does the webhook error say 'Executing an update/delete query'?"

That's a JPA internal error. It should never reach users. But there it was, stored in the database and visible in the admin panel. Here's how an innocent-looking @Async method broke everything.

The Code That Looked Fine

@Slf4j
@Service
@RequiredArgsConstructor
public class WebhookService {

    private final WebhookRepository webhookRepository;
    private final WebhookEventRepository webhookEventRepository;

    @Async
    public void fireEvent(Long userId, String event, Map<String, Object> payload) {
        List<WebhookEntity> webhooks = webhookRepository
            .findByUserIdAndEnabledTrue(userId);

        for (WebhookEntity webhook : webhooks) {
            WebhookEventEntity webhookEvent = createWebhookEvent(webhook, event, payload);
            deliverWebhook(webhookEvent, webhook);
        }
    }

    @Transactional
    public void deliverWebhook(WebhookEventEntity event, WebhookEntity webhook) {
        // ... send HTTP request ...

        if (success) {
            event.setStatus(WebhookEventStatus.DELIVERED);
            webhookEventRepository.save(event);

            // Reset failure count atomically
            webhookRepository.resetFailureCount(webhook.getId(), System.currentTimeMillis());
        }
    }
}

The repository method:

@Modifying
@Query("UPDATE WebhookEntity w SET w.failureCount = 0, w.modifiedTime = :now WHERE w.id = :id")
void resetFailureCount(@Param("id") Long id, @Param("now") Long now);

Looks reasonable, right? @Async for background processing, @Transactional for database consistency, @Modifying for atomic updates.

The Error

javax.persistence.TransactionRequiredException:
Executing an update/delete query

This was getting stored in the lastError field of webhook events. Users could see it. Security and UX nightmare.

The Root Cause: Self-Invocation Bypasses Proxies

Spring's @Transactional works through proxies. When you call a @Transactional method, you're actually calling a proxy that:

  1. Starts a transaction
  2. Calls your actual method
  3. Commits or rolls back

But here's the catch: self-invocation bypasses the proxy.

@Async
public void fireEvent(...) {
    // This runs in a new thread, outside any transaction

    for (WebhookEntity webhook : webhooks) {
        // This calls the method DIRECTLY, not through the proxy
        deliverWebhook(webhookEvent, webhook);  // @Transactional is ignored!
    }
}

When fireEvent() calls deliverWebhook(), it's calling this.deliverWebhook() - the actual method on the instance, not the Spring proxy. The @Transactional annotation is invisible.

The @Modifying query requires an active transaction. No transaction = exception.

The Fix: TransactionTemplate

When you can't rely on @Transactional, use programmatic transaction management:

@Slf4j
@Service
@RequiredArgsConstructor
public class WebhookService {

    private final WebhookRepository webhookRepository;
    private final WebhookEventRepository webhookEventRepository;
    private final TransactionTemplate transactionTemplate;  // Inject this

    @Async
    public void fireEvent(Long userId, String event, Map<String, Object> payload) {
        List<WebhookEntity> webhooks = webhookRepository.findByUserIdAndEnabledTrue(userId);

        for (WebhookEntity webhook : webhooks) {
            WebhookEventEntity webhookEvent = createWebhookEvent(webhook, event, payload);
            deliverWebhook(webhookEvent, webhook);
        }
    }

    public void deliverWebhook(WebhookEventEntity event, WebhookEntity webhook) {
        // ... send HTTP request ...

        if (success) {
            // Wrap database operations in explicit transaction
            transactionTemplate.executeWithoutResult(status -> {
                event.setStatus(WebhookEventStatus.DELIVERED);
                webhookEventRepository.save(event);
                webhookRepository.resetFailureCount(webhook.getId(), System.currentTimeMillis());
            });
        }
    }
}

TransactionTemplate doesn't rely on proxies. It explicitly starts and commits transactions. Works everywhere, including:

  • Self-invoked methods
  • @Async methods
  • Lambda callbacks
  • Anywhere proxy magic fails

Bonus: Sanitize Your Error Messages

Even after fixing the transaction issue, you should never leak internal errors to users:

private String sanitizeErrorMessage(String message) {
    if (message == null) {
        return "Unknown error";
    }

    // Detect internal errors
    if (message.contains("Executing an update/delete query") ||
        message.contains("javax.persistence") ||
        message.contains("org.hibernate") ||
        message.contains("java.sql") ||
        message.contains("SQLException")) {
        log.error("Internal error during webhook delivery: {}", message);
        return "Internal server error";
    }

    return message.length() > 500 ? message.substring(0, 497) + "..." : message;
}

The Debugging Checklist

When you see TransactionRequiredException:

  1. Is the method called from the same class? (self-invocation)
  2. Is the caller @Async? (new thread, no transaction)
  3. Is the method private? (proxies can't intercept)
  4. Is the class final? (no proxy possible)

Any of these = @Transactional won't work. Use TransactionTemplate.


Ever been bitten by Spring proxy magic? What's your go-to solution for transaction issues in async code?

Building jo4.io - a URL shortener that actually handles webhooks reliably.