Skip to main content
Caffeine provides removal listeners to receive notifications when entries are removed from the cache, allowing you to perform cleanup, logging, or other side effects.

Removal causes

Entries can be removed for various reasons, categorized by the RemovalCause enum.

Removal cause types

Manual removal by the user:
  • invalidate(key)
  • invalidateAll(keys) or invalidateAll()
  • refresh(key) (replaces existing value)
  • Map operations: remove(), computeIfPresent(), compute(), merge()
Not considered an eviction (wasEvicted() returns false).
Reference: RemovalCause.java

Removal listener

Receive notifications for all removal causes asynchronously.

Basic listener

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.RemovalListener;
import com.github.benmanes.caffeine.cache.RemovalCause;

RemovalListener<String, Document> listener = 
    (key, value, cause) -> {
        System.out.printf("Removed key=%s, value=%s, cause=%s%n",
            key, value, cause);
    };

Cache<String, Document> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .removalListener(listener)
    .build();
Reference: RemovalListener.java:50
Removal listeners are invoked asynchronously on the configured executor after the removal operation completes. The default executor is ForkJoinPool.commonPool().
Reference: Caffeine.java:991

Listener execution

The removal listener executes asynchronously:
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;

Executor executor = Executors.newSingleThreadExecutor();

Cache<String, Resource> cache = Caffeine.newBuilder()
    .maximumSize(1_000)
    .executor(executor) // Listener runs on this executor
    .removalListener((key, resource, cause) -> {
        resource.close(); // Cleanup resource
    })
    .build();
Reference: Caffeine.java:361

Filtering by cause

Handle different removal causes:
RemovalListener<String, Session> listener = (key, session, cause) -> {
    switch (cause) {
        case EXPIRED:
            logger.info("Session expired: {}", key);
            sessionStore.logExpiration(session);
            break;
        case SIZE:
            logger.warn("Session evicted due to size: {}", key);
            metrics.incrementEvictions();
            break;
        case EXPLICIT:
            logger.debug("Session explicitly removed: {}", key);
            break;
        case REPLACED:
            logger.debug("Session replaced: {}", key);
            break;
        case COLLECTED:
            logger.error("Session garbage collected: {}", key);
            break;
    }
};

Cache<String, Session> cache = Caffeine.newBuilder()
    .expireAfterAccess(Duration.ofHours(1))
    .maximumSize(10_000)
    .removalListener(listener)
    .build();

Handling null keys and values

Keys or values may be null if they were garbage collected:
RemovalListener<String, Resource> listener = (key, resource, cause) -> {
    if (key == null) {
        logger.warn("Key was garbage collected");
        return;
    }
    
    if (resource == null) {
        logger.warn("Value was garbage collected for key: {}", key);
        return;
    }
    
    // Safe to use both key and resource
    resource.cleanup();
};

Cache<String, Resource> cache = Caffeine.newBuilder()
    .weakKeys()
    .softValues()
    .removalListener(listener)
    .build();
Keys and values may be null in the listener callback if the entry was removed due to garbage collection.

Eviction listener

Receive notifications only for automatic evictions, not manual removals.

Basic eviction listener

import com.github.benmanes.caffeine.cache.RemovalListener;

RemovalListener<String, Data> listener = (key, data, cause) -> {
    if (cause.wasEvicted()) {
        logger.warn("Entry evicted: {} (cause: {})", key, cause);
        metrics.recordEviction(cause);
    }
};

Cache<String, Data> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(Duration.ofMinutes(30))
    .evictionListener(listener)
    .build();
Reference: Caffeine.java:938
Eviction listeners are invoked synchronously during the atomic removal operation, before the entry is removed from the cache.

Eviction vs removal listener

Cache<String, Resource> cache = Caffeine.newBuilder()
    .maximumSize(1_000)
    .evictionListener((key, resource, cause) -> {
        // Runs synchronously in the thread that triggered eviction
        // Affects cache operation latency
        resource.close();
    })
    .build();
Eviction listeners run synchronously and will block cache operations. Keep them fast and simple. Use removal listeners for time-consuming operations.

When to use each

  • You need to perform cleanup before the entry is removed
  • The cleanup operation is fast and must be synchronous
  • You only care about automatic evictions (not manual removals)
  • You’re using weakKeys() with buildAsync() (removal listener not supported)
  • You need to handle all removal causes (including manual)
  • The cleanup operation is time-consuming or I/O bound
  • You want to avoid impacting cache operation latency
  • You need to handle notifications asynchronously

Common use cases

Resource cleanup

RemovalListener<String, Connection> listener = (key, connection, cause) -> {
    if (connection != null) {
        try {
            connection.close();
            logger.debug("Closed connection: {} (cause: {})", key, cause);
        } catch (IOException e) {
            logger.error("Failed to close connection: {}", key, e);
        }
    }
};

Cache<String, Connection> cache = Caffeine.newBuilder()
    .expireAfterAccess(Duration.ofMinutes(10))
    .maximumSize(100)
    .removalListener(listener)
    .build();

Metrics and monitoring

RemovalListener<String, Data> listener = (key, data, cause) -> {
    // Record metrics
    metrics.recordRemoval(cause.name());
    
    if (cause.wasEvicted()) {
        metrics.incrementEvictions();
        
        if (cause == RemovalCause.SIZE) {
            metrics.incrementSizeEvictions();
        } else if (cause == RemovalCause.EXPIRED) {
            metrics.incrementExpirations();
        }
    }
    
    // Track entry lifetime
    if (data != null) {
        long lifetime = System.currentTimeMillis() - data.getCreatedAt();
        metrics.recordEntryLifetime(lifetime);
    }
};

Write-through cache invalidation

Cache<String, User> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .removalListener((key, user, cause) -> {
        if (cause == RemovalCause.EXPLICIT || cause == RemovalCause.REPLACED) {
            // Propagate explicit changes to backing store
            try {
                backingStore.invalidate(key);
            } catch (Exception e) {
                logger.error("Failed to invalidate backing store", e);
            }
        }
    })
    .build();

Cascading invalidation

RemovalListener<String, Document> listener = (key, document, cause) -> {
    if (document != null && cause == RemovalCause.EXPLICIT) {
        // Invalidate related entries
        Set<String> relatedKeys = document.getRelatedDocumentIds();
        relatedKeys.forEach(cache::invalidate);
    }
};

Cache<String, Document> cache = Caffeine.newBuilder()
    .maximumSize(5_000)
    .removalListener(listener)
    .build();

Exception handling

Listeners should handle exceptions gracefully.

Exception behavior

RemovalListener<String, Resource> listener = (key, resource, cause) -> {
    try {
        resource.close();
    } catch (Exception e) {
        // Exceptions are logged but not propagated
        logger.error("Failed to close resource: {}", key, e);
        // Cache operation continues normally
    }
};
Exceptions thrown by removal listeners are logged (via System.Logger) and swallowed. They do not propagate to cache callers.
Reference: Caffeine.java:977

Robust listener implementation

RemovalListener<String, Resource> listener = (key, resource, cause) -> {
    try {
        if (resource != null) {
            resource.close();
        }
    } catch (Exception e) {
        logger.error("Cleanup failed for key={}, cause={}", key, cause, e);
        
        // Report to monitoring
        metrics.recordListenerError(e.getClass().getSimpleName());
        
        // Attempt alternative cleanup
        try {
            resource.forceClose();
        } catch (Exception fallbackError) {
            logger.error("Fallback cleanup failed: {}", key, fallbackError);
        }
    }
};

Best practices

  • Minimize work in synchronous eviction listeners
  • Use removal listeners for expensive operations
  • Offload time-consuming tasks to separate threads
  • Avoid blocking operations in listeners
  • Check for null keys and values (garbage collection)
  • Handle all removal causes appropriately
  • Catch and log exceptions; don’t let them propagate
  • Test listener behavior with different removal scenarios
  • Log listener errors for debugging
  • Track listener execution time
  • Record listener failure rates
  • Alert on excessive listener errors
  • Use eviction listeners only when synchronous behavior is needed
  • Configure appropriate executor for removal listeners
  • Consider listener overhead in capacity planning
  • Avoid circular invalidation (listener invalidating other entries)

Build docs developers (and LLMs) love