Negative Caching for Repeated Failures

Caching usually brings successful results to mind: load a value once, keep it for a while, and avoid repeating expensive work. But repeated failures can be just as expensive as repeated successes.

Suppose a service receives thousands of requests for an object that does not exist. If every request queries the same downstream system, the absence of that object becomes a source of load. The same pattern appears with invalid identifiers, unavailable optional resources, failed name lookups, and other outcomes that are expensive to rediscover but unlikely to change immediately.

Negative caching means temporarily caching an unsuccessful result so later equivalent requests can reuse that knowledge. The idea is simple, but using it safely requires care: a stale success is inconvenient, while a stale failure can hide a resource that has just become available.

This article builds a practical model for negative caching, shows what belongs in a negative cache entry, and explains how to choose boundaries that reduce repeated work without turning temporary failures into persistent ones.

A cache can remember absence, not just presence

Consider a service that looks up a customer profile by ID:

getProfile("C42")
    -> profile store
    -> NOT_FOUND

A second request arrives a few milliseconds later for the same ID. Without negative caching, the service asks the profile store again and receives the same answer.

request 1 -> store -> NOT_FOUND
request 2 -> store -> NOT_FOUND
request 3 -> store -> NOT_FOUND

If C42 is a malformed or permanently unused identifier, this can continue indefinitely. A crawler, retrying client, stale link, or application bug can turn one missing key into a steady stream of identical downstream work.

A negative cache changes the path:

request 1 -> store -> NOT_FOUND -> cache negative result
request 2 -> negative cache -> NOT_FOUND
request 3 -> negative cache -> NOT_FOUND

The cache is not claiming that C42 can never exist. It is making a narrower statement: the last authoritative lookup said it did not exist, and that observation is still fresh enough to reuse.

That time boundary is the central design decision.

Treat a negative result as data with a lifetime

A useful mental model is to treat each negative cache entry as a small fact with three parts:

key       = customer:C42
outcome   = NOT_FOUND
expiresAt = 12:00:10

The key identifies which requests are equivalent. The outcome records what happened. The expiry limits how long the system is willing to trust that observation.

This model prevents a common mistake: treating every error as if it were the same kind of negative result. They are not.

A NOT_FOUND response may describe the state of the requested resource. A timeout describes the state of an attempt to communicate with a dependency. An authorization failure may depend on the caller. A validation error may depend entirely on the input. Those outcomes have different causes, so they need different cache policies.

Before caching a failure, ask two questions:

  1. Would another equivalent request probably receive the same outcome for a short period?
  2. Is it acceptable for that request to receive the cached outcome even if the underlying state changes before the entry expires?

If either answer is no, negative caching is a poor fit.

Start with the safest case: a stable miss

The smallest useful application is a lookup where absence is relatively stable and the cost of checking is meaningful.

Imagine an application resolves a document ID before doing more work:

resolveDocument(id):
    cached = negativeCache.get(id)
    if cached exists:
        return NOT_FOUND

    document = repository.find(id)
    if document does not exist:
        negativeCache.put(id, NOT_FOUND, ttl = short_duration)
        return NOT_FOUND

    return document

This pseudocode demonstrates the basic mechanism, not a production-ready cache implementation. Real systems also need to consider concurrency, cache capacity, invalidation, observability, and what happens when the cache itself is unavailable.

Notice what is not cached here: arbitrary repository errors. If repository.find times out, the code does not convert that timeout into NOT_FOUND. Doing so would erase an important distinction. The document may exist; the service simply failed to determine that fact.

That distinction matters because callers often make different decisions based on the failure class. They may stop retrying after a genuine missing-resource response but retry a transient dependency failure.

Cache keys must include everything that changes the answer

A negative cache is correct only if requests mapped to the same key are genuinely equivalent for the cached outcome.

Suppose document visibility depends on the requesting tenant. This key is unsafe:

key = documentId

Tenant A may be unable to see document D7 while tenant B can. If the service caches A’s NOT_FOUND under D7, B may receive a false negative.

The key needs the relevant context:

key = tenantId + documentId

The same rule applies to locale, API version, permissions, feature configuration, query parameters, or any other input that can legitimately change the answer.

Do not respond by putting every request detail into the key. That can make the cache nearly useless and can create unbounded key cardinality. Instead, identify the smallest set of inputs that determines the result you are caching.

There is also a security consequence. If authorization affects the result, sharing negative entries across security contexts can reveal or hide information incorrectly. In that situation, either partition the cache by the relevant authorization scope or avoid caching that outcome.

Choose the lifetime from the cost of being wrong

A negative cache entry creates a period during which the service may return an old failure without checking the source again. The time-to-live, or TTL, therefore controls a direct trade-off.

A longer TTL reduces more downstream work but extends the window in which a newly valid resource can remain invisible. A shorter TTL discovers changes sooner but allows repeated misses to reach the dependency more often.

Consider a profile that users can create at any moment. If a missing profile is cached for one hour, a user who creates it immediately after a miss may keep receiving NOT_FOUND for nearly an hour. That is usually a poor user experience.

If the negative TTL is five seconds instead, the maximum stale window caused by that entry is much smaller. Whether five seconds is acceptable depends on the product and the operation. There is no universal negative-cache TTL.

A useful way to choose one is to reason from the tolerated stale-failure window:

How long may a newly available result remain hidden?
        -> choose a TTL no longer than that window

Then check whether that TTL still removes enough repeated work to justify the mechanism. If not, negative caching may not solve the real problem.

Invalidation can shorten the stale-failure window

Expiry is the simplest invalidation policy because it requires no coordination. Sometimes the application also knows exactly when a negative result becomes obsolete.

Suppose createProfile("C42") succeeds in the same service that owns the negative cache. The service can remove the negative entry for C42 immediately after the successful creation becomes authoritative.

createProfile("C42")
    -> persist profile successfully
    -> remove negative cache entry for C42

This does not eliminate the need for a TTL. Invalidation messages can be missed, processes can restart, and multiple cache instances may not observe the same event at the same time. Expiry still provides a bound on stale entries.

Think of explicit invalidation as an optimization that can make a safe TTL more responsive, not as a reason to make negative entries permanent.

Not every failure should be negatively cached

The most dangerous implementation is a broad rule such as “cache errors for five minutes.” It reduces traffic by making failures sticky.

Transient dependency failures

A timeout, connection reset, or temporary overload says that an attempt failed. It does not necessarily say anything durable about the requested resource.

Caching that failure may prevent recovery from being observed. If a dependency is unavailable for 200 milliseconds but callers receive a cached error for five minutes, the cache has extended a brief incident far beyond the original failure.

If a system deliberately caches transient failures, the lifetime should normally be very short and the behavior should be treated as an overload or resilience mechanism with explicit semantics. It should not be confused with caching a stable absence.

Rate-limit responses

A rate-limit response is usually tied to a policy, identity, quota window, or server state. Reusing it under a broad resource key can be wrong for another caller or after the limit resets. Respecting an explicit retry time is a different mechanism from assuming the requested data is negatively cacheable.

Authorization failures

An authorization result can depend on user identity, roles, policy versions, resource ownership, or credentials. Caching it without all relevant context can deny valid access after permissions change or, worse, reuse one caller’s result for another caller.

Validation failures

Deterministic validation can sometimes be cheap enough that caching adds more complexity than it saves. If checking an identifier format takes microseconds, storing every invalid identifier may consume memory while avoiding almost no meaningful work.

Negative caching is useful when rediscovering the negative result has a real cost.

Protect the cache from hostile or accidental key growth

Positive caches often have natural reuse: popular objects are requested repeatedly. Negative keys can have the opposite shape. An attacker, scanner, broken client, or random-ID workload can generate a new missing key on every request.

If the negative cache accepts unlimited entries, the mechanism intended to protect the service can become a memory problem.

Use a bounded cache with an eviction policy appropriate to the application. Also consider whether the key space can be normalized. For example, malformed identifiers can often be rejected before they reach the cache at all.

This ordering is useful:

cheap input validation
        -> negative cache lookup
        -> expensive authoritative lookup

Validation prevents obviously bad requests from consuming cache space. The negative cache then handles well-formed requests whose absence is expensive to establish.

Metrics should separate positive hits, negative hits, misses, and evictions. A rising negative-hit rate may indicate that the cache is successfully absorbing repeated misses, but it may also reveal a stale client or bad link generating those misses. The cache reduces the operational cost; it should not hide the underlying behavior.

Negative caching and request coalescing solve different moments

Negative caching is easy to confuse with request coalescing because both can reduce duplicate work.

Request coalescing shares one operation while it is still running. Negative caching reuses an unsuccessful outcome after an operation has finished.

coalescing:
request A ----\
request B -----+-> one in-flight lookup -> NOT_FOUND
request C ----/

negative caching:
later request -> cached NOT_FOUND

The two mechanisms can be combined. Coalescing prevents a burst of simultaneous misses from launching many identical lookups. Negative caching prevents later requests from immediately repeating the same failed lookup.

They also have different correctness risks. Coalescing normally shares the result only for the duration of one operation. Negative caching deliberately extends the lifetime of that result, so stale-failure semantics become part of the design.

A practical decision process

Before adding a negative cache, describe the outcome you want to cache in one sentence. For example: “For a given tenant and document ID, remember an authoritative NOT_FOUND response for up to ten seconds.”

That sentence exposes most of the design:

  • Given tenant and document ID defines the equivalence key.
  • Authoritative NOT_FOUND excludes timeouts and unrelated failures.
  • Up to ten seconds states the stale-failure window.

Then test the boundary cases. What if the document is created one millisecond after the entry is stored? What if permissions change? What if callers generate millions of unique missing IDs? What if the cache disappears? What if two requests miss the cache at the same time?

The system should still be correct if the cache is empty or unavailable. A negative cache is generally an optimization, not the source of truth. Losing it may increase downstream load, but it should not change which authoritative result is possible.

If cache loss would make the application semantically incorrect, the component is doing more than caching and should be designed and named accordingly.

When a simpler approach is better

Do not add negative caching merely because some requests fail.

If the negative check is cheap, repeat traffic is low, or state changes must become visible immediately, performing the authoritative lookup each time may be clearer and safer. A cache introduces key design, expiry behavior, capacity limits, metrics, and another stateful component to reason about.

Sometimes the better fix is earlier validation. If requests fail because identifiers have an invalid shape, reject them before any expensive lookup. If one broken client repeatedly requests a deleted resource, fixing that client may remove more load than adding a cache. If many callers arrive simultaneously but rarely return later, request coalescing may address the burst without introducing a stale-result window.

Choose negative caching when repeated equivalent failures are expensive, the failure is stable enough for a bounded period, and a short stale-failure window is acceptable.

Make the failure policy explicit

Negative caching works well when it is treated as a policy about a specific outcome, not as a generic error-handling shortcut.

For each cached failure, be able to state what makes two requests equivalent, which exact outcome is reusable, how long it may be reused, what can invalidate it early, and how cache growth is bounded. Those decisions determine whether the cache quietly saves work or quietly serves incorrect failures.

A good next step is to inspect one expensive lookup in your system that frequently returns the same negative result. Measure how often the same key repeats, then decide whether a short, bounded negative cache would reduce meaningful work without hiding changes longer than users can tolerate.