Async vs Sync Audit Logging: When to Use Which

When building audit logging for your application, one of the first decisions is whether to log synchronously or asynchronously. Here's what we learned implementing both patterns.

The Two Patterns

Synchronous Logging

@Transactional(propagation = Propagation.REQUIRES_NEW)
public AuditLogEntity log(UserEntity actor, String action, ...) {
    AuditLogEntity auditLog = AuditLogEntity.builder()
        .actorId(actor.getId())
        .action(action)
        // ... more fields
        .build();
    return auditLogRepository.save(auditLog);
}

Characteristics:

  • Blocks until the audit log is written
  • Caller waits for database INSERT
  • Transaction completes before method returns

Asynchronous Logging

@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logAsync(UserEntity actor, String action, ...) {
    try {
        log(actor, action, ...);
    } catch (Exception e) {
        log.error("Failed to log audit event: {}", e.getMessage());
    }
}

Characteristics:

  • Returns immediately
  • Audit write happens in background thread pool
  • Main operation isn't blocked by audit I/O

When to Use Sync

Use synchronous audit logging when:

1. Audit is Legally Required Before Response

Some compliance frameworks require proof that the audit was recorded before confirming an action:

// Admin disabling a user - must be logged before response
AuditLogEntity auditEntry = auditService.log(admin, ACTION_USER_DISABLED,
    TARGET_TYPE_USER, userId, "Violated ToS");

// Now we can confirm to the admin
return ResponseEntity.ok("User disabled. Audit ID: " + auditEntry.getId());

2. You Need the Audit Log ID

If your response includes the audit reference:

AuditLogEntity audit = auditService.log(...);
return ResponseBody.builder()
    .response(result)
    .auditTrailId(audit.getId())  // Can't do this with async
    .build();

3. Admin/Privileged Operations

Admin actions are less frequent and more sensitive. The extra latency is acceptable:

// AdminController - all operations use sync
auditService.log(admin, ACTION_URL_SAFETY_OVERRIDE, TARGET_TYPE_URL, urlId, reason);
auditService.logWithValues(admin, ACTION_USER_SUBSCRIPTION_CHANGE,
    TARGET_TYPE_USER, userId, before, after, reason);

When to Use Async

Use asynchronous audit logging when:

1. User-Facing API Endpoints

Don't make users wait for audit I/O:

// UrlController - user creating a short URL
UrlEntity created = urlService.createUrl(urlEntity, user.getId(), tenantId);

// Fire and forget - user gets response immediately
auditService.logAsync(user, ACTION_URL_CREATED, TARGET_TYPE_URL,
    created.getId(), created.getSlug(),
    null, Map.of("shortUrl", created.getShortUrl(), "longUrl", created.getLongUrl()),
    null, null, null, null);

return ResponseEntity.status(HttpStatus.CREATED).body(responseBody);

2. High-Throughput Operations

Bulk operations especially benefit:

// Bulk import - one audit log for the whole batch
if (successCount > 0) {
    auditService.logAsync(user, ACTION_URL_BULK_IMPORT, TARGET_TYPE_URL,
        null, null, null,
        Map.of("totalProcessed", results.size(),
               "successCount", successCount,
               "failureCount", failureCount),
        null, null, null, null);
}

3. Non-Critical Audit Events

Operational logs that are nice-to-have:

// Bio link reordering - minor operation
auditService.logAsync(user, ACTION_BIO_LINK_REORDERED, TARGET_TYPE_BIO,
    bioPage.getId(), bioPage.getSlug(),
    null, Map.of("linkId", linkId, "direction", direction),
    null, null, null, null);

The REQUIRES_NEW Secret Sauce

Both patterns use Propagation.REQUIRES_NEW:

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logAsync(...) { ... }

This ensures the audit log commits in its own transaction. Why?

  1. Main transaction rollback - If the business operation fails, we still log the attempt
  2. Audit failure isolation - If audit logging fails, it doesn't roll back the main operation
  3. Connection management - Gets its own connection from the pool

Error Handling Difference

Sync: Propagate or Handle

// Option 1: Let it bubble up
auditService.log(...);  // Throws if it fails

// Option 2: Handle explicitly
try {
    auditService.log(...);
} catch (Exception e) {
    log.error("Audit failed, but continuing: {}", e.getMessage());
}

Async: Always Catch

The async method must catch exceptions - they can't propagate to the caller:

@Async
public void logAsync(...) {
    try {
        log(...);
    } catch (Exception e) {
        // Log it, alert it, but can't throw to caller
        log.error("Failed to log audit event: {}", e.getMessage());
        // Maybe send to dead letter queue for retry
    }
}

Our Split: 90% Async, 10% Sync

After implementing 67 audit points:

Controller Type Pattern Count
User-facing Async 57
Admin Sync 10

The result: Fast user APIs with reliable admin audit trails.


Building jo4.io - a URL shortener with comprehensive audit logging.