Caching in .NET is the cheapest way to make a slow dependency fast, and the easiest way to serve wrong data at scale. This guide is for backend developers who want to go beyond sprinkling IMemoryCache over slow queries. You will learn how IMemoryCache, IDistributedCache, Redis and HybridCache fit together, which caching patterns and invalidation strategies hold up in production, and how to design TTLs, keys and serialization, then prove it all works by measuring your hit ratio.

What Is Caching in .NET?#

Caching keeps a copy of expensive-to-produce data closer to where it is needed. A .NET application typically caches at three layers: HTTP responses at the edge or with ASP.NET Core output caching, application data inside your services, and pages in the database's own buffer pool. This guide covers the middle layer, application data caching. HTTP-level caching is covered in the output caching and rate limiting guide.

.NET gives you three abstractions for application data:

  • IMemoryCache stores object references in the process. It is the fastest option, but each server has its own copy.
  • IDistributedCache stores byte arrays in an external store such as Redis, SQL Server or PostgreSQL, so every instance sees the same data.
  • HybridCache combines both: an in-process first level (L1), an optional distributed second level (L2), stampede protection, tag-based invalidation and built-in serialization.

HybridCache ships in the Microsoft.Extensions.Caching.Hybrid package. It became generally available with version 9.3 in March 2025, is at 10.10 as of September 2026, and runs on .NET 8 and later as well as .NET Framework 4.6.2 and later. For new code, it should be your default starting point.

How Caching Works: Hit Ratio, Staleness and Failure Modes#

Every cache trades freshness for speed. Before writing code, answer three questions for each piece of cached data:

  1. How stale can it be? Product descriptions can lag by minutes; account balances usually cannot lag at all. This staleness budget sets your TTL and invalidation strategy.
  2. What does a miss cost? A miss that runs a 5 ms query is cheap. A miss that fans out to three services and a 2-second report is not, and concurrent misses on the same key can overload the source. This is a cache stampede.
  3. Who invalidates it? Either time does, with expiration, or your code does, with explicit removal after writes. Most production systems need both.

The hit ratio, hits divided by total lookups, tells you whether caching earns its complexity. A cache with a 30% hit ratio mostly adds latency and memory. Two-level caching changes the economics: L1 hits cost a dictionary lookup, L2 hits cost a network round trip and deserialization, and misses cost a trip to the source. Keep L1 lifetimes short, because other servers' L1 copies cannot be invalidated directly, and let L2 absorb most of the load.

Getting Started with HybridCache#

Register Redis as the distributed store, then add HybridCache, which automatically uses the registered IDistributedCache as its L2. Sharing one ConnectionMultiplexer avoids a second set of connections when you also call Redis directly.

C#
using Azure.Identity;
using Microsoft.Extensions.Caching.Hybrid;
using StackExchange.Redis;

var builder = WebApplication.CreateBuilder(args);

// One multiplexer per process, authenticated with Microsoft Entra ID
// (ConfigureForAzureWithTokenCredentialAsync comes from Microsoft.Azure.StackExchangeRedis)
var redisOptions = ConfigurationOptions.Parse(builder.Configuration["Redis:Endpoint"]!);
await redisOptions.ConfigureForAzureWithTokenCredentialAsync(new DefaultAzureCredential());
var redis = await ConnectionMultiplexer.ConnectAsync(redisOptions);
builder.Services.AddSingleton<IConnectionMultiplexer>(redis);

// L2: Redis behind IDistributedCache, keys prefixed per application
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.ConnectionMultiplexerFactory = () => Task.FromResult<IConnectionMultiplexer>(redis);
    options.InstanceName = "shop:";
});

// L1 + L2 with stampede protection and tags
builder.Services.AddHybridCache(options =>
{
    options.MaximumPayloadBytes = 1024 * 1024; // the default: 1 MB
    options.DefaultEntryOptions = new HybridCacheEntryOptions
    {
        Expiration = TimeSpan.FromMinutes(10),         // overall and L2 lifetime
        LocalCacheExpiration = TimeSpan.FromMinutes(1) // L1 lifetime on each server
    };
});

Services then ask for data with GetOrCreateAsync. The stateful overload with a static lambda avoids allocating a closure on every call, and tags let one write invalidate every entry that depends on it:

C#
using Microsoft.Extensions.Caching.Hybrid;

public sealed class ProductCatalog(HybridCache cache, CatalogDb db)
{
    private static readonly HybridCacheEntryOptions ProductEntry = new()
    {
        Expiration = TimeSpan.FromMinutes(30),
        LocalCacheExpiration = TimeSpan.FromMinutes(2)
    };

    public ValueTask<ProductDto> GetAsync(int id, CancellationToken ct) =>
        cache.GetOrCreateAsync(
            CacheKeys.Product(id),
            (db, id),
            static async (state, token) => await state.db.LoadProductAsync(state.id, token),
            ProductEntry,
            tags: [$"product:{id}"],
            cancellationToken: ct);

    // Invalidate: after the write commits, drop every entry tagged with this product
    public async Task ChangePriceAsync(int id, decimal price, CancellationToken ct)
    {
        await db.UpdatePriceAsync(id, price, ct);
        await cache.RemoveByTagAsync($"product:{id}", ct);
    }

    // Write-through: after the write commits, store the new value directly
    public async Task<ProductDto> RenameAsync(int id, string name, CancellationToken ct)
    {
        var product = await db.RenameProductAsync(id, name, ct);
        await cache.SetAsync(CacheKeys.Product(id), product, ProductEntry,
            tags: [$"product:{id}", $"category:{product.CategoryId}"], cancellationToken: ct);
        return product;
    }
}

IMemoryCache: Fast, Local and Easy to Misuse#

IMemoryCache is a thread-safe dictionary with expiration. It stores references, not copies, so a caller who mutates a cached object mutates it for everyone. It supports absolute expiration, sliding expiration, priorities and eviction callbacks. Three behaviors surprise people:

  • Sliding expiration alone can keep an entry forever. A frequently read item never expires, so always combine sliding with absolute expiration.
  • There is no eviction under memory pressure. The cache grows until you set SizeLimit. Once a limit is set, every entry must declare a Size, so give size-limited caches their own instance rather than configuring the shared one.
  • GetOrCreateAsync does not prevent stampedes. Concurrent misses on the same key all run the factory. HybridCache solves this; with plain IMemoryCache you need your own locking.
C#
using Microsoft.Extensions.Caching.Memory;

// A dedicated, bounded cache instead of the shared IMemoryCache singleton
public sealed class ExchangeRateCache(IExchangeRateApi api) : IDisposable
{
    private readonly MemoryCache _cache = new(new MemoryCacheOptions
    {
        SizeLimit = 10_000,     // units are yours to define; here, one per entry
        TrackStatistics = true  // enables GetCurrentStatistics()
    });

    public async Task<decimal> GetRateAsync(string from, string to, CancellationToken ct)
    {
        var key = $"fx:{from}:{to}";
        if (_cache.TryGetValue(key, out decimal rate))
        {
            return rate;
        }

        rate = await api.GetRateAsync(from, to, ct);
        _cache.Set(key, rate, new MemoryCacheEntryOptions
        {
            Size = 1,
            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5),
            SlidingExpiration = TimeSpan.FromMinutes(1)
        });
        return rate;
    }

    public MemoryCacheStatistics? Statistics => _cache.GetCurrentStatistics();

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

When the limit is exceeded, the cache compacts by a default of 5%, removing expired entries first, then low-priority and least recently used ones. IMemoryCache remains the right tool for small, process-local data such as configuration lookups, compiled templates or reference data that tolerates per-server differences.

IDistributedCache and Redis with StackExchange.Redis#

IDistributedCache is deliberately minimal: GetAsync, SetAsync, RefreshAsync and RemoveAsync on byte arrays with string keys. Implementations exist for Redis, SQL Server, PostgreSQL and Cosmos DB, and Microsoft recommends Redis for production. AddDistributedMemoryCache implements the interface in process, which is useful for tests but not distributed at all. Because the interface speaks bytes, serialization is your job:

C#
using System.Text.Json;
using Microsoft.Extensions.Caching.Distributed;

public static class DistributedCacheJson
{
    public static async Task<T?> GetJsonAsync<T>(
        this IDistributedCache cache, string key, CancellationToken ct = default)
    {
        var bytes = await cache.GetAsync(key, ct);
        return bytes is null ? default : JsonSerializer.Deserialize<T>(bytes);
    }

    public static Task SetJsonAsync<T>(
        this IDistributedCache cache, string key, T value, TimeSpan ttl,
        CancellationToken ct = default) =>
        cache.SetAsync(key, JsonSerializer.SerializeToUtf8Bytes(value),
            new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = ttl }, ct);
}

For anything beyond get and set, use StackExchange.Redis directly. Version 3.0, released in June 2026, rebuilt the I/O core and made RESP3 the default protocol while keeping the public API of 2.x, and 3.3 is current. The rules have not changed: create one ConnectionMultiplexer and share it, because it multiplexes all callers over a few connections, while GetDatabase() is a cheap pass-through you can call anywhere. Redis gives you atomic counters, hashes, sorted sets, conditional writes and pub/sub. The example below builds a small two-level cache in which pub/sub evicts local copies on every server after a write:

C#
using System.Globalization;
using Microsoft.Extensions.Caching.Memory;
using StackExchange.Redis;

public sealed class PriceCache(IConnectionMultiplexer redis, IMemoryCache local)
{
    private static readonly RedisChannel Invalidations = RedisChannel.Literal("prices:invalidate");

    // Call once at startup on every instance
    public Task SubscribeAsync() =>
        redis.GetSubscriber().SubscribeAsync(Invalidations,
            (_, sku) => local.Remove($"price:{sku}"));

    public async Task<decimal?> GetAsync(string sku)
    {
        if (local.TryGetValue($"price:{sku}", out decimal cached))
        {
            return cached;
        }

        var value = await redis.GetDatabase().StringGetAsync($"price:{sku}");
        if (value.IsNull)
        {
            return null;
        }

        var price = decimal.Parse(value.ToString(), CultureInfo.InvariantCulture);
        local.Set($"price:{sku}", price, TimeSpan.FromSeconds(30)); // short L1 backstop
        return price;
    }

    public async Task SetAsync(string sku, decimal price)
    {
        await redis.GetDatabase().StringSetAsync(
            $"price:{sku}", price.ToString(CultureInfo.InvariantCulture), TimeSpan.FromHours(1));
        await redis.GetSubscriber().PublishAsync(Invalidations, sku);
    }
}

Pub/sub is fire-and-forget, so a server that is disconnected during a publish misses the message. The short local TTL is the backstop that bounds staleness.

Licensing now matters when you pick a server. Redis 7.2 and earlier were BSD-licensed, 7.4 moved to a choice of RSALv2 or SSPLv1, and Redis 8.0 and later add AGPLv3 as a third option. Valkey, a BSD-licensed fork created just before the license change and run under the Linux Foundation, speaks the same protocol. StackExchange.Redis works with both, and managed services such as Azure Managed Redis handle licensing for you.

HybridCache Deep Dive: L1, L2, Tags and Stampede Protection#

HybridCache looks for a value in L1 (a MemoryCache), then in L2 (your IDistributedCache), and only then calls your factory. It writes the result back to both levels. Several design details matter in production:

  • Stampede protection. Within one HybridCache instance, only one caller per key runs the factory while the others await its result. Across a server farm each instance may still run the factory once, which is usually acceptable.
  • Tags are logical. RemoveByTagAsync does not delete matching entries; it records that entries created before now with that tag are invalid, so they count as misses and age out naturally. The reserved tag * invalidates everything.
  • Invalidation reaches L1 only locally. RemoveAsync and RemoveByTagAsync clear the current server's L1 and the L2 store, but other servers keep their L1 copies until LocalCacheExpiration elapses. That setting is your cross-server staleness bound.
  • Serialization is built in. string and byte[] pass through; everything else uses System.Text.Json unless you register a serializer with AddSerializer or AddSerializerFactory, for example for protobuf. With Native AOT, use source-generated serialization.
  • Limits protect you. Payloads above MaximumPayloadBytes (1 MB by default) and keys longer than MaximumKeyLength (1,024 characters by default) are not cached.
  • Instances can be reused. By default, every L1 hit deserializes a fresh copy, which is safe for mutable types. Mark a type sealed and [ImmutableObject(true)] to let the cache return the same instance.

Per-call flags let you bypass levels without separate code paths. HybridCacheEntryFlags includes DisableLocalCache and DisableDistributedCache, finer-grained variants such as DisableLocalCacheRead and DisableDistributedCacheWrite, DisableUnderlyingData for cache-only lookups, and DisableCompression. For example, use DisableLocalCache for data that must be consistent across servers but is still worth keeping out of the database.

Caching Patterns: Cache-Aside, Read-Through and Write-Through#

PatternHow it worksStrengthsRisks
Cache-asideThe app checks the cache, loads from the source on a miss and stores the resultSimple; caches only what is readStampedes and stale data after writes unless you invalidate
Read-throughThe cache calls a loader on misses (GetOrCreateAsync)Centralized loading; HybridCache adds stampede protectionSame staleness trade-offs as cache-aside
Write-throughEvery write updates the source and then the cacheReads right after writes hit fresh dataWrites get slower; racing writers can store older values
Write-behindWrites go to the cache and are flushed to the source laterVery fast writes; absorbs burstsData loss on cache failure; hard to reason about

HybridCache.GetOrCreateAsync gives you read-through semantics with stampede protection, and it is the right default. Add write-through with SetAsync for data that is read immediately after it changes, such as a user's own profile. Reserve write-behind for data you can afford to lose, such as view counters, and flush it with a background service.

Cache Invalidation Strategies That Work#

Invalidation is where most caching bugs live. Combine these strategies deliberately:

  • Time-based expiration is the safety net. Every entry needs a TTL, even when you also invalidate explicitly.
  • Delete after commit. Remove or re-set the entry only after the database transaction commits, never before, or a concurrent reader can repopulate the old value.
  • Tag-based invalidation handles one change that affects many keys, such as a category rename that touches product pages, listings and search results.
  • Versioned keys make invalidation free for bulk changes: bump a version segment in the key, and old entries become unreachable and expire on their own.
  • Event-driven invalidation keeps separate services in sync. Publish an event after the write, through an outbox, a message broker or a database change feed, and let each cache owner evict its entries.

Remember the race: a slow reader can load old data from the database, pause, and write it to the cache after your invalidation. Short TTLs bound the damage, and for critical data a version or timestamp check before writing to the cache prevents it.

Designing TTLs, Cache Keys and Serialization#

Derive TTLs from the staleness budget, not from habit. Use longer TTLs for reference data and short ones for volatile data, and add jitter so entries written together don't expire together and hit the source in a synchronized burst. Cache negative results, like "product not found", briefly to protect the database from repeated lookups of missing keys.

Keys need the same care as database schemas. Namespace them by application and entity, include a version segment, and include every input that changes the result: tenant, culture, user or permission scope. Never build keys from unbounded raw user input; hash it instead.

C#
using System.Security.Cryptography;
using System.Text;

public static class CacheKeys
{
    // Bump when the cached shape or meaning changes; old entries simply age out
    private const string Version = "v3";

    public static string Product(int id) => $"catalog:{Version}:product:{id}";

    public static string Search(string tenantId, string culture, string query) =>
        $"catalog:{Version}:search:{tenantId}:{culture}:{Hash(query)}";

    // Free-form input becomes a short, fixed-length, safe key segment
    private static string Hash(string value)
    {
        var normalized = value.Trim().ToLowerInvariant();
        return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(normalized)))[..16];
    }
}

public static class Ttl
{
    // Spread expirations by up to 10% so bulk-loaded entries don't expire together
    public static TimeSpan WithJitter(TimeSpan baseTtl, double spread = 0.1) =>
        baseTtl + TimeSpan.FromSeconds(baseTtl.TotalSeconds * spread * Random.Shared.NextDouble());
}

Serialization determines both payload size and CPU cost on every L2 hit. Cache DTOs shaped for readers, not EF Core entities with navigation properties, and use System.Text.Json source generation for hot types; the System.Text.Json guide covers the details. On the Redis side, set a maxmemory limit and an eviction policy such as allkeys-lru so the server evicts cold keys instead of rejecting writes.

Measuring Hit Ratio and Cache Health#

You cannot tune what you do not measure. Track hit ratio, factory calls, payload sizes and evictions per cache.

  • MemoryCache: with TrackStatistics = true, GetCurrentStatistics() returns total hits, total misses, the current entry count and the estimated size. .NET 11 goes further and publishes dotnet.cache.requests, dotnet.cache.entries, dotnet.cache.evictions and dotnet.cache.estimated_size from the Microsoft.Extensions.Caching.Memory.MemoryCache meter.
  • HybridCache: the Microsoft-Extensions-HybridCache event source publishes counters such as total-local-cache-hits, total-distributed-cache-misses, total-data-query and total-stampede-joins, which you can watch with dotnet-counters.
  • Redis: INFO stats reports keyspace_hits, keyspace_misses and evicted_keys for the whole server.

On .NET 10, bridge MemoryCache statistics into OpenTelemetry yourself:

C#
using System.Diagnostics.Metrics;
using Microsoft.Extensions.Caching.Memory;

builder.Services.AddMemoryCache(options => options.TrackStatistics = true);
builder.Services.AddSingleton<CacheMetrics>(); // resolve once at startup to register

public sealed class CacheMetrics
{
    public CacheMetrics(IMeterFactory meterFactory, IMemoryCache cache)
    {
        var meter = meterFactory.Create("Shop.Caching");

        meter.CreateObservableCounter("shop.cache.local.hits",
            () => cache.GetCurrentStatistics()?.TotalHits ?? 0);
        meter.CreateObservableCounter("shop.cache.local.misses",
            () => cache.GetCurrentStatistics()?.TotalMisses ?? 0);
        meter.CreateObservableUpDownCounter("shop.cache.local.entries",
            () => cache.GetCurrentStatistics()?.CurrentEntryCount ?? 0);
    }
}

Compute the hit ratio in your dashboard as the rate of hits divided by the rate of hits plus misses, and alert on sudden drops, which usually signal a key-format change, an eviction storm or a failing L2. The OpenTelemetry guide shows how to export these meters.

Best Practices#

  • Start with HybridCache. It gives you L1, L2, stampede protection and tags with one API.
  • Give every entry a TTL. Explicit invalidation fails eventually; expiration bounds the damage.
  • Keep L1 short. Its lifetime is your cross-server staleness bound.
  • Invalidate after commit. Never before the transaction completes.
  • Version your keys. Include tenant, culture and scope, and hash free-form input.
  • Cache DTOs, not entities. Smaller payloads and no accidental lazy loading.
  • Measure hit ratio per cache. Remove caches that do not earn their keep.

Common Pitfalls#

  • Caching mutable objects in IMemoryCache. Callers mutate the shared instance.
  • Sliding expiration without an absolute limit. Hot entries never refresh.
  • Unbounded memory caches. Without SizeLimit, the cache grows until the process runs out of memory.
  • A ConnectionMultiplexer per request. Connection storms and socket exhaustion follow.
  • Treating Redis as a database. Evictions and failovers can drop data; keep the source of truth elsewhere.
  • Forgetting per-user data in keys. One user's cached response is served to another.

IMemoryCache vs IDistributedCache vs HybridCache: When to Use Each#

AspectIMemoryCacheIDistributedCache (Redis)HybridCache
Where data livesProcess memoryExternal storeL1 in process, optional L2 external
Shared across instancesNoYesL2 yes, L1 no
Stored valueObject referencebyte[]Serialized copy by default
Stampede protectionNoNoYes, per instance
Tag invalidationNoNoYes, logical
SerializationNoneYour responsibilityBuilt in, pluggable
Best forSmall per-server dataSharing simple values across serversMost application data caching

Frequently Asked Questions#

Should I replace IMemoryCache with HybridCache everywhere?#

Not everywhere. HybridCache is the better default for application data because it adds stampede protection, tags and an optional distributed level. IMemoryCache still fits tiny process-local data where object identity matters or serialization would be wasteful.

Does HybridCache require Redis?#

No. Without a registered IDistributedCache, it works as an in-process cache with stampede protection and tags. Adding Redis, SQL Server or PostgreSQL as L2 later requires only a registration change.

How does HybridCache invalidate entries on other servers?#

It clears L2 and the current server's L1, but other servers keep their L1 copies until LocalCacheExpiration elapses. Keep that value short for data that changes, or disable the local cache for entries that must be consistent across servers.

How do I choose a TTL?#

Start from how stale the data may be, then balance it against the cost of a miss. Add jitter to avoid synchronized expirations, cache negative results briefly, and adjust using measured hit ratios.

Is Redis licensing a concern for .NET teams?#

It can be. Redis 8 is available under RSALv2, SSPLv1 or AGPLv3, which matters if you redistribute or host it for others. Managed services handle this for you, and Valkey offers a BSD-licensed, protocol-compatible alternative that works with StackExchange.Redis.

Summary#

  • Pick the layer first: HTTP output caching, application data caching or database tuning.
  • Use HybridCache by default for L1 and L2 caching with stampede protection and tags.
  • Keep IMemoryCache bounded, and always combine sliding with absolute expiration.
  • Share one ConnectionMultiplexer, and use Redis data structures and pub/sub when get and set are not enough.
  • Invalidate after commit, keep TTLs as a safety net, and version your keys.
  • Measure hit ratio and evictions continuously; remove caches that don't pay off.

Further Reading#