Negative Caching in C#: Cache Your Misses, Not Just Your Hits

I run a public API at MySafeInfo. While looking at database activity, I noticed how much work went into looking up API keys that did not exist: typos, old keys, and invalid values sent by bots. Valid keys were already cached. Repeated requests for a bad key kept going back to SQL Server.

The reason was easy to miss. My lookup followed this pattern:

public async Task<ApiKey?> GetKeyAsync(string key)
{
    if (_cache.TryGetValue(key, out ApiKey? cached))
        return cached;

    var apiKey = await _repository.GetKeyAsync(key);

    if (apiKey is not null)
        _cache.Set(key, apiKey, TimeSpan.FromMinutes(5));

    return apiKey;
}

The null check looks reasonable. Why store something you did not find? Because the absence is useful information too. Without it, the next request for the same nonexistent key repeats the database lookup.

Negative caching means keeping that result for a while. With Microsoft.Extensions.Caching.Memory, a cached value can be null. The important distinction is the Boolean returned by TryGetValue: true means an entry was found, even if its value is null. Checking only the returned value would lose that distinction.

Here is the revised method. The one-minute and five-minute lifetimes are examples, and this assumes the caller has already checked the key's expected format and bounded its length:

public async Task<ApiKey?> GetKeyAsync(string key)
{
    if (_cache.TryGetValue(key, out ApiKey? cached))
        return cached; // A cached null is a known miss.

    var apiKey = await _repository.GetKeyAsync(key);

    var options = new MemoryCacheEntryOptions()
        .SetAbsoluteExpiration(apiKey is null
            ? TimeSpan.FromMinutes(1)
            : TimeSpan.FromMinutes(5))
        .SetSize(1);

    _cache.Set(key, apiKey, options);

    return apiKey;
}

Once the miss is stored, later requests for that key can return without another database call until the entry expires or is evicted. The repository must reserve null for a completed lookup that found no record. A timeout or database failure should remain an error, rather than becoming a cached claim that the key does not exist.

I give misses a shorter lifetime because absence can change. If a key is created while a miss is cached, this method can keep returning null for the rest of that entry's lifetime. Absolute expiration bounds that delay; repeated requests do not extend it. If new keys must work immediately, their creation needs to coordinate with cache invalidation, including lookups already in flight.

Positive results need thought too. An API key might be revoked, expire, or have its permissions changed. Five minutes is not automatically an acceptable delay for those changes. Choose the lifetime and invalidation policy around the authorization rules, and continue checking expiry and other applicable restrictions when using a cached record.

The cache also needs a limit. For this integration I use a dedicated cache for API keys, which avoids imposing size requirements on unrelated code. Microsoft warns against adding a size limit to a shared cache unless every writer supplies an entry size.

using Microsoft.Extensions.Caching.Memory;

public sealed class ApiKeyCache : IDisposable
{
    public MemoryCache Cache { get; } = new(
        new MemoryCacheOptions
        {
            SizeLimit = 10_000
        });

    public void Dispose() => Cache.Dispose();
}

Register the wrapper as a singleton in Program.cs:

builder.Services.AddSingleton<ApiKeyCache>();

Inject ApiKeyCache into the lookup service and assign its Cache property to _cache. The dependency injection container owns the wrapper and disposes it at shutdown. ApiKey and _repository represent the application's existing model and data access code.

With every entry assigned a size of 1, the limit counts entries, including cached misses. It is not a byte limit, which is another reason to bound the length of incoming keys. An addition that would exceed the limit is rejected; the .NET implementation also schedules background compaction, which can evict existing entries. The method still returns the repository result even if the cache does not retain it.

There are two limits to what this small change buys you:

  • It helps with repeated keys. A different random key on every request still requires a lookup. That traffic can also fill the cache with misses and displace useful entries. Input validation and rate limiting still matter.
  • It does not combine simultaneous lookups. Several requests can all miss the cache before the first one stores its result. Each application instance also has its own memory cache. This reduces repeated work; it does not guarantee one database call per key per minute across the service.

The change that started this was just removing a null check. The useful question was why the database kept answering something the application had already learned.