Articles
Caffeine Cache Interview Questions: Eviction, Refresh, and Asynchronous Loading
Practice Caffeine Cache interview questions with eviction budgets, refresh timelines, shared futures, failed loads, and deterministic time-based testing.
To reason through a Caffeine Cache interview, separate three questions: is the entry present, is it old enough to refresh, and what does the caller receive while loading happens? Treating eviction, expiration, and refresh as interchangeable produces incorrect latency and freshness claims.
This guide uses official facts from Caffeine's documentation and API contracts, then original exercises and engineering inference to trace caller-visible behavior. It does not use candidate reports or claim an employer's interview frequency.
What does a local cache guarantee?
Official fact: Caffeine is an in-memory Java cache with optional loading, eviction, expiration, refresh, and statistics. The project's current dependency example uses 3.2.4; pin the version used by your exercise rather than assuming every integration exposes identical options. See the project README.
Original exercise: Two application instances each cache product 42. Updating the cache in instance A does not update instance B's separate memory. A successful local invalidation is therefore not proof of fleet-wide freshness.
Engineering inference: state the scope before presenting a guarantee. Per-key load coalescing inside one cache is different from deduplicating requests across replicas. A local cache can reduce origin traffic but does not replace a durable source, distributed invalidation protocol, or authorization check.
For a price display, a briefly stale value might be acceptable. For permission revocation, the same policy may be unacceptable. Ask which decision consumes the cached result before selecting a freshness interval. An interval is a business tolerance, not an intrinsic property of the library.
How do size and weight eviction differ?
Official fact: maximumSize bounds entry count, while maximumWeight and a weigher allow entries to have different costs. Weights are calculated at creation or update and remain static until recalculated; relative weights do not directly determine victim selection. The eviction guide explains these limits.
Original exercise: One cache entry holds 10 small records and another holds 10,000. An equal entry-count budget does not express their unequal memory footprint. A weigher can represent a chosen cost, but that cost is not automatically the JVM's precise retained heap size.
If the cached object is mutated in place to contain more records, the old recorded weight does not magically follow that mutation. Engineering inference: immutable cached values make both weight reasoning and concurrent use easier. Replace the value deliberately when its cost changes, or document how weight is recalculated.
Official fact: Caffeine's design uses Window TinyLFU, combining recency and frequency information. It is not a strict global least-recently-used list. See the design description.
An interview trace asking which key will be evicted should therefore specify the policy and relevant history. Do not give a deterministic LRU answer merely because the builder has a maximum size. Explain the difference between an illustrative bounded-cache algorithm and the actual library's admission policy.
Does refreshAfterWrite fetch on a schedule?
Official fact: refreshAfterWrite makes an entry eligible for refresh after the configured age. A query initiates the refresh; an eligible but unqueried entry does not automatically receive a periodic reload. While refresh is in progress, a present old value can be returned. See Refresh.
Original exercise: Assume a loading cache contains V1 at time zero, refresh age is two minutes, and write expiration is ten minutes. No capacity eviction or invalidation occurs. A fake clock controls time and a controlled future holds refresh completion.
| Time and event | Refresh state | Caller-visible result |
|---|---|---|
| Minute 1: read | Not yet eligible | V1 |
| Minute 3: no read | Eligible, no access-triggered reload | No caller result |
| Minute 4: read, reload still pending | Refresh starts | V1 |
| Minute 4: another read before completion | Same refresh is in flight | V1 |
| Minute 5: reload completes with V2 | Replacement succeeds | A subsequent read sees V2 |
This trace deliberately completes before the expiration boundary. It does not make a claim about every refresh/expiration race. If the interviewer moves completion beyond expiration, return to the pinned API and implementation contract rather than extending the table by intuition.
A common incorrect answer says minute 2 starts a background reload even without access. Another says the minute 4 caller waits for V2 merely because the refresh age has elapsed. Both confuse refresh eligibility with a missing entry.
Engineering inference: communicate freshness and latency separately. A policy that serves V1 during reload trades stale results for lower caller latency. Whether that is acceptable depends on the data and the application's fallback rule.
How is expiration different from refresh?
expireAfterWrite measures age since creation or replacement. expireAfterAccess measures inactivity since a qualifying access or write. Those are different clocks: frequent reads can keep an access-expiring session alive while a write-expiring configuration still ages out.
Expired entries are not intended to remain readable merely because physical cleanup has not run. Maintenance affects reclamation and notifications, so an estimated size or a listener event is a different observation from a retrieval result.
Original exercise: Set an entry at minute zero and read it every minute. Compare a five-minute access expiration with a five-minute write expiration, without refresh. The former has repeated activity; the latter has no replacement. Then stop reading and ask when each should become unavailable.
Engineering inference: describe what is being measured. A retrieval contract, removal notification, physical retained entry count, and source update time can all differ. “The TTL fired late” is ambiguous until you identify the observation.
A scheduler can improve prompt expiration maintenance. It does not turn refreshAfterWrite into a periodic source fetcher. Nor does it create a new value after expiration without a loading or application retrieval operation.
What happens when two callers miss the same key?
Official fact: the population API supports synchronous manual loading, loading caches, and asynchronous variants. Atomic loading avoids a separate check-then-put race; asynchronous cache values are represented by futures. The pinned AsyncLoadingCache contract specifies removal after failed asynchronous computations. See Population.
Original exercise: A and B call AsyncLoadingCache.get("product-42") before the initial load completes. Assume no intervening invalidation, replacement, or eviction. They share the cached computation for that key rather than requiring two independent origin fetches.
| Event | Cache entry | What the caller has |
|---|---|---|
| A misses | Future F is installed | F |
| B requests the same key | F is still installed | F |
| F completes successfully | Successful cached value | Both observe that completion |
| Separate failure experiment: F fails | Failed entry is removed | Both observe failure; a later get can load again |
This is a concurrency contract within the chosen cache instance. It does not promise one origin call forever. After removal, a later request can legitimately start another load. Other processes have their own futures.
Engineering inference: avoid treating the shared future as a caller-owned cancellation handle. If one HTTP request times out, cancelling shared work can affect other callers. Define request deadlines separately from the shared origin-load lifetime, and verify the semantics of your wrapper.
An async API also does not guarantee the loader has no blocking work. The executor and loader implementation determine where that work runs. Review queue capacity and origin limits instead of assuming CompletableFuture creates unlimited useful concurrency.
What does a failed refresh return?
Official fact: a refresh failure retains the old value, logs the exception, and does not replace it with the failed result. Refresh uses an executor; the default is the common fork-join pool unless configured otherwise. The refresh contract also deduplicates explicit refresh requests already in flight.
Original exercise: V1 is present. A refresh starts and fails before expiration. A caller that was served V1 did not receive an origin exception as its successful cache lookup result. A monitoring system that only counts failed foreground HTTP responses can consequently miss the refresh failure.
Contrast that with a failed initial load: there is no prior value to serve. The caller's future can complete exceptionally, and the failed cached entry is removed. Do not present “keep stale data” as a blanket rule for every loading error.
Engineering inference: track foreground misses, refresh failures, source latency, and acceptable data age separately. A high hit rate can coexist with unhealthy refreshes. If the application needs a fallback, specify its maximum age and the decisions for which it is allowed.
How would you test time and concurrency without sleeps?
Official fact: Caffeine supports a Ticker for controlled time and documents deterministic test techniques using controllable executors and maintenance. See Testing.
Original exercise: Create a cache with a fake ticker. Advance beyond refresh age, then assert that advancing time alone did not call the loader. Perform a get, hold the reload future, and assert the old value is returned. Complete with V2 and verify replacement. In a separate fixture, fail the future and verify V1 remains available within the specified age boundary.
For coalescing, start two gets against an unresolved loader future and assert the invocation count before resolving it. For retry after failure, complete exceptionally, establish removal, then make a later get. These tests examine transitions rather than relying on the machine being fast enough.
Use a separate test for expiration and cleanup. Advancing time, checking retrieval, running maintenance, and inspecting removal observations each prove a different part of the contract. Document that separation in your interview explanation.
What should an invalidation race mean?
Original exercise: A source update invalidates key K while an older load is still in progress. Before predicting the final value, specify the exact API calls, cache version, and whether the application also installs the new value. “We invalidated it” alone does not establish the result of every possible interleaving.
Engineering inference: define the freshness invariant first, then build a controlled test for that particular race. Hold the old source result, commit the source update, invoke invalidation, and release the old result. Verify the subsequent retrieval against the contract. If the application requires stronger protection than its selected sequence provides, consider versioned keys or version-aware values, with explicit rules for accepting replacements.
Do not turn a successful single-threaded invalidation test into a concurrency guarantee. Capture loader start, source version, invalidation, completion, and caller result in the test ledger. Those five events make a failing trace reviewable.
Related questions to rehearse
Some use other languages; apply their reasoning to Caffeine explicitly. They are related practice, not evidence that their employers use this library.
| Question | Caffeine-specific follow-up |
|---|---|
| Debug a cache incident end-to-end | Separate stale hits from failed reloads. |
| Hand-Write a Bounded Cache for a Database-Backed Backend Service | Compare count and weighted capacity. |
| Review an Asynchronous Cache for Races, Memory Growth, and Thread Safety | Trace the shared future lifecycle. |
| Build a Custom CompletableFuture: Async Primitive and Parallel Array Processing | Define completion and failure ownership. |
| Make a Dict-Based Memoization Cache Safe for Concurrent Threads | Explain why check-then-load is a race. |
Try the cache incident exercise with a timeline containing one miss, one refresh, and one failure. Explain each caller result before proposing a policy change.