DEV Community

Cover image for Async vs Sync Audit Logging: When to Use Which
Anand Rathnas
Anand Rathnas

Posted on

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);
}
Enter fullscreen mode Exit fullscreen mode

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());
    }
}
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

The REQUIRES_NEW Secret Sauce

Both patterns use Propagation.REQUIRES_NEW:

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logAsync(...) { ... }
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

Whats your audit story? Always looking for ways to improve.

Building jo4.io - URL shortener with analytics

Top comments (0)